Compare commits

..
363 Commits
Author SHA1 Message Date
ayozetr 74ed838daf Docs: line references after the changes above 2026-10-29 17:43:35 +00:00
ayozetr 976027ea53 Credits: iannitram, molnar-a and gibs2193; what Awaken, Mrsuss60 and Nahnahchi added since 0.51-linux.18
iannitram's FSR 3.1 memory-type fix, molnar-a's Steam Deck crash report
and test (#35), gibs2193's mouse camera report (#31); Awaken's camera
motion check, Mrsuss60's upscaler image changes and Nahnahchi's reports
of black effects, rainbow textures and memory-manager crashes. In
docs/CREDITS.md and the launcher's About, in every language.
2026-10-29 17:43:27 +00:00
ayozetranddeadinside 916bcd1ceb Image clears: one layer of a cube or array too
The point lights' shadow cube is cleared a face at a time. A clear of
one layer was not recognised, so the game's shader wrote only memory and
the face kept older shadows that could show as wedges of light. A clear
whose range is exactly one layer of a single-level array or cube image
now clears that layer, unless the image holds CPU writes not uploaded
yet (a clear drops that mark for the whole image). Seen here: 64x64
cubes, all six faces, in the Hunter's Dream. From upstream 0.7.

Co-Authored-By: deadinside <[email protected]>
2026-10-29 17:43:27 +00:00
ayozetrandiannitram 8ffbf6b5df FSR 3.1: never a protected memory type unless asked for
RADV lists device-local protected memory types after the plain ones on
some GPUs (Ryzen Z1 Extreme / PHOENIX: ROG Ally, Legion Go), and
findMemoryTypeIndex kept the last match, so FSR 3.1 put its resources
in protected memory the device never enabled: full-screen colour noise.
A third patch to FSR-Vulkan, from upstream bbport 0.7.

Co-Authored-By: iannitram <[email protected]>
2026-10-29 17:43:19 +00:00
ayozetrandAwaken 6530aa1641 Camera motion: no NaN, Inf or wild vectors into the upscaler
A camera whose matrices are not finite is not used, and the shader
zeroes NaN/Inf motion and clamps it to four times the render size, so
one bad frame cannot poison the upscaler's history. From Awaken's
Lantern Engine 0.9.38.

Co-Authored-By: Awaken <[email protected]>
2026-10-29 17:43:19 +00:00
ayozetranddeadinside 5cc279af74 Texel buffers: no miss remembered over an image not drawn yet (#1)
The memo of texel-buffer ranges with no image also took ranges where an
image exists but the GPU has not drawn into it yet. Once the GPU drew
into it nothing changed the registry generation, so the buffer kept
reading stale memory: the motion blur frozen after lamp travel until
the next one. A miss is remembered only where no image is there at
all. From upstream 0.7 (its #308).

Co-Authored-By: deadinside <[email protected]>
2026-10-29 17:43:19 +00:00
ayozetr 12d76b3862 Texture collector: the memory check for images written by copies and clears too (#29, #32, #35)
The game's memory under a GPU-written image was taken only where a draw
or a compute shader wrote it. Copies, blits, resolves and clears mark
an image GPU-written too, without it, so the collector wrote such an
image back unchecked; a session with the check on still crashed at
0x208599f (#29). Image::NoteGpuBase takes it now on every path that
marks an image GPU-written.
2026-10-29 17:30:55 +00:00
ayozetr 9507c9c392 Logs: the version and the build on the first line
Issue reports rarely say which version or test build they ran, and a
test build's About page showed the release it was built on. The
AppImage now carries a BUILD file (commit, branch, date) next to
run.sh; run.sh prints the launcher's version with it, or with git's
commit and branch in a checkout, as the first line of every log, and
the About page shows the build after the version. The issue template
and the log guide say so.
2026-10-29 15:43:54 +00:00
ayozetr 65fad4c52c Texture collector: no write-back over memory the game reused (#25, #32, #35)
Past the critical mark the collector writes an old GPU-written image back
into the game's memory before freeing it (#13, #14). Neither memory model sees
every change there: the game's heap reusing the memory of an area left behind
(write tracking misses it, and the PC model tracks none). The image still
looked clean, its write-back overwrote the heap's bookkeeping, and the game
crashed later in its allocator (0x208599f, 0x20859c7, 0x208596a) or stopped
with "memory block seems to be improper or freed already". Cards with 8 GB or
less and the Steam Deck are past the critical mark often, so their players met
it: on the Deck at every second lamp travel (#35).

The game's memory under an image is now hashed when the GPU first writes it
since its last upload, and the write-back, tiled or linear, goes ahead only
if it is unchanged; otherwise the image is dropped, as BB_GC_TILE_BACK=0 does.
Reproduced on an RTX 3060 with BB_PC_MODEL=0 and BB_GC_BUDGET_MB=2500: a
crash at the first reload in 4 of 4 runs before, 18 reloads without one after.
With BB_PC_MODEL=1 the same images were written back over changed memory
(no crash there in 6 reloads), and are now dropped; the frames stay right.
2026-10-29 13:47:58 +00:00
ayozetr 28bb63315f Texture collector: no tiled write-back over memory the game reused (#25, #32)
Past the critical mark the collector tiles an old GPU-written image back into
the game's memory before freeing it (#13, #14). Write tracking misses some
changes there: the game's heap reusing the memory of an area left behind. The
image still looked clean, its write-back overwrote the heap's bookkeeping,
and the game crashed later in its allocator (0x208599f, 0x20859c7,
0x208596a) or stopped with "memory block seems to be improper or freed
already". Cards with 8 GB or less are past the critical mark often, so their
players met it, mostly on NVIDIA's memory model.

The game's memory under an image is now hashed when the GPU first writes it
since its last upload, and the write-back goes ahead only if it is unchanged;
otherwise the image is dropped, as BB_GC_TILE_BACK=0 does. Reproduced on an RTX 3060
with BB_PC_MODEL=0 and BB_GC_BUDGET_MB=2500: a crash at the first reload in
4 of 4 runs before, 12 reloads without one after (about 450 write-backs in
each run, 8 or 9 of them refused).
2026-10-29 13:47:28 +00:00
ayozetr 8d18daeb96 Mouse: the direct camera by default, as in bbport 0.6 (#31)
With the mouse moving the right stick, the camera turned at the game's
stick speeds, which differ sideways and up and down, with the stick's
dead zone and acceleration on top. mouse_camera now defaults to direct;
mouse_camera=stick keeps the old way. Earlier launchers wrote
mouse_camera=stick into bbport.ini with the other keys, so the launcher
turns it to direct once.
2026-10-29 00:10:38 +00:00
ayozetrandMrsuss60 1cdb240933 Upscaling: three of Mrsuss60's image changes, off by default for comparisons
From Mrsuss60's Windows port (466e8e7d), each behind its own switch so the game looks exactly as
before until they are compared in game:
- BB_TAA_RESPONSIVE=1: TAA history weights 0.88/0.75 instead of 0.98/0.85, fewer trails.
- BB_STABLE_MOTION_HASH=1: object motion identifies a draw's vertex streams without their buffers'
  addresses, for streams rewritten into a ring buffer at a new address each frame.
- BB_DEPTH_MIN_FILTER=1: depth-only reduced-size targets are resampled by the shader, keeping the
  nearest of each 2x2 when downscaling.
The rest of that commit, YCoCg and variance clipping and the 3x3 filter on by default, is left
out: measured here on 2026-10-02 they did not help, and two made the picture worse. Docs line
references follow the code again, the binding count change before this one included.

Co-Authored-By: Mrsuss60 <[email protected]>
2026-10-27 19:42:31 +00:00
ayozetr 55f9db9941 Shaders: per-mip image bindings counted the same everywhere a stage's bindings start
An image with a binding per mip level (DynamicIndex, on GPUs without
VK_AMD_shader_image_load_store_lod, such as NVIDIA's) takes one binding number per level in the
pipeline layout and in the SPIR-V, but Info::AddBindings and the rasterizer counted one: a stage
after it found its resources at numbers the layout does not have. Both now count as the layout
does (Info::ImageBindingCount; the T# is read only for such images). A stage whose start moves
gets a new permutation, since StageSpecialization compares the start, so the pipeline cache
stays valid. No such stage came up in a session in the Hunter's Dream; the counts there are
unchanged.
2026-10-27 19:23:59 +00:00
ayozetr 007c1e4b60 Shaders: images counted once per stage again when the next stage's bindings start
Counting every per-mip binding of a DynamicIndex image in Info::AddBindings (4e7c4a0d) agreed
with the pipeline layout, but the rasterizer still advances one per image when it writes the next
stage's descriptors: the next stage's shader would look for them where they are not written. That
mode is used on NVIDIA (no VK_AMD_shader_image_load_store_lod), so it goes back to how it was until
both sides change together. Pipeline key version 6 again, so caches are not rebuilt for nothing.
The phi pass ordering from the same commit stays.
2026-10-27 18:20:52 +00:00
ayozetr 1f4f56164c Launcher: version 0.51-linux.18 2026-10-27 18:14:14 +00:00
ayozetr df10280204 Texture collector: "maybe touched by the CPU" checked before a GPU-written image is dropped (#30)
An image whose page the CPU may have touched is only suspected dirty: RefreshImage compares a
hash and keeps what the GPU wrote when nothing changed. The collector treated the suspicion as
dirt and freed such images without downloading them, so they came back with the game's old bytes:
effects drawn by compute shaders turned black under memory pressure (#30). With a small VRAM
budget and three reloads, 63 GPU-written storage images were dropped this way; with the hash
checked first, 7, all of them really changed by the CPU.
2026-10-27 18:14:05 +00:00
ayozetr 42ec043c3e Texture cache: a GPU-written image is downloaded with all its mip levels (#30)
DownloadImageMemory copied mip level 0 only. When the collector freed a GPU-written linear image,
its smaller levels never reached the game's memory, and the image loaded again later had the
game's old bytes there: black wherever those levels were drawn, effects and particles and models
seen from afar (#30). Every level is now copied to its offset in the game's layout, as
WriteBackTiledImage already does for tiled images. With the old eviction rule
(BB_GC_KEEP_GPU_WRITTEN=0), a small VRAM budget and seven reloads, the player's armour and weapon
now stay correct; before, they turned black.
2026-10-27 18:12:00 +00:00
ayozetr fe740c843d Texture collector: GPU-written linear images wait for the critical mark (#30)
Under memory pressure, below the critical mark, the collector downloaded the linear images the
GPU had written and freed them; loaded again they came back black. On 8 GB cards, under pressure
the whole session, effects in the Hunter's Dream turned black after a few reloads (#30).
Reproduced here with a small VRAM budget (BB_GC_BUDGET_MB=2500) and seven reloads: the player's
armour and weapon went black. Linear GPU-written images now follow the tiled ones' rule, leaving
only past the critical mark and unused for a minute; with that the same run stays correct.
BB_GC_KEEP_GPU_WRITTEN=0 restores the old behaviour.
2026-10-27 18:12:00 +00:00
ayozetr 804651e418 Players' guide: published to the gh-pages branch by a script
GitHub Pages now serves the gh-pages branch. tools/publish_guide.sh builds the guide (strict)
and commits it there, replacing the Actions workflow, which cannot run on this account for now.
2026-10-27 02:30:33 +00:00
ayozetr 0e03a5c29e Players' guide on GitHub Pages, built from docs/
A guide site for players (https://ayozetr.github.io/bloodborne_linux/), made with MkDocs Material
from docs/ itself, so the guide and the repository's documents never disagree. A theme of its
own, "Hunter's Dream" (moonlit blue-grey, IM Fell English headings, EB Garamond text, a moon and
mist on the home page), with no artwork or typeface from the game. New pages for players:
getting started, Steam and the Steam Deck, getting a log (Game Mode too) and a FAQ from the
questions asked in issues and on Reddit. A hook points links to files outside docs/ at GitHub.

Online play now starts with Seamless Hunt, the default server, and links read as words instead
of file names. The workflow builds the site (strict) and publishes it when docs/, mkdocs.yml or
site/ change on main. The README and the launcher's About page link to it.
2026-10-27 02:27:45 +00:00
619826c20b Small fixes: data folder parents, a deleted image's slot, the map's zoom on resize
- The GPU library's data folders are created with their parents: a home without ~/.local/share
  ended the process. From Awaken's LanternEngine.
- FindImage's cache no longer reads the slot of an image deleted since it was cached. From
  Mrsuss60's Windows port.
- Hunter's Atlas keeps what the map covers when the window size changes: the zoom follows the UI
  scale. From Hunter's Atlas 3.0.

Co-Authored-By: Awaken <[email protected]>
Co-Authored-By: Mrsuss60 <[email protected]>
Co-Authored-By: MikeTat1988 <[email protected]>
2026-10-25 16:55:54 +00:00
ayozetrandAwaken 4e7c4a0d47 Shaders: every per-mip image binding counted; the phi pass in a fixed order
Info::AddBindings counted one binding per image, while the SPIR-V emitter and the pipeline layout
give an image with a binding per mip level (DynamicIndex) one per level: the next stage's
bindings could start inside it and read the wrong resources. Pipeline key version 7, so saved
pipelines are rebuilt once.

The phi simplification pass queued SCC groups in the hash table's order, which depends on block
addresses, so the same shader could compile to different SPIR-V from one run to the next. They
are queued in the SCCs' order now (upstream shadPS4 #5364). Both from Awaken's LanternEngine
(0.9.37 beta 5 and 7).

Co-Authored-By: Awaken <[email protected]>
2026-10-25 16:55:46 +00:00
ayozetrandAwaken 25093ba8cc Guest memory: a block promoted to VRAM is uploaded whole
With the PC memory model, a block given a VRAM copy was only marked CPU-modified, so just the
ranges later bindings named were uploaded; the rest of its slot kept the bytes of the block that
had it before. Vertex buffers are bound without a size and shaders read through the page table,
so draws could read them: stretched polygons and flashing lights, as EnsureResident already says
for reused slots. The whole block is now uploaded at the next packet. From Awaken's LanternEngine
(0.9.37 beta 5).

Co-Authored-By: Awaken <[email protected]>
2026-10-25 16:55:46 +00:00
ayozetr a92a2dd921 Controller light bar colour, as a launcher option
A new row in Controls sets the colour of the light bar on a DualShock 4 or DualSense (off by
default: SDL's player colour). The launcher passes it as BB_PAD_LIGHTBAR=RRGGBB, and the runtime
sets it on every controller it opens, with a log line saying whether the controller took it.
Asked for in discussion #26. Docs line references follow the code again.
2026-10-25 16:36:04 +00:00
ayozetr 23ce6331d4 Launcher: version 0.51-linux.17 2026-10-25 15:13:52 +00:00
ayozetrandCLS_AMG a55d98a50c BBSC 0.0.2: spectator camera, bells anywhere, the host's night, fixes
CLS_AMG's BBSC 0.0.2 (4f11faf), merged three ways over our 0.0.1 base
(315f76c) with every Linux change kept; bbsc/CHANGES-bbport.md lists
what was taken and how each conflict went. The aimed placement and the
host lock that main already had now use upstream's names.

From 0.0.2: the camera that follows a partner while you are dead, the
kit's bells ringing anywhere (a new BBSC option in the launcher, in its
seven languages), the guest seeing the host's time of night, shorter
message and bloodstain lists against the "Panic 265" crash, the fixes
around boss kills, kicks and guest drops, the kit found in the game's
tables, the relay's write queue and a short timeline of each game in
bbsc/logs. New switches: SHADPS4_BBSC_AREA_LIST_CAP, _WORLD_SYNC_ALL,
_TIMELINE and _BELLS_ANYWHERE, all on as upstream (docs/ENVIRONMENT.md).

On Linux the spectator camera reads its keys through SDL, as F8 does,
and only while you are dead in co-op; the free filming camera stays out,
since every key and mouse button also reaches the game through the pad.
The relay sends with MSG_NOSIGNAL and sets its send timeout as a
timeval. BBSC's own Discord status is not used: bbport has its own.

The relay frames are upstream's, so Linux and Windows 0.0.2 players
share one protocol.

Co-Authored-By: CLS_AMG <[email protected]>
2026-10-25 15:12:58 +00:00
ayozetr a23e88394b License: GPL-3.0-or-later; credits and docs brought up to date
The port now includes code from Lantern Engine by Awaken
(https://github.com/Awakenchan/LanternEngine), whose engine is GPL-3.0-or-later, so the whole is
distributed under GPL-3.0-or-later. Everything else in it allows that: bbport and shadPS4 are
GPL-2.0-or-later, the rest MIT, BSD or Zlib. Files from other projects keep their own notices.
LICENSE has the GPL-3.0 text; the README, BBPORT.md, the launcher's About page and Hunter's
Atlas's notes say so, and the attribution Lantern Engine's NOTICE asks for is in the README,
BBPORT.md, the credits, the About page and the release notes template.

Credits: TacticalBill, holydingus, OPGZ2, kuday248 and BlazinKarp for their reports, with more
for n3vrs0ft, Nahnahchi and Awakenchan, in docs/CREDITS.md and the About page in every
language. Code line references in the docs follow the code again.
2026-10-25 15:09:59 +00:00
ayozetr 083ba1f085 Steam overlay, as an option, when the game is started from Steam (#28)
The AppImage's sharun clears LD_PRELOAD in every program it runs, so Steam's overlay never reached
the game. With the new launcher option (under Add to Steam, saved as soon as it changes) run.sh
reads Steam's 64-bit gameoverlayrenderer.so from the nearest process above that still has it and
hands only that library to the game. On a Wayland desktop the window then goes through Xwayland,
since Steam draws its overlay only on X11 windows. Off by default.
2026-10-25 15:02:24 +00:00
ayozetr d412c456cf Steam shortcut: start folder and icon outside the AppImage's mount
Added from the AppImage, the shortcut's start folder and icon were in the AppImage's mount under
/tmp, which is gone once it closes and changes on every start. The shortcut now starts in the
AppImage's folder, and its icon is a copy in the data folder.
2026-10-25 15:02:24 +00:00
ayozetrandAwaken 29a4d62feb Indexed indirect draws sync their whole index buffer
An indirect draw's index count is in GPU memory, and NUM_INDICES may be another draw's smaller
count, so indices past it could be read stale: a possible cause of stretched models (#25). The
buffer's own size (VGT_DMA_MAX_SIZE) is synced when it is larger, up to 4M indices and only
over mapped memory. From Awaken's LanternEngine (940396b).

Co-Authored-By: Awaken <[email protected]>
2026-10-25 02:17:55 +00:00
ayozetrandAwaken 1b8c9de5b6 Draw preparation: a fault ahead of the GPU thread leaves the draw to it
A draw-preparation worker reading guest memory the game had protected or freed had its fault
handled like any other: a readback by the GPU command thread, waited for while the worker held
programs_mutex, which the GPU thread could be waiting for. Workers now jump to a recovery point
for the whole draw instead, and the GPU thread prepares that draw in order. GetProgram keeps
its own recovery point inside the lock and hands back to the outer one. From Awaken's
LanternEngine (7aa3d1b).

Co-Authored-By: Awaken <[email protected]>
2026-10-25 02:17:55 +00:00
ayozetrandAwaken 86d418b21b Guest memory: the game's in/out arguments are written outside the region lock
Mapping, querying and batch calls read and wrote the guest's pointers with the region table
lock held. A write landing on a GPU-tracked page faulted into page tracking, which waits for
its own locks or a readback by the GPU thread, while those threads waited for the region
lock: a hang. The arguments now go through locals, copied in before the lock and out after.
From Awaken's LanternEngine (7aa3d1b).

Co-Authored-By: Awaken <[email protected]>
2026-10-25 02:17:55 +00:00
ayozetrandAwaken fff6786e17 Draws read their vertex inputs again when the prepared ones are stale (#25)
A draw-preparation worker reads a draw's vertex buffer descriptors (V#s)
ahead of the recording thread. When the game rewrites the table in
between, the worker's copy has stale addresses or strides and vertex
fetch reads the wrong data: a stretched shape across the screen, as
reported on an RTX 4060 in the Hunter's Dream. The comparison with the
V#s the draw's packet carries was there, but only for statistics
(BB_CE_STATS). A draw whose prepared V#s differ now reads its inputs from
the packet's, as without a worker, and the log says how many did
("Vertex inputs: ...", every 5 s at most).

Not reproduced here: in the Hunter's Dream on an RTX 3060 with 8
workers, 0 of 4.4 million draws differed. Forcing 1 draw in 7 down the
new path: no errors, 60 FPS.

Found by Awaken (LanternEngine 0.9.34).

Co-Authored-By: Awaken <[email protected]>
2026-10-23 21:41:06 +01:00
ayozetr da01b97dbd Credits: thomasgarafano1's Hunter's Atlas report; GitHub's language bar
thomasgarafano1 found why Hunter's Atlas did not prepare the map with
their Game of the Year dump, down to the two lines, and tested the fix
(#24). Under Help and testing, in CREDITS.md and the launcher's About
section in its seven languages.

.gitattributes: GitHub counted bbsc_messages.inc, a C++ table of game
texts, as PHP because of the game's <?name?> placeholders; it is marked
as C++.
2026-10-23 01:56:01 +01:00
ayozetr 72f92a18cf Hunter's Atlas: any dump of the 1.09 executable prepares the map (#24)
The map generator took only one eboot.bin: it compared the file's SHA-256
with the US release dumped by one tool. A dump of the same 1.09
executable made by another tool (another SELF header around it), or of
another edition, stopped with "Unsupported executable fingerprint" (a
European Game of the Year edition dumped on the player's PS4).

Both checks now use the port's own one (scripts/game_check.py): the hash
of the executable's loaded image, the same for every dump and edition the
port runs. The features file keeps its format marker, which the game
checks, so maps already made still load.

Reported by @thomasgarafano1.
2026-10-23 01:55:25 +01:00
ayozetr e58b0a2a62 Docs: the European Game of the Year edition named as such
"The GOTY edition (CUSA03173)" read as if it were the only one. The title
IDs and regions in the docs were checked against the PlayStation Store and
ORBISPatches: they are right.
2026-10-23 01:39:38 +01:00
ayozetr 2d049d51c6 Docs: English only, up to date, and nothing left from other platforms
READMEs taken from other projects described their Windows packages:
- bbbridge/README.md, bbsc/README.md and bbsc/server/gameserver/README.md
  are rewritten for how this port builds and runs them; bbsc/server gets
  a README.md for hosting BBSC on Linux in place of the Windows host
  package's README.txt.
- BBSC's Windows host scripts (.cmd, tools/*.ps1) and bbbridge's
  make_release.ps1 are removed: nothing used them. SUPPORTERS.txt keeps
  its heading, which BBSC checks, without the donation links.

docs/ kept upstream bbport's files unchanged so that its updates merged;
the port has gone its own way since:
- Upstream's release notes, session logs, roadmap and dated experiments
  are removed; links to them point at upstream's copies.
- Every Markdown file is in English: BBPORT.ru.md and three dated
  Russian notes are removed, and the notes the code refers to
  (upscaler.md, TRANSLATOR_ARCHITECTURE, MEMORY_MODULE_PLAN) are
  translated.
- docs/README.md indexes what is left.

Checked against the code:
- ENVIRONMENT.md: file and line references follow the code again, plus
  Hunter's Atlas variables and other missing rows.
- BBPORT.md: the memory model's Auto default, occlusion counts, the debug
  menu's fonts and the clone URL.
- ONLINE.md and SERVER.md: Seamless Hunt as the default and the BBSC
  switch's name.
- parallel_gpu.md: the toggle bits.
- The launcher's comment on the memory module.
2026-10-23 01:26:44 +01:00
ayozetr 8161988bd9 Launcher: version 0.51-linux.16
The release with the crash at guest offset 0x2169edc fixed in all
likelihood (#23), the GPU freeze guard for NVIDIA (#19), the Language
entry back in the title's System menu, and the stick dead zone and local
co-op options.
2026-10-23 00:13:58 +01:00
ayozetr 8478fb95fb Credits: Awaken's occlusion and shader fixes, Nahnahchi's logs and tests
Awaken found why the occlusion counters were written ahead of the DMA that
clears them (#23) and why vertex shaders were reused with the wrong
bindings. Nahnahchi's logs and tests showed the GPU freeze in a boss fight
(#19) and the crash in Yahar'gul (#23). In CREDITS.md and the launcher's
About section in its seven languages.
2026-10-23 00:10:33 +01:00
ayozetr 81dd13e990 Merge branch 'fix/launcher-choice-align' into test/all-2026-10-22 2026-10-23 00:05:28 +01:00
ayozetr b9552c562e Launcher: a short chosen value sits against its arrow
The chosen value of a choice row is as wide as the list's longest one, so
it does not jump between choices, and was aligned to the left: a short one
such as "60" or "Principal" stood far from the arrow. It is aligned to
the right now; the open list keeps its items aligned to the left.
2026-10-23 00:05:28 +01:00
ayozetr 83bf796126 Merge branch 'fix/stage-specialization-reuse' into test/all-2026-10-22 2026-10-22 23:49:47 +01:00
ayozetr 717fa0ad83 Merge branch 'fix/zpass-in-order' into test/all-2026-10-22 2026-10-22 23:49:47 +01:00
ayozetr 22f9b6d769 Merge branch 'fix/quit-menu-language' into test/all-2026-10-22 2026-10-22 23:49:47 +01:00
ayozetr 7b8491fef8 Merge branch 'feature/allocation-failure-log' into test/all-2026-10-22 2026-10-22 23:49:47 +01:00
ayozetr 101e583bac Merge branch 'feature/guest-fault-call-chain' into test/all-2026-10-22 2026-10-22 23:49:47 +01:00
ayozetr f926e25676 Merge branch 'fix/issue19-indirect-draw-guard' into test/all-2026-10-22 2026-10-22 23:49:46 +01:00
ayozetr ba63ecfc4e Merge branch 'feature/launcher-polish' into test/all-2026-10-22 2026-10-22 23:49:46 +01:00
ayozetrandAwaken d8f3e3633e Occlusion counts written in stream order (#23)
With the occlusion queries not translated, the stand-in counter dump was
written at decode. The game zeroes a query's block with a DMA_DATA first,
which the draw pipe runs later on the recording thread: the zeroing landed
after the counts and the query never finished. The game makes new queries
in place of the ones it still waits for, and when it can make no more it
reads a null handle at guest +0x2169edc, the crash of issue #23.

The dump now runs in order with the draws, as a WRITE_DATA does.
BB_ZPASS_IN_ORDER=0 writes it at decode as before.

Found by Awaken (LanternEngine 0.9.35).

Co-Authored-By: Awaken <[email protected]>
2026-10-22 23:04:41 +01:00
ayozetrandAwaken 8d36a25b13 Shaders: a vertex shader is reused only for the same bindings
Two stage specializations with no guest sharps compared equal, but the
engine's own buffers (user data, clip planes and the like) carry no sharp
and are still bound from `start` on. A vertex shader with only those was
reused after a fragment shader with another binding count, so its bindings
did not match the pipeline layout: NVIDIA reads other descriptors, AMD's
driver can end the process. The shortcut now needs a stage with no
resources at all, and the resource counts must match.

The pipeline key version moves to 6: pipelines saved with such pairs are
rebuilt once.

Found by Awaken (LanternEngine 0.9.37 beta 7).

Co-Authored-By: Awaken <[email protected]>
2026-10-22 23:03:43 +01:00
ayozetrandethan f99498b900 Quit to Desktop: keep the Language entry in the title System menu
Quit to Desktop took the place of the System menu's Language entry, whose
handler held its code, and removed the entry outside the world, where the
game shows it (text and voice language). Players lost it.

The Language entry is the game's own again. In the world, where the game
builds it only to remove it, a jump at its start adds Quit to Desktop
instead, with its own text id (110006; Language keeps 110005). The new
code lives past the end of the code segment, in the rest of its last
4 KiB page: mapped executable with the segment and unused by the game.
The loader lets patches write there, and patches.py checks the segment
layout first.

From Supermedo's Windows port (c065c80e, by ethan).

Co-Authored-By: ethan <[email protected]>
2026-10-22 22:51:10 +01:00
ayozetr c0728a3dbc Log reservations of guest memory that fail
Failed direct and flexible mappings already printed a line; a failed direct
allocation, virtual range reservation or low memory map returned its error
silently. A guest NULL read soon after one of them (issue #23) would then
look like a bug of its own. Each now prints what failed, its size, the
error and the direct memory in use; the first 16 only. The return values
are unchanged.

The idea comes from Mrsuss60's Windows port (4035ab4e).
2026-10-22 22:34:34 +01:00
ayozetranddeadinside 936f51f3e7 Print the game's thread and call chain on a guest fault
A crash inside the game logged only the faulting offset (issue #23: a NULL
read at +0x2169edc). The fault handler now also prints the thread name and
up to 12 return addresses along the rbp chain, read with process_vm_readv so
a broken chain cannot fault again. It runs only when the process is already
terminating. From upstream 0.6 (72e5c8ce).

Co-Authored-By: deadinside <[email protected]>
2026-10-22 22:31:58 +01:00
ayozetr 25a8ca77e9 Check indirect draw arguments before the GPU runs them (#19)
Indirect draws take their vertex and instance counts from memory a
shader may have written, as indirect dispatches do. In #19 the GPU
stopped on one during a boss fight (NVIDIA Xid 109, context switch
timeout), as garbage counts would make it.

Before each indirect draw a compute shader copies its arguments to a
slot of our own, with the vertex (or index) and instance counts zeroed
when their product passes BB_INDIRECT_DRAW_GUARD_MAX (16M by default;
the largest the game was seen to use is 8600). Skipped draws are
counted in the log. BB_INDIRECT_DRAW_GUARD=0 turns it off.

The check cannot run inside a render pass, so an open one is paused
and continued afterwards, loading its attachments instead of clearing
them again. The game has a few indirect draws per frame at most, so
this costs nothing measurable; synchronisation validation reports
nothing on the new commands.
2026-10-22 02:00:25 +01:00
ayozetr 5768190fbd Local co-op: copy player 1's saves to another player
Players 2-4 have saves of their own, so playing the same characters
together meant copying files by hand. Local co-op now has Copy player
1's saves, to player 2, 3 or 4: their saves are first copied to their
savedata-backups (save_import.import_saves), with no game running.
2026-10-21 15:48:40 +01:00
ayozetr 62aa2ee587 Local co-op: a monitor of its own
The players' windows went to the game's monitor (Graphics → Monitor),
so playing together on a TV beside the desk meant changing the game's
monitor first. Local co-op now has its own Monitor row: as the game, or
any connected monitor, which all the players' windows then share.
2026-10-21 15:47:16 +01:00
ayozetr aab75135e6 Launcher: the sticks' dead zone in Controls
A worn stick that drifts moved the character or the camera on its own,
and the only fix was BB_PAD_DEADZONE in the extra variables. Controls
now has Stick dead zone (%), 0 to 50, 4 by default (the port's 5 of
127, which is then left to the port).
2026-10-21 15:46:02 +01:00
ayozetr 63e9cebb7a Local co-op: say so when there is no X server
The players' windows are placed through X11 (XWayland in a Wayland
session). Without an X server the games started and their windows could
not be placed. Play together now checks for one first and says how to
get it: XWayland in the session's settings, or an X11 session.
2026-10-21 15:45:02 +01:00
ayozetr e26cc5fd51 Launcher: a warning on FPS limits above 120
Above about 120 FPS the game's movement timing breaks: running and
rolling slow down. An FPS limit above 120 now shows a warning sign and
says so in the row. The idea is from Supermedo's launcher (51cac0e).
2026-10-21 15:44:14 +01:00
ayozetr f2dd58e118 Launcher: menu and debug patches out of Graphics
Skip intro, Quit to Desktop, the free camera and the debug menu change
the game's menus or open its debug tools, not the picture, yet they sat
under Graphics → Game effects. They are now in their own group, Mods →
Menus and debugging; the settings and bbport.ini keys stay the same.
2026-10-21 15:43:17 +01:00
ayozetr 119bce7969 Launcher: Online tab in the order it is used, and "Don't ask again"
The Online tab's groups follow the order they are set up in: the server
and its address, the account on it, seamless co-op, then the connection
details, with the title's online/offline choice at the end.

Play with an account set and "Play online" off asks whether to play
online; someone who plays offline on purpose can now tick "Don't ask
again". Turning "Play online" on brings the question back.
2026-10-21 15:37:50 +01:00
ayozetr de95d55c60 Trophies: an encrypted npbind.dat or trophy file in the dump
A Steam Deck dump of CUSA03173 has sce_sys/npbind.dat, but encrypted
(no NPBIND header, no NPWR id in it), so the Trophies page said the file
was not found (#22). For a Bloodborne release the port runs (param.sfo's
TITLE_ID), its own id NPWR05818_00 is used then; without the trophy key
that also gives PSN's English list. When the id is still unknown, the
page says the id is missing from npbind.dat rather than the file.

A trophy00.trp that is not a trophy file (encrypted or damaged the same
way) was taken for a wrong trophy key. It now says so, and to dump the
game again.
2026-10-21 14:53:03 +01:00
ayozetr a9690c592c Credits: n3vrs0ft's BBSC logs and simo1730's trophies report
n3vrs0ft's logs showed a BBSC guest following another host's world (#17),
and simo1730 reported the Trophies page not finding the dump's files
(#22). Both under Help and testing, in CREDITS.md and the launcher's
About section in its seven languages.
2026-10-21 12:25:04 +01:00
ayozetr ed36fd1aa0 Trophies: dump files found whatever their case, and the missing one named
A dump copied through Windows or extracted by some tools can have
SCE_SYS/TROPHY/TROPHY00.TRP or NPBIND.DAT, and on Linux the exact
lower-case path was not found, so the Trophies page said the dump had no
trophies (#22). Each name is now matched without regard to case, and
when the trophies still cannot be read the page says which file is
missing: sce_sys/trophy/trophy00.trp, or sce_sys/npbind.dat (the game's
NP communication id).
2026-10-21 12:20:43 +01:00
ayozetr 5023ce183c Monitor list: the monitor's pixels, not the scaled desktop size
On a scaled Wayland desktop SDL gives the desktop mode in points, so a
2560x1440 monitor at 115 % was listed as 2226x1252. The size is now the
points times the display's pixel density.
2026-10-21 12:16:27 +01:00
ayozetr a5cff2a433 Launcher: version 0.51-linux.15
The release with the launcher in a sidebar, Seamless Hunt as the
default server, saves export, import and automatic backups, and
seamless co-op that meets players on BBSC 0.0.2.
2026-10-21 12:02:12 +01:00
ayozetr 279e978750 BBSC: the guest locks onto the host that rang its sign, as in 0.0.2
BBSC 0.0.2 (published 2026-10-21) ships the aimed placement this
branch reimplemented from its earlier DLL, with the same frame
("SHAD" 0x31 + "<TargetUserId>|<placement>") and the same 90 s, and
ignores the server-wide 0x30 that 0.0.1 sends. Windows 0.0.2 players
need it, so it goes in now with the part of 0.0.2 that uses it:

- a placement aimed at our sign notes its sender as the host that rang
  it, and while joining that host's packets are taken, so the join
  load gets its world flags;
- the guest locks onto that host when it is on the relay; otherwise the
  position lock is stricter (6 m, one host that close, 3 checks in a
  row), and "the only one hosting on the server" is gone, as it was a
  stranger whenever the real host was not on the relay.

The rest of 0.0.2 (spectator camera, bells anywhere, night time, the
fixes after boss fights) is left for a later update.
2026-10-21 12:02:00 +01:00
ayozetr 1c52215987 BBSC: a host's placement goes to the sign it rings
Issue #17: as a guest on Seamless Hunt, a Linux player's sign never
reached their friend, and sometimes a stranger summoned it despite the
password. BBSC 0.0.1 sent every host's placement to the whole server,
and a waiting guest followed the first host it heard from: with 12
hosts on the server in one session, it followed a stranger and its sign
was advertised on that stranger's map.

BBSC's own bbsc.dll of 2026-10-17 changed this, with no source
published yet. This is the same protocol, reimplemented from that
binary, so Linux and updated Windows players understand each other:

- a guest keeps the UserId its sign is created under;
- a host ringing a sign (/summon_messenger/request with a TargetUserId)
  sends its placement to that user only: relay frame "SHAD" 0x31 +
  "<TargetUserId>|<placement>", plus the placement header to the
  broker;
- a guest takes only a placement sent to its own sign, and keeps it for
  90 s (20 s otherwise);
- placements sent to the whole server (0x30) are ignored, as the newer
  BBSC builds do; nothing is sent while polling for signs.

The frame layout matches the binary's sender and receiver. Not tested
with two players yet. Documented in bbsc/CHANGES-bbport.md.

Co-Authored-By: CLS_AMG <[email protected]>
(cherry picked from commit 729aaca178662c8671641eae949dded9edb279b8)
2026-10-21 12:02:00 +01:00
ayozetr e6d62a35d3 Launcher: optional automatic save backups, and a banner for a missing game
Setup → Saves can copy the saves into savedata-backups/auto-<date> before
each game start, keeping as many copies as chosen (5 by default); off by
default. Saves unchanged since the newest copy are not copied again, so
starting a few times without playing keeps the older ones. A copy is
restored like any folder, with Import → From a folder…, and the row menu
opens the backups folder.

Without a game folder (or without eboot.bin in it) Play stays grey; a
banner now says why on every page and leads to Setup, where the first
start opens.

The Journal's deferred scroll reads the current view: a rebuild right
after new log lines scrolled the destroyed one and aborted GTK.
2026-10-21 11:53:38 +01:00
ayozetr 9e8b45537a Launcher: saves export and import, folder rows in words
- Setup has a Saves group: the saves folder, Export (a .zip with the
  saves in use, in a folder named as a PS4 export) and Import, from a
  folder as before or from such a .zip. Only the save files are read
  out of an archive, by name, so no path in it can write elsewhere;
  the current saves are backed up first, as with a folder.
  save_import.py takes --export and a .zip source too.
- The game, DLC, saves, mods and patches folder rows had up to five
  icon buttons. They now have "Change..." and a "..." menu with the
  other actions in words (open in the file manager, back to the
  default folder, refresh), and the saves row says in its subtitle
  whether there are saves.
- "Mute in the background" moves from Display to Setup, next to the
  Discord status: how the game behaves on the desktop.
- Present mode and GPU data readbacks explain each choice under the
  row, and the readback choices are translated (Relaxed, Off, Precise).
2026-10-20 15:44:13 +01:00
ayozetr ada0be7b7c Launcher: Mods first in its section, loading under Advanced
The Mods section opened with the map, the cheats and the gameplay
tweaks, and its own Mods group came fourth. It now goes Mods,
Third-party patches, Map, Cheats, Gameplay tweaks. Loading and
smoothness (bbhost's quick loading and the stutter fixes) is about
performance, not mods: it moves to Advanced, after Performance.

Spanish says "Online" throughout, as the switch already did, instead
of mixing it with "en línea".
2026-10-20 15:30:44 +01:00
ayozetr 5899c2b37d Launcher: no freeze on Play, dialogs without a stutter
- Play froze the window for 0.2-0.5 s: the button prompts follow the
  controller, and the gamepads were looked at on the main loop. The
  look now runs in a thread (with_gamepads) and the game starts after
  it; Play is off meanwhile, so a double click starts one game. Local
  co-op and the Refresh buttons go the same way, and the window's lists
  are filled from the last look and again once a look in the
  background ends. The window now builds in 0.4 s (0.9 s before).
- Every dialog (alerts, confirmations, the thanks) opens and closes
  without its animation under the cairo renderer, where each frame
  redrew the whole window on the CPU (present_dialog).
- The Trophies page no longer remakes its rows and icons on every
  visit when nothing changed.
- The Log scrolls to the end once per burst of lines, not once per
  line.
- A language change waiting for work that ends just as the game starts
  waits for the game too.
2026-10-20 14:11:04 +01:00
ayozetr 57ea71d393 Launcher: translations, tooltips and smaller texts
- Simplified Chinese had 25 strings in English (cheats, gameplay
  tweaks, trophy grades, the own server's note): translated. 16 keys
  no code uses any more are gone from every language.
- The game folder row says why the game cannot start (no eboot.bin)
  in its subtitle, and "choose the game folder" when none is chosen,
  not only in a tooltip.
- Assign and Reset in Controls name their control in the tooltip, so
  a screen reader tells them apart; the trophies menu button has one.
- The guide to hosting a server shows with "Your own server" only.
  Failed test lines get an error mark, not a warning.
- Shorter "From the dump" region value (it was cut in Spanish), quoted
  account name after Create, the About group no longer repeats the
  section's title, and a few English wordings.
- Docs: the connection test has check and error marks, not green and
  red lines; BB_SKIP_NETWORK_CHOICE goes online when online play is
  on.
2026-10-20 14:07:05 +01:00
ayozetr ca41802094 Launcher: keep edits over a waited rebuild, own server checks
- A language change that waited for running work rebuilt the window
  from the saved settings: what was changed meanwhile, an account just
  signed in to The Hunter's Dream included, was lost. It is stored
  first now.
- With "Your own server" and no address, Create and Test used Seamless
  Hunt's server (and filed the account under the own server). They are
  off until there is an address.
- The Status row's "signed in" belonged to the name, password and kind
  only, so another own server's address still showed signed in. The
  server's address is part of it now.
- A rebuild on the Trophies page prepared the trophies twice at once:
  the two could clash on the temporary file and show a false "wrong
  key". It prepares once, the file is per thread, and only a decryption
  error counts as a wrong key. A result arriving while trophies are
  being marked no longer drops the checks.
2026-10-20 14:04:34 +01:00
ayozetr bf133c7153 Launcher: thanks dialog made once, shown without a stutter
The thanks dialog (some 60 rows) was made on every click and its
animation ran at about 20 frames a second: with the cairo renderer,
kept because GTK 4.24's GPU renderers still cut the top of capitals at
115 % scale, every frame redraws the whole window on the CPU. The
dialog is now made once while the window is idle (again after a
language change), and with cairo it opens and closes without its
animation; animations come back once it is gone.
2026-10-20 14:02:48 +01:00
ayozetr 866b8d410c Launcher: faster start, no pause when the game starts or stops
Profiled with real settings, building the window took 1.65 s and a
language change 1.62 s:

- The Hunter's Atlas row counted the maps' and tools' size on every
  update of Play (about 14 000 files, half a second), so the window
  also froze each time the game started or stopped. The size is now
  counted once per state of the folders; preparing or deleting the
  maps makes a new one.
- The gamepad list started bb-gpu-capabilities twice per build (0.3 s
  each). One look now serves the build; Refresh still looks again.
- The Trophies page decrypted the trophy names in Python on every
  visit (half a second in a thread, holding the interpreter). The same
  .trp, key, language and script are now decrypted once; the key is
  only part of a digest.

The window now builds in 0.9 s, a language change takes 0.5 s.
2026-10-20 13:26:09 +01:00
ayozetr 8a2d900587 Docs: launcher locations after the sidebar layout
The README, BBPORT (English and Russian), ENVIRONMENT, TROUBLESHOOTING,
ONLINE, SERVER, MODS, COMPATIBILITY, DEBUGGING, CREDITS and the Hunter's
Atlas README named pages the launcher no longer has (Settings, Game,
Mode, Screen, the Online and Local co-op pages, the Journal tab). They
now name the sidebar sections and groups: Graphics, Controls,
Multiplayer (Online and Local), Mods, Trophies, Setup, Advanced, Log
and About. The connection test is the Test button in the Status row,
and the README lists Seamless Hunt first, as the default server.
2026-10-20 13:21:30 +01:00
ayozetr 0ce9f40f88 Launcher: clearer Online tab and texts
- Online tab: one Test button, in the Status row, with the results
  right under it (cleared when the server changes). The intro says
  where messages and summons come from on the chosen server, the Play
  online switch says what it does in both states, and the game server
  rows show only where they apply (an own server's address, Seamless
  Hunt's bloodstains).
- Dropdowns keep their value readable (up to 16 characters) beside a
  long subtitle: the server choice read "Seamle...".
- Hunter's Atlas: Prepare is no longer the suggested action when maps
  from another folder are already in use.
- Setup: "System language" is now "Game language". Advanced drops the
  "Mode" title over its only row.
- English: "Couldn't" throughout, one "Applies when the game starts",
  the Log is no longer called the Journal. Spanish says "launcher"
  throughout.
2026-10-20 13:18:17 +01:00
ayozetr 7b04dffd8b Launcher: Play, co-op, sidebar and rebuild fixes
- Play and local co-op follow one rule (can_start): no game folder, a
  page that failed to build, an FSR 4.1.1 build or a map preparation
  keep them off. Before, finishing a map preparation or an FSR build
  turned Play back on without a game or in a half-built window, and
  co-op could start from one.
- The sidebar's page follows its selection, so the arrow keys work.
- A launcher language change keeps the section, the Multiplayer tab
  and, on a narrow window, the page shown. It waits until the game, a
  map preparation, an FSR build and the threaded tasks (PKG install,
  connection test, account creation, bbbridge sign-in) are done, so
  their buttons cannot be pressed twice on the new page.
- Refresh in the display, controller and co-op lists keeps the choice
  not saved yet.
- Trophies: only the latest refresh shows, a visit while marking keeps
  the checks, an unexpected error shows instead of an empty list, and
  the old count goes when no trophies are found.
- Players 2-4 of local co-op start only beside their own player 1.
- A failed section shows its own error, and a failed rebuild does not
  reuse the old page's "skip the online choice" group.
- Saving trophies, clearing the shader cache and deleting the maps say
  when a file could not be written or removed.
2026-10-20 13:13:58 +01:00
ayozetr 6eef9ed409 Online: Seamless Hunt by default, shadPS4's public server gone
srv.shadps4.net signs this port's builds out since 2026-10-12 (upstream
docs/BBSC.md), so it is no longer offered. Seamless Hunt is the default
server, and settings that chose the public server, or none yet, get it.

The public server's account is kept in online_accounts but not reused
on Seamless Hunt. An own server without an address now stops the game
from starting and shows in the Online status row. run.sh's default for
online play without the launcher is Seamless Hunt's server too.

The texts that sent players to the old Online page (the BBSC kit's help
line, the server refusal alert, the docs) now name Multiplayer.
2026-10-20 12:32:46 +01:00
ayozetr 2d243ff2ed Launcher: sections in a sidebar, Multiplayer with Online and Local
The pages were tabs in the header bar; with seven of them (nine now)
the names were cut on narrow windows and the everyday settings were
mixed with the ones set once. As asked in discussion #11, the launcher
now has a sidebar of sections, collapsed into a list on narrow windows
(the Steam Deck), with Play always in the header:

- Graphics: screen, upscaler, frame rate, game effects;
- Controls: the gamepad, then the keyboard and the mouse;
- Multiplayer: the Online and Local co-op pages as two tabs, Online
  starting with the account's status (off, no account, not checked,
  signed in or the reason it was not) and a Test button, and the
  play online / offline choice;
- Mods: the map, cheats, gameplay tweaks, mods, external patches,
  loading and smoothness;
- Trophies;
- Setup: the launcher's language, the game, saves and DLC folders,
  PKG install, language and region, Steam and Discord;
- Advanced: memory model, performance and developer options;
- Journal and About.

Local co-op also says which keys the keyboard player uses in menus.
Every setting keeps its row and its key; only where it is shown
changed. In the launcher's languages.
2026-10-20 12:10:48 +01:00
ayozetr 6029eced91 README: multiplayer with local co-op, the in-game map, support
The first feature is Multiplayer now: online play, Seamless Co-op and
local co-op (split screen). Hunter's Atlas gets its own line, and the
Support section says what goes into this fork beyond deadinside28's
port.
2026-10-20 02:06:03 +01:00
ayozetr a6bdb47739 Launcher: version 0.51-linux.14
The release with Hunter's Atlas (the in-game map, its maps prepared
from the launcher), local co-op, the controller that spun after
reconnecting (#20) and the settings the launcher wrote back (#21).
2026-10-20 01:08:06 +01:00
ayozetr d3632ba39a Launcher: Hunter's Atlas, its switch and its maps
Settings has a Map (Hunter's Atlas) group: the in-game map on or off
(off by default), lamp travel and items on the map, and the maps.

The maps are made from the player's own game files (they cannot be
shipped). Prepare (Remake once there are maps) runs
launcher/bbport_atlas_maps.py, with a progress bar of its steps and
Cancel:
- the first time it fetches uv 0.12.22 (its static musl build, checked
  against its SHA-256), CPython 3.12.15 and the generator's packages of
  tools/hunters-atlas/requirements.lock (exact versions with their
  hashes) into atlas-tools/ in the data folder: about 200 MB downloaded,
  about 600 MB on disk with the maps;
- it runs the generator, writes the names in the game's language and
  converts Atlas's art (tools/hunters-atlas/art: Atlas's source/art,
  its PNGs recompressed without loss by optipng, the same pixels,
  checked against their hashes) for the game;
- the maps are made in navigation-map.new and replace the old ones only
  once complete, keeping the map's settings.ini; a failed or cancelled
  run leaves the previous maps as they were, and the generator's work
  files are removed.
The bin button removes the maps and the tools. Nothing of it runs while
the game does, nor the game while the maps are made. uv and the
generator run without the package's Python and libraries.

On, the game gets BB_ATLAS_ENABLED=1 and the maps' paths; off or
without maps, BB_ATLAS_ENABLED=0 and none of the map's code runs. The
AppImage carries the generator and the art (tools/hunters-atlas).

Checked from scratch here: prepared in a new data folder, the maps are
byte for byte those already tested in the game; Remake from the
launcher reused the tools and kept settings.ini; Cancel during the
generation left the previous maps untouched. In the launcher's
languages.
2026-10-20 00:59:25 +01:00
ayozetr 50ec11bc74 Docs: Awakenchan's port is LanternEngine now
BloodBorneButPc was renamed LanternEngine: the credits link to its new
name, and the README recommends it for Windows next to Supermedo's port.
2026-10-20 00:27:30 +01:00
ayozetr be8478545b Credits: MikeTat1988's Hunter's Atlas
In docs/CREDITS.md (with Grimrukh's soulstruct, which its generator
runs with) and in the launcher's About page, in its languages.
2026-10-19 23:51:42 +01:00
ayozetr e0bbdbbfcc Hunter's Atlas: the game's own cursor sound
On a recording of the game's quick menu, every cursor move of the game
holds CURSOL_SELECT (0.0097 +- 0.0003 of the recording's scale, nothing
at other times) and never CURSOL_MOVE, which Atlas played; its timbre
was the difference the player heard. The map now plays CURSOL_SELECT
for its moves and confirmations, at the level measured against the
map's own sound in the same recording (gain 0.024).
2026-10-19 23:51:15 +01:00
ayozetr 3f1277f217 Hunter's Atlas: cursor sounds at the game's level
The map's cursor sounds were mixed at full level, several times louder
than the game's own menu cursor on a recording of both. They are mixed
at 0.07 now; BB_MAP_SOUND_GAIN (0-2) changes it.
2026-10-19 23:51:15 +01:00
ayozetr e00b4b6040 Hunter's Atlas: travel dialog selection and Alt+Tab
- The travel dialog marks its selected button and Left/Right choose
  between Travel and Cancel; the confirm button does what is selected.
  Only the button under the mouse was marked, so with a controller
  neither looked chosen.
- Losing the window's focus no longer closes the map; only what is held
  (sticks, zoom) is let go.
- After a travel the quick-menu tile stays hidden until the game's menu
  has closed, instead of showing on the loading screen.
2026-10-19 23:51:15 +01:00
ayozetr 311f09a1b8 Hunter's Atlas: the Map tile follows the game's quick menu
Measured frame by frame against recordings of the game's own menu:
- the tile fades in and slides with the menu as it opens, instead of
  appearing 180 ms late and still;
- it stays until the menu leaves the screen, fades out with it when the
  menu is closed with Back, and is gone before a lamp travel's loading
  screen;
- losing the window's focus (Alt+Tab, local co-op) no longer hides it
  or drops its selection;
- back from the System page the game's menu manager reports 0 instead
  of 2 while the menu is shown, so the tile stayed hidden until the menu
  was reopened; 0 with the quick menu's list alive counts as shown now;
- its "Map" label has the game's size, colour and weight, and the cover
  over the game's own label is only as wide as that label, opaque and
  faded at its edges, without hiding the divider line.

BB_ATLAS_DEBUG=1 logs what the map reads from the quick menu whenever it
changes, which is how the System case was found.
2026-10-19 23:51:15 +01:00
ayozetr f2ad0fdb25 Hunter's Atlas: Up and Down reach the Map tile like a native tile
The game knows of no tile above the fourth column, so Up from below the
Map tile landed on System, and Down from the Map tile went to the tile
below System. Up from below the Map tile now focuses it, and Down from
it reaches the tile below it (the game moves to the tile below System
and Right is pulsed until it gets there). Tested both ways, with System
and the tile below it keeping the game's own behavior.
2026-10-19 23:51:15 +01:00
ayozetr db9adde3f6 Hunter's Atlas: a test of the pad with the map on
tests/test_pad_atlas.c runs src/runtime_pad.c with BB_ATLAS_ENABLED=1
(tests/test_pad.c covers it off): buttons the tile or the map takes do
not reach the game, the game gets neutral input while the map is open,
and buttons and sticks still held when it closes stay hidden until
released, then reach the game when pressed again. Without the release
filter in sample_atlas the test fails at the closing step.
2026-10-19 23:51:15 +01:00
ayozetr dbeb093107 Hunter's Atlas: area, lamp and item names in the game's language
tools/hunters-atlas/localize_names.py runs after the generator, which
writes names in English and matches them by their English text (left
untouched). Each name of a pack that is one of the game's own English
texts (msg/engus, menu and item texts) is replaced by the text with the
same id in the chosen language folder, so the names are the game's own;
a name joined from several with " / " is translated part by part, and
one it cannot find stays in English. maps/generated-assets.json gets
the language and the new file hashes.

On the US release here: 778 names in Spanish (Sueño de cazador,
Distrito de la Catedral, Camposanto de Oedon, Vial de sangre), 790 in
Russian, 745 in French; the ones left as they were are the game's own
names in that language too (Byrgenwerth, Tonitrus, and Hemwick Charnel
Lane in French).
2026-10-19 23:51:15 +01:00
ayozetrandMikeTat1988 317c0d8a2c Hunter's Atlas: its map generator, running on Linux
The generator of Hunter's Atlas by MikeTat1988 (GPL-2.0-or-later), from
Atlas 1.3 (source/generator/src at 4ee4a0ed), in tools/hunters-atlas:
it reads the player's own game files and writes the map packs the
in-game map draws. Without Atlas's Windows packaging (PyInstaller,
generate_atlas_lite.py) and its copy of soulstruct (GPL-3.0-or-later),
which is installed from PyPI; requirements.txt keeps Atlas's pins.

One change for Linux: extract_verified_gates.py and
extract_subarea_names.py looked a parameter table up by the binder
entry's name, which soulstruct gives with the game's Windows path
(N:\SPRJ\...\ObjActParam.param); Path.name splits that only on
Windows, so the table was never found (StopIteration). The part after
the last \ or / is compared now. The files keep their CRLF endings.

Run on the US release here: 16 areas, 602 layers, 44 lamps, 48 ladders,
66 NPCs and 85 gates in about 4.5 minutes, the counts of Atlas's
Windows reference within 0.05 % of triangles and walls; this copy gives
byte-identical output to the generator run from Atlas's own sources.
README.md says how to run it.

Co-Authored-By: MikeTat1988 <[email protected]>
2026-10-19 23:51:15 +01:00
ayozetr 7a3ba74c81 Hunter's Atlas: Russian in the game's own map font
The map font the generator makes from the game's menu font keeps Latin
and Cyrillic glyphs (the extractor keeps U+0020-024F and U+0400-052F),
and every character of the map's Russian, Spanish, Italian, French,
German and Portuguese texts is in it, as checked against the font
generated here. Only Chinese still takes the port's font.
2026-10-19 23:51:15 +01:00
ayozetr 047895f8f9 Hunter's Atlas: the map and its tile in the game's language
Atlas had its texts in English, with a Russian label on the tile only.
They now follow the port's other texts in the game:

- the map and the quick-menu tile follow the game's language, like the
  port's pages in the game's System menu (BbGameMenu::Language, now
  exported: the game's own once its menu has looked up a text, else
  the port's menu language);
- the map's section of the port's menu (Insert) follows that menu's
  language;
- gpu/shim/bbport_atlas_i18n.h holds them in Russian, Spanish, Italian,
  French, German, Brazilian Portuguese and Simplified Chinese, with the
  game's own names where it has them (Farol, Fiole de sang, Озарение);
- in Russian and Chinese the map's titles use the port's font: the map
  font, made from the game's English menu font, has no such glyphs.

Identifiers stay in English (the travel popup's id); the text the
footer compares is translated on both sides. The names of areas and
lamps still come from the generator in English. tests/test_atlas_i18n.cpp
checks the columns and the English fallback.
2026-10-19 23:51:15 +01:00
ayozetrandMikeTat1988 2f69cfaa37 Hunter's Atlas: the features test runs in build.sh too
test_atlas_features.cpp writes its own synthetic features.json into the
folder it is given, so it needs no game data after all (the last commit
said otherwise): it runs in build.sh --test with the other Atlas tests.
Only test_menu_sound.c needs the player's own sound bank; it decodes
sound/sprj_main.fsb of the US release here (3072 and 4096 frames).

Co-Authored-By: MikeTat1988 <[email protected]>
2026-10-19 23:51:15 +01:00
ayozetrandMikeTat1988 c0d40fe6d5 Hunter's Atlas: its tests
Hunter's Atlas's unit tests (MikeTat1988): the tile's state machine,
map styles, floor interiors and the controller shortcut run in
build.sh --test, without game data. test_atlas_features.cpp (a
generated features.json) and test_menu_sound.c (the game's own sound
bank) need the player's data, so they are kept for manual runs;
_putenv_s became setenv.

Co-Authored-By: MikeTat1988 <[email protected]>
2026-10-19 23:51:15 +01:00
ayozetrandMikeTat1988 e1e6c3e47c Hunter's Atlas: the map screen, its quick-menu tile and input
Second part of Hunter's Atlas by MikeTat1988 (GPL-2.0-or-later,
github.com/MikeTat1988/HuntersAtlas): the fullscreen map, drawn with
the overlay's ImGui, and the "Map" tile next to the game's quick menu.

- gpu/shim/bbport_map*.cpp, bbport_atlas_*: the map screen (layers,
  floors, walls, lamps, markers, travel dialog), its art, features,
  tile and controller shortcut, as in Atlas. Its map packs are read
  with nlohmann/json, the copy bbbridge/third_party already carries.
- The overlay draws and ticks them, routes events and the pad to
  them, and loads the map's title font; the window lets the mouse go
  while the map or the tile uses it.
- runtime_pad.c: with the map on, the pad is read even while the map
  holds it, Atlas takes what it uses (bbgpu_atlas_route_pad), the game
  gets neutral input meanwhile, and buttons still held afterwards stay
  hidden until released (sample_atlas).
- runtime_audio.c and runtime_menu_sound.c: the tile's cursor sounds,
  decoded on first use from the player's own sound/sprj_main.fsb with
  LibAtrac9, mixed into the main port, silent in the background like
  the game.

Everything is behind BB_ATLAS_ENABLED=1: off, the overlay calls none
of it and the pad keeps its current path, unchanged.

Co-Authored-By: MikeTat1988 <[email protected]>
2026-10-19 23:51:15 +01:00
ayozetrandMikeTat1988 551b53ece0 Hunter's Atlas: the game-state readers and lamp travel on Linux
First part of Hunter's Atlas by MikeTat1988, a fullscreen in-game map
(GPL-2.0-or-later, github.com/MikeTat1988/HuntersAtlas): the runtime
pieces that read the game's state.

- src/runtime_map.c: the player's position, facing and area, the quick
  menu's state and which button confirms. Reads only; every guarded
  site is checked byte for byte first. On Linux the reads go through
  process_vm_readv, and /proc/self/mem where that fails (on AMD the
  game's memory is a GPU dma-buf); ReadProcessMemory stays for
  Windows.
- src/runtime_atlas_state.c: event flags (read only) and the opt-in
  lamp travel (BB_MAP_TRAVEL_ENABLED=1), which hooks the game's
  per-frame function and calls its own return-point and warp functions
  on the game thread. On Linux the lock is a pthread mutex and the
  hook is written while the loader still has the image writable, with
  no page protection change at run time.
- bb-probe binds them right after bbgpu_patch_image, and only with
  BB_ATLAS_ENABLED=1: with the map off, nothing of the game is touched.

All nine sites checked match the 1.09 executable here. Atlas's tests
for both pieces run in build.sh --test, with a new case for travel on.
The map screen itself comes next.

Co-Authored-By: MikeTat1988 <[email protected]>
2026-10-19 23:51:04 +01:00
ayozetr ddd813258d Launcher: Local co-op page
A page to play local co-op without scripts: 2 to 4 players, the layout
for two (one above the other or side by side; three and four take the
quarters), each player's controller by device path (or the keyboard and
mouse), and sound for player 1 only. Play together starts player 1's
game with the launcher's settings, then the others 8 s apart; the
Journal marks their lines with [P2]..[P4], and they stop with player 1.

Players 2-4 get their own data folder (coop/player<N>: generated files,
saves, bbport.ini copied from player 1's the first time) and play
without an online account, Discord status or BBSC. The variables come
from bbport_coop.py, which has its own tests.
2026-10-19 23:51:00 +01:00
ayozetr 3b6eefc04b Local co-op: keep the sound while another player has the focus
With BB_PAD_BACKGROUND=1 one player's window is always out of focus,
so bbport.ini's mute_unfocused silenced player 1's game whenever
another player's window was active. It is ignored in local co-op;
players 2-4 are silenced with BB_AUDIO=none instead.
2026-10-19 23:51:00 +01:00
ayozetr b1d721ffb4 Gamepad list: the device path of each controller
bb-gpu-capabilities --gamepads prints each controller's device path as
a fourth column, and connected_gamepads(paths=True) returns it. Two
controllers of the same model share a GUID and a name; the path is what
tells them apart when each local co-op player gets one (BB_GAMEPAD
already takes a path).
2026-10-19 23:51:00 +01:00
ayozetr 5ece4d2aac Local co-op: a game per player, each with its own controller and window part
The groundwork for local co-op without PartyDeck: several games at once,
one per player, each with a HOME (or BB_DATA_DIR) of its own.

- BB_GAMEPAD also takes the device path, which tells two pads of the same
  model apart; the log's "gamepad connected" line shows it.
- BB_GAMEPAD_STRICT=1: only the chosen controller, never another
  player's (no fallback to the first one or the one in use).
- BB_KEYBOARD=0: a controller player's game ignores the keyboard and the
  mouse and never takes the mouse, which kept the other window from
  getting clicks and the focus.
- BB_PAD_BACKGROUND=1: the controller keeps driving its game while
  another player's window has the focus.
- BB_WINDOW_RECT=x,y,w,h: the window's part of the game's display, as
  fractions (the desktop's coordinates and scale differ from screen to
  screen), without borders; read whatever the locale. It needs X11:
  Wayland lets no window place itself.

Tried with two games on one PC: each in its half of the screen (2560x720,
32:9 with the ultrawide patch), the controller game took only its pad.
The keyboard game's input is the next thing to try.
2026-10-19 23:51:00 +01:00
ayozetr 737472975b Seamless co-op: name its kit items while it is off
BBSC names its five items (the Hunt, Kin, Bloodlust and Duel Bells and
the Parting Blank) in the game's text table in memory while it runs.
With Seamless co-op off they stayed in the inventory as "?GoodsName?".

A mod layer now writes their names and captions, BBSC's own in the
languages BBSC has them in (bbsc_kit_texts.json, made from
bbsc/translations), and a help line saying they need BBSC, into the
game's item texts. It fills only the slots the game leaves empty: on
the game's 22 item text archives every other text and file stays byte
for byte the same. With Seamless co-op on there is no layer and BBSC
shows its own texts as before. The layer is made once per game folder
(under a second) and is optional like the others.
2026-10-19 23:51:00 +01:00
ayozetr 9d3f43114f Launcher: keep bbport.ini changes made outside it (#21)
The launcher wrote every bbport.ini key it shows back to the file on
Play and on closing, with the values it had read when it opened. A
setting changed in the game's menu, or by hand, while the launcher was
open went back to the launcher's old value (resolution, upscaler
preset...), and keys it did not know were dropped.

Only the keys changed in the launcher since its page was built (or last
saved), the control bindings changed in it, and keys missing from the
file are written now; every other key keeps the file's current value.
Checked without a display: a file changed behind the launcher keeps its
values (main wrote them back), a launcher change is still saved, and a
new file still gets every key.
2026-10-19 23:51:00 +01:00
ayozetr 746d623518 Controller: a stick at the end of its travel is never its neutral
Issue #20: after the controller was disconnected and connected again
mid-game, the camera and the player spun towards the bottom right until
the game was restarted. Right after reconnecting, an 8BitDo Pro 2 (seen
as an Xbox One controller) reported -32768 on all four axes before its
first real report. The neutral measurement took that as a biased
neutral (the pattern of clone pads that rest at about +/-16380), so a
stick at rest then read as pushed all the way.

An axis at the end of its travel (30000 or more either way) is now
skipped like SDL's pre-report zeros, and is never taken as a biased
neutral; still there after the 3 s timeout, the neutral is 0 and the
log says so. A clone's real biased neutral is taken as before.

tests/test_pad.c feeds the reconnection pattern and then a stick at
rest (neutral 0, centred reading) and a clone's neutral; without the
fix the first check fails.
2026-10-19 23:51:00 +01:00
ayozetr 52c1aaf6fb Launcher: version 0.51-linux.13
The release with the fixes brought from upstream bbport 0.6, the
launcher that no longer stays blank and numbers with a decimal point
in every language.
2026-10-19 13:54:19 +01:00
ayozetr 6adcc2118f Numbers with a decimal point in every language (AppImage)
The AppImage preloads sharun's anylinux.so, which checks the host
locale with setlocale(LC_ALL, "") and leaves it set. In Spanish,
German, Portuguese and other languages with a decimal comma, the game's
process then wrote bbport.ini as "sharpness=0,30" and
"menu_pos=0,5000,0,3000" from the in-game menu (the launcher could not
read it and stayed blank: issue #18), and read "0.30" back as 0:
strtof and atof stop at the point. Builds run from the repository were
not affected; every AppImage log here has "59,6 FPS" where the
repository's have "60.0".

- bb-probe sets LC_NUMERIC to C at the start of main, after the
  preload's check: numbers use a point again, the rest of the user's
  locale stays.
- A single number written with a decimal comma by an earlier AppImage
  is read as written with a point (the game's settings and
  mouse_sensitivity), so those files keep their values.

tests/test_pad.c reads "1,5" and checks, under es_ES.UTF-8, that
strtof cuts "1.5" to 1 and that LC_NUMERIC=C reads it right; without
the comma handling it fails.
2026-10-19 13:54:06 +01:00
ayozetr 32d61dbcf5 Launcher: read a decimal comma in bbport.ini as a point
Earlier AppImages wrote bbport.ini from the in-game menu in the user's
language: "sharpness=0,30" in Spanish or German (see the bb-probe
locale fix). Such a single number now reads as written with a point,
so those files keep their values instead of falling back to the
default, and the next save writes them with a point.

Checked with sharpness=0,30 and mouse_sensitivity=1,50: the window
shows 0.30 and 1.50 and saves them as 0.30 and 1.50.
2026-10-19 13:54:06 +01:00
ayozetr baa18403db Launcher: a saved value it cannot read no longer leaves it blank
Issue #18: the launcher's window stayed blank for good, with an
unlabelled Play button. An error while building a page (a saved
setting of the wrong type: fps_limit "abc" in settings.json gives
"TypeError: Must be number, not str") stopped build() before any page
was added, and it happened again on every start.

- Numbers read from bbport.ini (fov, mouse_sensitivity, preset,
  sharpness) and settings.json (fps_limit, online_p2p_port, language)
  that are not numbers keep the default, with a line on stderr; the
  next save writes the default back.
- A page that still fails to build shows its error, with a Copy
  button, in its place; the other pages are built. Play is then off
  and nothing is saved (store() returns), so a half-built window never
  writes the user's files or starts the game. The error is in the
  Journal and on stderr too.

Checked with temporary settings: unreadable values open normally; a
forced failure in the settings, online or cheats page shows the error,
keeps both files byte for byte, and survives a rebuild (language
change). With copies of real settings, main and this write the same
files.
2026-10-19 13:54:06 +01:00
ayozetr 7e396e963f Guest hooks: install the game fixes once the image is relocated
The fixes for the game's own bugs were installed when the GPU started,
from the buffer cache. That is before the loader writes the image's
relocations and patches, and writing a jump left its code page
read-only and executable: the loader's next relocation in that page
(0xbbc40f, next to the blur kernel clamp) faulted at start (signal 11
in memcpy, from bb-probe's main).

They are now installed from bbgpu_patch_image, with the libGnm and
menu patches: the image is relocated and patched, still writable, and
no game thread runs yet. The jump is written as plain bytes and the
loader sets each segment's protection right after. The bytes checked
are those after relocation and patches, so a patch at a site leaves
that fix out. No BBSC hook is within 16 bytes of these sites.
2026-10-19 12:36:33 +01:00
ayozetr 7eb725dd94 Credits: axpico for waiting on evicted textures with full VRAM
axpico's upstream PR #206 has the game wait for the textures the
collector evicted instead of closing when VRAM is full: a line in the
launcher's About page in every language and in docs/CREDITS.md.
2026-10-19 11:36:21 +01:00
ayozetranddeadinside 3b65ad3f32 FSR 4.1.1 capture: GE-Proton from a package with another folder name
GE-Proton was found only in folders named GE-Proton*. A package can
name it otherwise (the AUR's proton-ge-custom installs to
/usr/share/steam/compatibilitytools.d/proton-ge-custom, upstream issue
#186): such a folder's `version` file names the build now. The list of
candidates here is the same as before.

From upstream bbport 0.6.

Co-Authored-By: deadinside <[email protected]>
2026-10-19 11:35:40 +01:00
ayozetranddeadinside 4bb9dc0853 Launcher: NVIDIA's libraries also from the host's library folders
The libraries NVIDIA's driver loads (libnvidia-*, libcuda) were looked
for only next to its ICD. Debian 13 puts the ICD in nvidia/current/ and
libnvidia-glsi, -tls and -glcore one level up, so the game fell back to
another GPU (upstream issue #205). The ICD's own folder still comes
first, then the host's library folders; the first file of each name
wins. Here (CachyOS) the 94 libraries linked are the same as before.

From upstream bbport 0.6.

Co-Authored-By: deadinside <[email protected]>
2026-10-19 11:35:40 +01:00
ayozetranddeadinside bb49ffe06f GPU: end-of-pipe labels by the CPU on Polaris
On AMD GCN 3/4 (GFX8: Polaris, RX 4xx/5xx), the game stalled with
end-of-pipe labels written by the GPU (upstream issue #203, an RX 580:
nothing submitted for 5 s after the first cinematic, or a guest fault
at character creation). There they are now written by a CPU thread
once the GPU is done, as BB_GPU_LABELS=0 did; BB_GPU_LABELS=1 forces the
GPU. RADV names the chip in the device name; other drivers are left as
they were. Not verified on such a GPU yet.

From upstream bbport 0.6.

Co-Authored-By: deadinside <[email protected]>
2026-10-19 11:35:40 +01:00
ayozetrandaxpico ddce44bbf7 Guest memory: wait for evicted textures before failing on full VRAM
With VRAM full, a copy of the game's memory that found no room stopped
the game ("Failed to allocate ... VRAM is full"), and a failed
system-memory fallback did too, through Vulkan::Check. Images the
texture collector evicted are destroyed only once the GPU is past them,
so their VRAM was still taken at that moment.

- A failed system-memory allocation now gives none, like a failed VRAM
  one, and the caller goes on to its other attempts.
- Before giving up, it waits for the submitted work (not the one being
  recorded), runs the pending deletions and tries again.

Only the paths that ended the game change. From upstream PR #206.

Co-Authored-By: axpico <[email protected]>
2026-10-19 11:35:40 +01:00
ayozetranddeadinside 0521a560d0 FSR 4.1.1: no white picture after loading another save
- On a reset (a scene change: another save loaded, a teleport), FSR
  4.1.1's temporal buffers are cleared and the previous exposure is
  dropped; what the last scene left there gave a white or broken
  picture.
- A WRITE_DATA longer than 8 bytes can initialize texture memory (the
  effects' private history clears): an image cached there is now
  uploaded again from the new contents. Label and fence writes keep
  their fast path.

From upstream bbport 0.6.

Co-Authored-By: deadinside <[email protected]>
2026-10-19 11:32:08 +01:00
ayozetranddeadinside 087f0516b1 Guest hooks: fixes for the game's own bugs, with either memory model
Hooks in the game's code were installed only with the PC memory model,
the default on AMD alone. Fixes for bugs of the game's own now have an
install step of their own (the profile's install_fixes), run with
either model before the game's threads start; the memory hooks are
unchanged. The idea comes from upstream PR #214 (whp199).

- Render job slots (BB_JOB_SLOT_FIX): a job that fails kept the slot it
  took from the game's pool. Above 30 FPS Laurence's cinematic fails
  enough of them to empty the pool, and the game then waits for a slot
  for good. The slot is now given back at the job's exit, and when its
  runner is destroyed or reset holding one.
- Blur kernel (BB_KERNEL_CLAMP): game data now and then gives a base
  sample count of 2 where the game asserts at least 3 (exception
  0xa0020007, upstream issue #207). It is raised to 3 at the entry.

Every site is checked byte for byte first (verified against the 1.09
executable); BB_GUEST_HOOKS=0 still turns everything off.

From upstream bbport 0.6.

Co-Authored-By: deadinside <[email protected]>
2026-10-19 11:32:08 +01:00
ayozetranddeadinside fa9e9bc6ff Shaders: a failed table read gives zero for that draw only
When the SRT walker read unmapped memory (a stale pointer in a shader
resource table), the fault handler patched the shared MOV into an XOR
for good. Draw-preparation workers run the same walker, so the patch
raced with other threads' handlers, and every later draw through that
walker read zero there even when the memory held valid data again.

The handler now sets the register to zero in the faulting thread's
context and resumes after the instruction; the code is never changed.
The first faults and then every power of two are logged.

From upstream bbport 0.6.

Co-Authored-By: deadinside <[email protected]>
2026-10-19 11:32:08 +01:00
ayozetranddeadinside a9fd9e429b GPU: submission waits cover the frame's first reader
The second wait of a submission waited only at the color attachment
output stage. A prepared frame is first read by the present blit (a
transfer) and by diagnostic dumps, so those reads could race the work
that produces the frame: single black or grey frames. Every wait now
covers all commands.

From upstream bbport 0.6.

Co-Authored-By: deadinside <[email protected]>
2026-10-19 11:32:08 +01:00
ayozetr 6d8abf14a7 Launcher: Copy and Export take the whole session's log
The Journal keeps only its last 5000 lines on screen, and Copy and
Export saved just those, so a long session lost its start (issue #17:
the failed co-op attempts were cut). Every line the Journal gets is
now also written to a temporary file for the session, and Copy and
Export take that. The screen keeps its limit. If the file cannot be
written, they take what the Journal shows, as before.
2026-10-19 00:00:14 +01:00
ayozetranddeadinside 6d409904ff FSR 4.1.1 capture: the FP8 passes on an RX 9000 after a spoofed id
When AMD's DLL did not offer FSR 4.1 to the GPU's id under the Proton
build, the capture asked again under an RX 7800 XT's id. On an RX 9000
the FP8 variant was then asked for under that id too, which gets no FP8
passes (deadinside28#186). On such a PC the FP8 variant is now asked
for under an RX 9070 XT's id, and the id is restored afterwards. Other
GPUs see no change.

From upstream bbport 0.6-pre1.

Co-Authored-By: deadinside <[email protected]>
2026-10-18 18:20:49 +01:00
ayozetranddeadinside 5fc3797c0e Memory model: blocks reloaded or unmapped start over (FPS aging)
With the layer memory module, a block the GPU once wrote in place stayed
"dynamic" (bound in place, read over the bus) for good. As areas loaded
over memory the last one's GPU buffers had used, such blocks piled up
(164 -> 1117 MiB after a few teleports upstream), and one in a range
kept the whole range in place: the frame rate fell the longer one
played, about 28 % after 17 minutes of lamp travel.

- A dynamic block the loaders then write whole may have a VRAM copy
  again; the GPU's writes there stay in place from then on, so the CPU's
  view is never left behind. Blocks the CPU writes often stay dynamic.
  BB_LAYER_RELOAD_RESET=0: as before.
- Blocks the game unmaps whole lose what kept them in place (CPU writes,
  GPU writes in place, failed watches); memory mapped there again
  starts as new. BB_LAYER_UNMAP_RESET=0: as before.

From upstream bbport 0.6-pre1.

Co-Authored-By: deadinside <[email protected]>
2026-10-18 18:20:15 +01:00
ayozetranddeadinside 5ad6bb0d00 Pipeline cache: stages loaded from disk and draws without a pixel shader
Three stale-state bugs around the pipeline cache:
- A pipeline with a geometry stage loaded from the disk cache got no
  fetch shader: the geometry stage, loaded after the vertex stage,
  overwrote it with none. Only a stage that has one sets it now, and a
  pipeline that fails to load leaves no stage behind for the next.
- Clip distance emulation's discard shader (NVIDIA) was never saved, so
  a pipeline without a pixel shader loaded from disk rebuilt it from
  whatever fragment stage the previous pipeline left. It is saved now
  (entries written before keep loading as they did).
- A draw without a pixel shader used the previous draw's fragment
  runtime info; it is now made from this draw's registers.

From upstream bbport 0.6-pre1.

Co-Authored-By: deadinside <[email protected]>
2026-10-18 18:18:56 +01:00
ayozetranddeadinside 47965c6a6b Memory model: unprotect a trapped block under the trap lock
The write trap handler took the block out of the armed set under the
lock, then unprotected it after releasing the lock. A trap armed again
for that block in between (LayerArmTraps, same lock) then lost its
protection: the game's writes went unnoticed and the block's VRAM copy
went stale, seen as "exploding" vertices. The block is now unprotected
before the lock is released.

From upstream bbport 0.6-pre1.

Co-Authored-By: deadinside <[email protected]>
2026-10-18 18:17:28 +01:00
ayozetranddeadinside f2efa0a227 GPU: an extension whose commands do not resolve counts as missing
Some drivers list and enable an extension but resolve its commands to
null (NVIDIA 595, Intel ANV): calling them jumped to address 0 (RIP=0
crashes, deadinside28#161, #177, #181). After the device is made, the
buffer marker, vertex input, dynamic state 3, memory export and host
memory import extensions are turned off when their commands are not
resolved, with a line in the log, and a missing push descriptor command
stops with a clear message instead. Drivers that resolve them all, as
most do, see no change.

From upstream bbport 0.6-pre1.

Co-Authored-By: deadinside <[email protected]>
2026-10-18 18:16:55 +01:00
ayozetranddeadinside e4729de4a3 Runtime: a clean stop when the game raises its own exception
When the game stopped itself (its debug exception), the runtime called
exit, which ran the static destructors while other threads (the online
module's) were still running, and the process ended with "terminate
called ..." instead of the report. It now flushes and calls _exit, and
the log says where it was called from and the game's return addresses
up the stack, to tell which check failed.

From upstream bbport 0.6-pre1.

Co-Authored-By: deadinside <[email protected]>
2026-10-18 18:16:17 +01:00
ayozetranddeadinside edbcdab361 run.sh: keep the AppImage's fd 3 when saving the log
With "Save the log" on, run.sh opened the log file on fd 3. The AppImage
runtime keeps its mount open on an inherited fd 3, so closing it could
unmount the image under the game ("Software caused connection abort"),
seen with --play. The log now goes to a free descriptor.

From upstream bbport 0.6-pre1.

Co-Authored-By: deadinside <[email protected]>
2026-10-18 18:15:43 +01:00
ayozetr 784638ed69 Credits: msisovic for the lamp travel loading hang
msisovic found and diagnosed why loading hung after a lamp travel (guest
thread handles above 40 bits): a line in the launcher's About page in
every language and in docs/CREDITS.md.
2026-10-18 18:15:14 +01:00
ayozetrandSupermedo 82b85856f8 Mods: more folder layouts load, and folders that do not say why
A mod folder whose layout was not recognised was left out of the list
without a word. Now accepted as well:
- notes without a suffix (README), previews (.webp, .gif, .pdf) and .url
  links beside the wrapper folder;
- up to three wrapper folders (Mod/Mod v2/dvdroot_ps4);
- the game's folders next to others (Screenshots, Optional), which, like
  notes beside them, stay out of the game.
Folders that are still not mods are listed under the mods in the
launcher with the reason (still an archive, several mods in one folder,
no game files, empty), in every language. Mods that loaded before load
the same files.

Adapted from Supermedo's port.

Co-Authored-By: Supermedo <[email protected]>
2026-10-18 18:14:23 +01:00
ayozetrandEthan Murphy 24248b0188 Mute in the background
bbport.ini mute_unfocused (off by default): the audio ports are silent
while the game window does not have focus. They keep their pace, so the
game's audio threads run on as before. A switch under Screen in the
launcher and a checkbox in the in-game menu, in every language.

Adapted from Supermedo's port.

Co-Authored-By: Ethan Murphy <[email protected]>
2026-10-18 18:12:24 +01:00
ayozetrandEthan Murphy a0b5d6e1aa Field of view setting
bbport.ini fov (30-90 degrees; unset or 43, the game's, changes nothing)
scales camFovY in every row of the game's camera table (LockCamParam in
gameparam.parambnd.dcx) by fov / 43, so areas with their own camera keep
their difference. scripts/fov.py writes the archive as a layer under the
mods (a mod's own camera table wins), kept between starts while the
setting and the game file match; run.sh adds it. A row under Screen in
the launcher, in every language.

Checked on the game's own file: its 72 cameras (43 and 48 degrees) are
scaled and the other 64 tables of the archive stay byte for byte.

Adapted from Supermedo's port.

Co-Authored-By: Ethan Murphy <[email protected]>
2026-10-18 18:10:07 +01:00
ayozetrandmsisovic fb6b45239f Threads: keep guest thread handles below 40 bits (lamp travel hang)
A guest thread's record is its ScePthread handle, and the game packs
handles into 40-bit fields: its hybrid lock masks the owner to 40 bits
and then compares it with the whole handle. Records came from calloc,
whose addresses are above 40 bits, so a thread could fail its own owner
check and sleep on its semaphore forever: lamp travel hung at a loading
screen now and then.

Records now come from low memory (below 40 bits, zeroed), carved out of
256 KiB pieces and never freed, as before; calloc stays the fallback,
with a warning. The new thread-handles test fails without the change.

Found and diagnosed by msisovic (deadinside28/bloodborne_pc PR #193),
as upstream bbport 0.6 takes it.

Co-Authored-By: msisovic <[email protected]>
2026-10-18 18:06:01 +01:00
ayozetrandSupermedo 6558ee508d GPU: the driver's fault report in the log on device lost
A lost device gave only the breadcrumbs (where they are available) and
no word from the driver. With VK_EXT_device_fault, enabled where the
driver offers it, the log now shows the driver's fault description, the
faulting addresses and the vendor's fault codes before the game stops,
also with the breadcrumbs off. For NVIDIA freezes such as #5.

Adapted from Supermedo's port.

Co-Authored-By: Supermedo <[email protected]>
2026-10-18 18:03:13 +01:00
ayozetrandEthan Murphy d5370741b2 Mouse camera: turn the camera in the game's camera update
The direct mouse camera wrote the camera's angles from the window thread
at moments unrelated to the game's frames, so a frame could get more or
less of the turn than the time it showed: a steady pan juddered by a
pixel now and then.

The window thread now only gathers the motion. The camera hook calls
into the port from the game's camera update, just before the game loads
the angles, and turns the camera there by the motion since the last
update. The stub skips the game's red zone and saves what the call may
change: the caller-saved registers, ymm0-15 whole (the game's code is
AVX) and the flags. Motion gathered while the camera is not updated (a
loading screen) is not saved up, and lock-on is checked every update.

Adapted for Linux from Supermedo's port.

Co-Authored-By: Ethan Murphy <[email protected]>
2026-10-18 18:00:04 +01:00
ayozetr eb75881360 Credits: HatsyRei for the keyboard walk key
The walk key comes from HatsyRei's work in Mrsuss60's port: a line in
the launcher's About page in every language and in docs/CREDITS.md.
2026-10-18 17:48:40 +01:00
ayozetrand0xCydral a2fd00134c Controls: after a menu, hide only the buttons still held
When the in-game menu or the text box closed, every button stayed hidden
until all were released, so that the one that closed it (the Cross that
accepted a name) was not taken as a new press. A button pressed after
the menu closed was ignored as well while another was still held.

Now only the buttons held through the menu are hidden, each until it is
let go; one pressed afterwards shows at once. The triggers' analog value
is hidden with their button, as before.

Adapted from 0xCydral's port.

Co-Authored-By: 0xCydral <[email protected]>
2026-10-18 17:47:44 +01:00
ayozetr 3f71604f25 GPU: free the command processor's finished indirect buffer tasks
Each indirect buffer runs in a coroutine that stays suspended when it
finishes (final_suspend), and nothing destroyed it: every indirect
buffer of every frame leaked its frame, ~600 bytes, so the process grew
for as long as the game ran. The graphics, constant and compute
indirect buffer tasks (and the self-test's) are now destroyed once
done. Only the top-level tasks are kept anywhere else, and those were
already destroyed.

From upstream shadPS4 (12c5978f), as Mrsuss60's port brought it.
2026-10-18 17:45:09 +01:00
ayozetrandHatsyRei e62439bb50 Keyboard: hold Left Alt to walk
The move keys always pushed the stick all the way, so the character
could only run. Held with them, the new walk input keeps the stick at
48 of 127, under the game's run threshold; a diagonal keeps its shape.
It is key.walk in bbport.ini (Left Alt by default) and a row in the
launcher's Controls page, keyboard only.

Adapted from HatsyRei's walk key in Mrsuss60's port.

Co-Authored-By: HatsyRei <[email protected]>
2026-10-18 17:43:22 +01:00
ayozetrandEthan Murphy 4e42c0d5fd Patches: an external FPS patch replaces the built-in preset
shadPS4's patch file has frame rate patches of its own ("120 FPS++",
"60FPS++ (no deltatime)") that write at the built-in preset's addresses
with other values. Chosen in the launcher, they were applied over the
preset and the two mixed.

An external patch named FPS that writes over the preset now replaces it
whole, and the log says so. Only one named FPS does: "Smaller Hunter"
writes there too and would otherwise leave the game at 30 FPS. Checked
with the real executable and shadPS4's file: Smaller Hunter keeps the
preset, 120 FPS++ replaces it, nothing chosen gives the same writes.

Adapted from Supermedo's port.

Co-Authored-By: Ethan Murphy <[email protected]>
2026-10-18 17:40:01 +01:00
ayozetrandEthan Murphy 7bacb0c2bd Texture cache: room for textures on cards below 8 GB
On dedicated GPUs below 8 GB the pressure mark was 40% of the budget
(2.4 GB on a 6 GB card), about what the game holds outside textures.
Such cards were under pressure all session, evicting textures unused
for 5 s and uploading them again when the camera turned back.

The pressure mark is now at least 60% of the budget there
(BB_GC_PRESSURE_PERCENT, 10-75), never above the critical mark, which
is unchanged. Cards of 8 GB or more and integrated GPUs keep their
marks. Adapted from Supermedo's port, where it cut evictions on a test
route from 13528 to 1398 with the same frame rate; not tested here on
such a card.

Co-Authored-By: Ethan Murphy <[email protected]>
2026-10-18 17:32:35 +01:00
ayozetr edc3774dc4 Credits: thank the people behind the latest reports
The reports and logs behind the fixes in 0.51-linux.11 and
0.51-linux.12 (#10, #12 to #16) go under Help and testing, in the
launcher's About page in every language and in docs/CREDITS.md.
2026-10-18 16:51:55 +01:00
ayozetr 1d5ddb7c78 Launcher: version 0.51-linux.12 2026-10-18 16:46:37 +01:00
ayozetr 72b69acf22 Write GPU-written tiled images back instead of dropping them
Past the critical memory mark the texture cache collector dropped
GPU-written tiled images that had not been used for a minute, without
writing them back. When the game came back to an area whose textures
it had rendered itself (the Hunter's Dream), it found that memory empty
and drew it black.

The collector now tiles those images back into the game's memory before
freeing them, the same way linear images were already written back.
Multisampled images are still dropped. BB_GC_TILE_BACK=0 restores the
old behaviour for diagnosis.

Fixes #13, #14.
2026-10-18 16:30:45 +01:00
ayozetr ca77ff8afd Steam Input: open the window through Xwayland on Wayland desktops
Started from Steam with Steam Input on, the game opened Steam's virtual
controller but got no input from it (#12). Steam sends that input only
to the window it sees focused, and on a Wayland desktop it sees only X11
windows, so the native Wayland window never counted as the game.

run.sh now sets SDL_VIDEO_DRIVER=x11 when Steam made a virtual
controller for the game (SteamVirtualGamepadInfo lists one), the desktop
is Wayland with Xwayland and SDL_VIDEO_DRIVER is not set already.
BB_STEAM_INPUT_X11=0 keeps Wayland. Anywhere else nothing changes.

TROUBLESHOOTING.md gives the launch options for older releases.
2026-10-18 12:47:19 +01:00
ayozetr 433f95dc69 Third-party patches: read shadPS4's Bloodborne.xml
Any file named Bloodborne.xml in the patches folder was skipped, so that
an older package's copy of the built-in file could not apply its
frame-rate patches over the port's. shadPS4's patch file has the same
name and was skipped too, without a word (#16).

Such a file is now read. Its patches named as built-in ones (any case)
are left out, and the rest start off until chosen in the launcher,
whatever the file says. Every past version of the built-in file still
adds nothing; shadPS4's file lists 30 patches, all off.
2026-10-18 12:19:13 +01:00
ayozetr 8464ac3f35 Quit to Desktop closes at the title again with online play on
Quit to Desktop from the System menu closes the game once the title
screen is built, which its code learns from the Play Online / Play
Offline label. Skip Online/Offline Choice: Online skips that dialog, so
the game signed in first and waited for the server's welcome message to
be dismissed before closing (#15).

The patch now calls the web browser import with bb:t before it answers
PLAY ONLINE, as the label does.
2026-10-18 12:13:15 +01:00
ayozetr 7b791c660b Launcher: version 0.51-linux.11 2026-10-18 00:39:38 +01:00
ayozetr bdb08e9898 Patches: start online at the title when online play is on
Skip Online/Offline Choice answered the title's dialog with PLAY OFFLINE,
so the game's own online session only started after Log In, and the Kin
and Bloodlust Bells stayed greyed out until then (issue #9). With online
play on, a variant now answers PLAY ONLINE: the same dialog builder
returns what the PLAY ONLINE item returns (operator() at 0x1b49eb0),
which differs from PLAY OFFLINE's only in the online flag. Offline, the
old patch stays. The launcher's switch and the README say so.
2026-10-18 00:39:13 +01:00
ayozetr 5deacbd471 Launcher: the game region also picks the game's server
The region decides the address of the game's server as well as the text
of regional languages: without one, Log In found no server (issue #9).
Its description in the launcher now says so.
2026-10-18 00:39:13 +01:00
ayozetr 99c49c4b6a Launcher: Seamless Hunt as a co-op server choice
Seamless Hunt, BBSC's community server with players on the Windows port,
took three addresses typed by hand, and a missing port or http:// broke
it (issue #9). It is now a choice next to the public server, your own
and The Hunter's Dream: its shadNet server, WebAPI and game server come
with it, it keeps its own account, and Create makes one there. README and
docs/ONLINE.md say how, and why its bloodstains are off.
2026-10-18 00:39:05 +01:00
ayozetr f0ad983381 Launcher: create an account on a private shadNet server
A private server such as Seamless Hunt had no way to make an account on
Linux: its tool is a Windows program. The account group gets a Create
button for your own server, which registers the name and password typed
above (shadNet's Create command, as shadnet-sample's register). A server
wants one email per account; one is made from the name, as Seamless
Hunt's tool does. The connection test and the new request share one
shadNet exchange (shadnet_request).
2026-10-18 00:24:44 +01:00
ayozetr da1d2ccfc5 Online: Seamless Hunt's bloodstains off by default
Seamless Hunt's bloodstains and death visions (tomb_messenger/message_area)
crash the game with "Transaction failed" on Windows and Linux alike, and
its own BBSC package sends those two requests nowhere by default (issue
#10). The overrides for that server now do the same, so they fail and the
game goes on without them; floor messages, ghosts and summon signs are
untouched. A switch under the game server in the launcher brings them
back.
2026-10-18 00:24:44 +01:00
ayozetr 15f15314cc Launcher: version 0.51-linux.10 2026-10-17 16:15:19 +01:00
ayozetr e5fc8e068a Content: a dump without a region gets Europe
A dump whose param.sfo has no region (USER_DEFINED_PARAM_1 = 0) left the
game without an address for its server: Log In on the title screen failed
with "Unable to obtain Bloodborne server information", whatever game
server was set (issue #9). With no region chosen in the launcher, such a
dump now gets Europe; both regions log in on The Hunter's Dream and
seamlesshunt.
2026-10-17 16:15:14 +01:00
ayozetr 97adb6d8c0 Overlay: the on-screen keyboard and menu follow A and B swapped
The on-screen keyboard typed on the south button and cancelled on the
east one, and the port's menu accepted and went back the same way,
whatever bbport.ini bound to cross and circle. With A and B swapped for
a Nintendo-style controller the game confirmed on A and the keyboard on
B. The runtime now tells the overlay which button cross and circle are
on (runtime_pad_button), and both follow it.
2026-10-17 16:15:14 +01:00
ayozetr 5323911239 Online: a game server typed without http:// keeps working
A game server address without a scheme was taken as https, and servers
that answer the game in plain http on port 20443 (seamlesshunt's) then
failed the TLS handshake. Such an address now gets whatever port 20443
speaks.
2026-10-17 15:59:07 +01:00
ayozetr d38059932a bbsc: the co-op kit comes on a first start with no saves
BBSC_SAVE_DIR was only set from an existing save folder, so a first start
had none and the kit was never given. The runtime now exports the saves'
title id (BB_SAVEDATA_TITLE), and the mod uses the folder the game will
write: savedata/1/<title id>/SPRJ0005.
2026-10-17 15:59:07 +01:00
ayozetr 3e7face701 bbsc: the bell trace records the kit's bells too
BBSC's trace of the responder bell check only kept the vanilla Small
Resonant and Sinister Resonant Bells (205, 225), which the kit retires:
the Kin and Bloodlust Bells (4121, 11201) that replace them were never
recorded, so a greyed-out Kin Bell (issue #9) left nothing to look at.
It now records them as well; that is how the Log In on the title screen
turned out to be the cause.
2026-10-17 11:40:13 +01:00
ayozetr 2c0c4c35dc README: online play may need Log In on the title screen
The game here starts without its own online session, although the port
tells it it is signed in: the session only starts after Log In on the
title screen, and until then the Kin and Bloodlust Bells are greyed out
(issue #9). Said where online play is set up.
2026-10-17 11:40:13 +01:00
ayozetr 7f0513cb32 bbsc: find the item tables in AMD's GPU memory
On AMD the port keeps the game's memory in a GPU dma-buf (the new
memory model), and process_vm_readv cannot read a driver's mapping:
BBSC's search for the item tables found nothing ("0 of 0 table(s)")
the whole session, so the co-op kit never came and the scaling had no
tables either. A slice that process_vm_readv cannot read is now read
again through /proc/self/mem, which goes through the driver's own
access.

Reported in #9 (holydingus, RX 9070 XT); BB_PC_MODEL=0, the older
memory model, brought the items there.
2026-10-17 02:55:08 +01:00
ayozetr 88b990746d Launcher: mark trophies earned on a PS4 or in another version of the port
A player who carries a save over from a PS4 already has some trophies,
and the game only records the ones it unlocks here. The Trophies page's
menu (⋮, "Mark trophies") shows a check per trophy; Save writes the
checked ones to earned.tsv with a third column, "manual", and takes back
the marks unchecked. The ones the game unlocked stay, and the platinum
follows the others as the runtime awards it. The game reads earned.tsv
when it starts, so it doesn't announce a marked trophy again; with the
game running the page doesn't edit it.

Requested in #8 (TacticalBill).
2026-10-16 23:49:42 +01:00
ayozetr caa9b4b17b Runtime: the game's threads get the PS4's floating-point state
The PS4 runs its threads with flush-to-zero and denormals-are-zero set in
MXCSR (0x9fc0); a Linux thread starts with both clear (0x1f80), and the
runtime never set them. The game's own code then got different results
than on a PS4: very small values stayed as denormals instead of becoming
0, which a physics step can drift on (the ghosts' cloth in Cainhurst was
reported glitching), and denormals are slow on x86.

The main thread gets it before the first guest code (the modules'
initializers), and every thread the game creates when it starts. The
values are the ones dumped from real hardware, as in shadPS4#3568
(StevenMiller123, found by red-prig), which fixed similar issues there.
2026-10-16 10:56:00 +01:00
ayozetr f83bc7c567 Launcher: version 0.51-linux.9 2026-10-16 10:18:27 +01:00
ayozetr 5aa78796d2 bbsc: the co-op kit still comes when the character loads after 3 minutes
BBSC looks for the game's item tables to make its kit (Hunt Bell, Kin
Bell, Parting Blank, Duel Bell), and they only appear once a character
is loaded; its search gave up 3 minutes after the start. A player who
stayed longer on the title screen (a first start compiling shaders)
never got the kit, while the vanilla bells already said "No longer
used. Use the Hunt Bell". It now keeps looking, every 5 s for those 3
minutes and every 15 s after.

Reported on r/BloodbornePC (hcaoRRoach).
2026-10-16 09:53:30 +01:00
ayozetr 0c6f49a6f2 Launcher: 16:9 on the usual output sizes, icons and a clearer update check
- The usual output sizes, 720p to 4K, say (16:9), as the ultrawide ones
  say 21:9 or 32:9, in the launcher, the in-game menu and the game's
  System menu. The launcher's 720p drops "(Steam Deck)", which did not fit
  in the list; the row's subtitle already says the Steam Deck is 720p.
- The Updates group's rows have icons, like the rest of the About page.
- An update check that cannot reach GitHub (a connection not up yet when
  the launcher opens) tries again 5 s later, and then says there is no
  connection to GitHub instead of "[Errno -5] No address associated with
  hostname".
2026-10-16 01:17:26 +01:00
ayozetr 8feadda4fb Docs: credits for ultrawide, Quit to Desktop and the keyboard sprint 2026-10-16 00:18:00 +01:00
ayozetrandEthan Murphy 1497357b6b Menus: Quit to Desktop in the title and System menus
The PC builds' title-menu and System-menu entries that the console game hides
(Opinion Box, Language) are shown as Quit to Desktop (bbport.ini
quit_to_desktop, on by default).

- scripts/quit_menu.py makes a mod layer with the entries' texts in the
  game's menu text archives (22 languages; the "japanese" folder takes the
  Japanese texts) and six-row title and System menu movies. run.sh lays it
  after the button prompts and before the ultrawide HUD layout, which builds
  its menu movies on it, all under the mods. The layer is made once per game
  folder and reused while the game files and the scripts are the same.
- patches.py adds the menu code to patches.bin after checking the 01.09
  bytes it replaces. It is left out when the layer is missing (run.sh passes
  --quit-to-desktop 0), and when another enabled patch writes any of the same
  bytes: a third-party "Exit Game (title menu)" patch is kept then.
- The menus signal the runtime through sceSystemServiceLaunchWebBrowser
  (runtime_services.c). "bb:q" (title menu) closes the game. "bb:p" and
  "bb:d" (System menu, then Exit Game's confirmation, answered 1 so it shows
  the Quit to Desktop question) arm a quit after the game's own Exit Game, so
  the game saves first: it closes when a menu/logo_ file opens with no save
  data mounted, after the last save unmounts (runtime_savedata.c), or when
  the title screen is built ("bb:t"). An unanswered arm lapses after two
  minutes. Closing pushes SDL_EVENT_QUIT, the window's own close: the window
  thread ends the process with exit code 0.
- Optional at start: when the layer's files cannot be made, the game starts
  without the entries (quit_menu.py prints an empty layer, as hud_layout.py
  does).
- The switch is in the launcher (Settings, Game effects) and the overlay
  menu, in every language.

docs/TROUBLESHOOTING.md describes the entries, what the log says when they
are left out, the third-party patch and mods that replace the menu texts.
Tests: tests/test_quit_menu.py (code writes, original bytes, the texts in
the archives, the layer) and the browser messages and the quit they cause in
test_runtime.c.

From Supermedo/bloodborne_pc#70 (emurphy04), with Quit to Desktop optional
at start as in Supermedo's 9461312. Thanks: emurphy04, in 7 languages.

Co-authored-by: Ethan Murphy <[email protected]>
2026-10-16 00:17:51 +01:00
079392a447 Ultrawide: patch values computed for the output's aspect, a wider HUD canvas and the HUD position
Replaces the patch selection of 8d1db30, which applied Kyo's 21:9 or 32:9
patch from patches/Bloodborne.xml as it was: his 32:9 patches write a HUD
canvas and projection 3840 or 5120 wide against 1080 high (a squeezed HUD),
3840x1600 had no patch at all, and the HUD canvas they widen did not match the
1920x1080 the port drew the UI with.

- patches.py ultrawide_writes computes the values of Kyo's 21:9 patch for any
  output wider than 16:9: the camera's aspect ratio, the HUD canvas width (the
  `mov e?x, 2560` sites), the HUD projection width and the screen-to-HUD
  mapping of the lock-on and HP-bar markers. At 2560x1080 it is byte-identical
  to his patch. The HUD canvas is 1080 high and as wide as the output's aspect
  (hud_canvas: 2580x1080 for 3440x1440, 3840x1080 for 32:9); patches.py writes
  it as the UI size, and run.sh passes it to the renderer as BB_UI_RES.
- bbport.ini aspect_patch=0 still turns it off (the 16:9 picture stretched),
  and custom output_res sizes keep working, now with their own aspect too.
- UiComposition::Canvas replaces the fixed 1920x1080 in NativeViewport and
  Scale. A scene of exactly the canvas size (2560x1080 or 3840x1080 at Native
  AA) is drawn 4x2 pixels smaller, as 1920x1080 at 4K Performance already was:
  the UI composition tells UI draws apart by that size.
- Menu-only frames (title, loading) get black columns beside the 16:9 stage
  instead of the game's grey clear color; frames with a scene (upscaler off, a
  menu over the world) fill the whole width.
- Output resolutions 2560x1080, 3440x1440, 3840x1600, 5120x2160 (21:9) and
  3840x1080, 5120x1440 (32:9) in the launcher, the overlay menu and the game's
  Display page. They need the startup patch: run.sh never takes live
  resolution changes for them, the renderer does not switch to them live, and
  choosing one in the game restarts it.
- HUD position (bbport.ini hud_position, 0 centred in 16:9 .. 100 at the screen
  edges, the default; launcher and overlay menu): scripts/hud_layout.py, a
  pure-Python SWF/GFX editor, moves the HUD's corner groups in fe.gfx and widens
  full-screen menu backdrops in the menu movies; pictures (title art, loading
  screens) keep their shape. Its output is a layer laid after menu_prompts.py's
  (its gestures menu is built on theirs) and under the mods. It is optional:
  when its files cannot be written the game starts without it, the HUD
  centred.
- run.sh computes the output sizes before the menu layers now, so the HUD
  layer knows the canvas.

The README describes the ultrawide sizes; docs/ENVIRONMENT.md lists BB_UI_RES.
Tests: tests/test_ultrawide.py (patch values, canvas, HUD layout), the
canvas in test_ui_composition.cpp, custom ultrawide sizes and hud_position in
test_settings.cpp.

From Supermedo/bloodborne_pc#70 (emurphy04): ultrawide output and the HUD
layout, with the HUD layout optional at start as in Supermedo's 9461312.
Thanks: emurphy04, in 7 languages.

Co-authored-by: Ethan Murphy <[email protected]>
Co-authored-by: pmwork-personal <[email protected]>
2026-10-16 00:17:39 +01:00
ayozetrandSupermedo 523c88e08e Keyboard: a sprint is kept through direction changes
Measured by cassioKenji (Supermedo/bloodborne_pc#74): the game picks walk
or run by the larger stick axis, and ends a sprint when the stick turns 45
degrees or more between two reads. Our keyboard stick broke both: a
diagonal of the movement keys sat on a round gate (W+A was 90 of 127 on
each axis), and W -> W+A turned 45 degrees in one read.

The movement keys now give the square (the larger axis all the way at
every angle; stick_circular=1 brings the round gate back), the direction
turns toward the keys' at 900 degrees a second and at most 30 degrees a
read, a half turn (A <-> D) goes through forward, and a released key
keeps the last direction for 60 ms. Controller sticks are not touched.
After Supermedo's 4b930b1; cassioKenji in the About page.

Co-authored-by: Supermedo <[email protected]>
2026-10-16 00:17:17 +01:00
ayozetr 558fb3599a Online: The Hunter's Dream through bbbridge is marked unofficial
The Hunter's Dream's team has not approved bbbridge, and players of bbport
builds were asking their support for help (Supermedo hid the pool in his
Windows port for it, 9eeabba). The account group of the Online page now
says the connection is unofficial and that problems go to bbport on
GitHub, in the 7 languages; docs/ONLINE.md says the same.
2026-10-16 00:04:43 +01:00
ayozetr 43c5ee9a77 Docs: the README points to the credits and to a Windows version 2026-10-16 00:03:47 +01:00
ayozetr c7d012aa07 Docs: a shorter README, and every credit in docs/CREDITS.md
The README now shows at a glance what the port offers, how to start and
how to play online; building from source stays, the Add to Steam, button
prompt and DLSS sections go (the launcher explains them). The DLC steps
move to docs/COMPATIBILITY.md. docs/CREDITS.md lists every project this
fork took something from and everyone in the launcher's About page; the
About page's community patches entry links to it.
2026-10-16 00:00:12 +01:00
ayozetr 002f9c170f Launcher: version 0.51-linux.8 2026-10-15 23:25:15 +01:00
ayozetr 4e2d0824e2 Docs: BBSC's items and languages, and the camera on diagonals 2026-10-15 23:24:55 +01:00
ayozetr f6633ea393 bbsc: the F8 unstuck key on Linux
BBSC's F8 (put the player back on the ground when stuck inside another
player) only existed in the Windows mod: it reads the key with
GetAsyncKeyState and checks that the game's window is in front. On Linux
the key comes from SDL, which the game's window already uses, and the
window is in front while SDL has one with keyboard focus.
2026-10-15 23:24:09 +01:00
ayozetr a22c380af1 Controls: an option to even out the camera stick's diagonals (issue #7)
Measured with the camera's yaw and pitch read while the right stick was
held at set positions: the game turns the camera in proportion to the
stick, diagonals included, up to ~82 degrees a second, and past ~96% of
the stick's reach switches to its fast turn, 174 degrees a second
sideways, whenever the stick is not near vertical (still at 60 degrees
from horizontal). A stick pushed all the way on a diagonal therefore
turned sideways at full speed, about twice the speed a moment before -
with any controller, in shadPS4 and on a PS4 alike.

Controls -> "Even camera speed on diagonals" (bbport.ini
pad_camera_diagonal=1) pulls a camera stick that far out between 20 and
80 degrees from horizontal in to 93%: diagonals turn at the proportional
speed and the fast turn stays straight sideways. Off by default: without
the fast turn on diagonals the camera feels heavier to players used to it.

BB_CAM_TRACE=<file> writes the camera's yaw and pitch as it turns, the
measurement this came from.

The round gate's comment no longer names it as the cause of #7.
2026-10-15 23:20:11 +01:00
ayozetr 80bfb4c5e8 bbsc: record bbport's changes to it, as its license asks 2026-10-15 22:49:51 +01:00
ayozetr 28246a9489 bbsc: its texts in the game's language
BBSC writes its messages, item names, descriptions and notices in English
whatever the game's language. They now follow BB_LANGUAGE in Spanish,
French, German, Italian, Portuguese and Russian: bbsc/translations/*.json
map BBSC's English text to each language (texts naming one boss through
their {boss} form), gen_translations.py makes them into
bbsc_translations.inc, and core/bbsc_i18n.cpp looks them up by the English
text, so BBSC's own sources keep their strings. The kit items use names of
their own in each language (Campana de cacería, Cloche de chasse, ...). The
boss name in the host's boss battle line stays BBSC's English one.
2026-10-15 22:47:28 +01:00
ayozetr 1a46cf5bcc Online: keep the session token out of the log
The HTTP library logged every request header with its value, the shadNet
session token (Authorization: Bearer ...) among them, three times per
WebAPI request. Players attach these logs to public bug reports. The
values of Authorization and Cookie are now logged as "(hidden)".
2026-10-15 22:41:42 +01:00
ayozetr c48221a3f3 bbsc: point BBSC at bbport's save folder
BBSC looks for saves in shadPS4's layout under the US title id
(home/<user>/savedata/CUSA00207/SPRJ0005); bbport keeps them in
savedata/<user>/<title id>/SPRJ0005. Without them BBSC gives no co-op kit
(it backs the saves up first: "no save folder found to back up") and its
own-world, loot and progress features have no save slot. BBSC_SAVE_DIR,
BBSC's own setting for a save folder elsewhere, is set to the most
recently written one, and the mod's log says which.
2026-10-15 22:36:39 +01:00
ayozetr b7b97895f2 bbsc: search the game's memory on Linux too
FindInMemory only had its Windows walk (VirtualQuery under SEH) and found
nothing on Linux, so BBSC never found the game's param tables: no co-op kit
(Hunt Bell, Kin Bell, Parting Blank, Duel Bell), no Duel Bell, no enemy
scaling per guest. The readable mappings come from /proc/self/maps and are
read in 1 MiB slices through process_vm_readv, so a page protected or
unmapped meanwhile fails that read instead of faulting.
2026-10-15 22:28:07 +01:00
ayozetr 21ba4491b9 bbsc: keep its files in the game's user folder, not the working directory
In the AppImage the working directory is its read-only mount: BBSC could not
create its capture folder, and without it installs none of its hooks. Its
death patch stayed in, so a death never reloaded and the player was left
waiting with camera control only. The folder is now BB_GPU_USER_DIR's
(run.sh exports it), or the temporary folder when that is not writable.
2026-10-15 22:12:29 +01:00
ayozetr d9599e4663 Docs: every environment variable the port reads
docs/ENVIRONMENT.md lists the 400 or so variables found in the code
(the game, the GPU library, the online module, BBSC, run.sh, the
launcher and the server images): what each does, its values and
default, and where it is read, by area, with the most useful ones
first. It also says how the launcher's Extra variables field is parsed
(space-separated NAME=value, no quoting) and which wins when the
launcher's own settings, the online settings, the extra variables and
run.sh set the same one, and lists the known quirks. The README points
to it.
2026-10-15 13:49:01 +01:00
ayozetr 4a45c725ee Swapchain: the launcher's FIFO Relaxed present mode is used
The launcher offers FIFO Relaxed (BB_PRESENT_MODE=FifoRelaxed), but the
swapchain knew only Mailbox, Fifo and Immediate: it logged "Unknown
present mode" and used Mailbox. A driver without FIFO Relaxed gets FIFO,
as for any mode it lacks.
2026-10-15 13:49:01 +01:00
ayozetr 71de6f9945 Launcher: Seamless Co-op by its name in Spanish
Spanish players know BBSC's kind of co-op as Seamless Co-op, as in other
games' mods; "cooperativo continuo" was nobody's word for it.
2026-10-15 13:38:33 +01:00
ayozetr 94999afb3d Server: BBSC's game server in Docker, shadNet's seamless mode by variable
tools/bbsc-server builds a small image of BBSC's game server
(bbsc/server/gameserver, Python only) taken from this repository: it
listens on BBSC_HOST (waiting for Tailscale, as the shadNet image does),
talks to the shadNet server at BBSC_SHADNET, keeps its messages,
bloodstains and logs in /data, and leaves the play-log uploads to Amazon
S3 off. BBSC_BBHOST=0 drops its bbhost-compatible service.

The shadNet image's entrypoint now honours
SHADNET_BLOODBORNE_SEAMLESS_COOP=1/0 (BloodborneSeamlessCoop in
shadnet.cfg), which docs/SERVER.md already told players to set. The
seamless co-op section of docs/SERVER.md walks through both.
2026-10-15 13:27:50 +01:00
ayozetr a445ff2e5a Online: BBSC's UDP relay only with BBSC on a server of its own
The online module now has BBSC's relay fallback, which by default sends
every peer's frames through UDP 3478 on the shadNet server's address as
well. Only BBSC's game server answers there: without BBSC, or on The
Hunter's Dream (bbbridge's gateway relays by itself), the launcher turns
it off (SHADPS4_P2P_RELAY=0), so their co-op goes direct only, as before.
2026-10-15 13:26:27 +01:00
ayozetrandCLS_AMG f605ea9be4 Online: BBSC's changes to the online module (relay, rejoin, NpLookup)
BBSC's build of the Windows port carries a patch to the shadp2p network
code (bbport-1.7.2-bbsc.patch, in its BBport release), and Linux players
must match it to play with BBSC's Windows players. Brought into the
online module (gpu/bbnet; Supermedo's gpu/shadps4):

- p2p_sockets.cpp, sockets.h: a peer that sends nothing back directly
  within 2 s is reached through the game server's UDP relay (port 3478
  on the shadNet server's address), by online id.
- np_signaling: a re-activation checks the peer, and a new peer
  connection id restarts the handshake (rejoining after a session ended).
- np_utility.cpp/.h (new): sceNpLookupNpId answered from the online id,
  which a third player's join needs; it was the offline stub.
- np_matching2: the peers' endpoints for the relay.
- bloodborne_re.h: BBSC's frames in signaling packets are left to BBSC.

The patch was made against Supermedo e5d04e2; most of it applied as it
is, np_signaling_state.cpp, sockets.h, bbnet_glue.cpp and the header by
hand (line endings and nearby differences).

Co-authored-by: CLS_AMG <[email protected]>
2026-10-15 13:25:33 +01:00
ayozetr 954d969f50 bbsc: keep its logs and captures from growing without end
Every game start wrote a new BBSC log and a new trace capture, kept for
good, and the capture grew about 30 MB an hour of play. Now only the
newest five of each stay (older starts' files go at start), and a capture
stops at 64 MB (Linux build only).
2026-10-15 13:22:45 +01:00
ayozetr d8ec9ed684 bbsc: seamless co-op in the build, the AppImage and the launcher
- build.sh builds libbbsc.so (bbsc/), with xbyak 7.37.3 (BSD-3-Clause)
  and fmt 12.2.0 (MIT) now kept header-only in bbsc/third_party.
- The AppImage carries it in share/bbport/bbsc/ with mod.json and the
  license, notice and supporters list BBSC checks for.
- run.sh sets BB_BBSC_LIB only with seamless co-op on (BB_BBSC=1) and
  online, so bb-probe loads BBSC then only.
- The launcher's Online page gains Seamless co-op (BBSC): the switch and
  BBSC's own options from mod.json (scripts/bbsc.py turns them into its
  variables; only what the player changed is kept), on any server, in 7
  languages. It takes the place of the old seamless option, which only
  routed summons to the server's WebAPI.
- BBSC's relay reads the overrides file run.sh hands the online module.
- Docs: README, docs/ONLINE.md (what works on which server) and
  docs/SERVER.md (BBSC's game server next to shadNet). Thanks: CLS_AMG
  (BBSC) and fhody125-web (NoDreamForHunter, the base of its game
  server), in 7 languages.
2026-10-15 13:21:56 +01:00
ayozetr efb652ad8a bbsc: build BBSC for Linux as libbbsc.so, loaded by bb-probe
BBSC's game logic (emulator/src/core/bloodborne_re.cpp, built with
BBSC_MOD as the Windows mod builds it) now builds on Linux into
libbbsc.so, with the mod's network files (relay, P2P channel, HTTP
wrappers) and Linux counterparts of its platform files:

- mod/linux/bbsc_mod_linux.cpp (for bbsc_mod.cpp): the same start-up -
  every byte patch site checked against the 1.09 bytes before any is
  written, Wozzardman's seamless patches, the hooks, a watcher that
  writes back a site that changed - with the game always at 0x800000000,
  memory questions answered from bbport's own map of the game's memory
  (runtime_memory_vma_info, no system call) and its log in
  <user>/bbsc/logs/, warnings also in the game's log. BBSC's cinematic
  camera (F1, L3+R3: bbport's menu chord) and its own Discord status are
  left out; bbport has its own.
- mod/linux/bbsc_hooks_linux.cpp (for bbsc_hooks.cpp): the same xbyak
  trampolines, in 1 MB mapped below the image.
- mod/linux/bbsc_win32.h: the Win32 and Winsock names the network files
  use, on POSIX; the few spots that differ are #ifdef'd in place (paths
  of host_overrides, a plain socket POST instead of WinHTTP, the game's
  memory map instead of VirtualQuery). On Linux the online module
  applies host_overrides itself, so BBSC's HTTP wrappers leave URLs as
  they are.

bb-probe loads it when BB_BBSC_LIB names it, once the game is linked and
patched and before any of its code runs. None of BBSC's 243 sites
overlaps a patch of patches/Bloodborne.xml or bbport's own hooks. Not
tried in the game yet.
2026-10-15 12:47:59 +01:00
ayozetr fb3f39ba8a bbsc: BBSC 0.0.1 by CLS_AMG (GPL-2.0-or-later), as published
Bloodborne Seamless Co-op from https://github.com/Classmg22/BBSC at
315f76c ("BBSC 0.0.1 - first public test"), unchanged: the game logic
(emulator/src/core/bloodborne_re.cpp and bbsc_*), the Windows mod that
runs it inside the game's process (emulator/mod/), the emulator headers
the mod build uses, and the host servers (server/). Its LICENSE, NOTICE,
CREDITS and SUPPORTERS come along: BBSC turns itself off without them.

The Linux port follows in the next commits.
2026-10-15 12:40:32 +01:00
ayozetr 4140b670bf Docs: what the public server and seamless co-op do today
docs/SERVER.md sold the seamless co-op option as moving the guest into the
host's area. In bbport it only sends the game's summon requests to a
shadnet-p2p server with BloodborneSeamlessCoop=true; shadp2p's host
placement is not ported, and it was never tried in a real session. The
section says so and points to BBSC, and the launcher's option says it is
not ready, in 7 languages.

The UDP line of Test the connection is no longer red with shadPS4's
public server (it answers in its own framing now), so TROUBLESHOOTING.md
no longer blames it for a red line; the public server does not accept
bbport builds at all. BBPORT.md (and its Russian version) no longer
gives srv.shadps4.net as the default without saying so.
2026-10-15 12:15:06 +01:00
ayozetrandJoao Ramos e7cbc43baa Online: the connection test pings the public server in its framing
Test the connection sent the UDP ping with the 4-byte vport header only, which
srv.shadps4.net drops, so its UDP line was always red. The ping now goes out in
both framings, as the game's does, and either reply counts.

Co-authored-by: Joao Ramos <[email protected]>
2026-10-15 12:13:19 +01:00
ayozetrandJoao Ramos f502965635 shadNet: co-op signaling in the public server's framing too
shadPS4's public server (srv.shadps4.net) only takes UDP framed as a PS4 P2P
datagram, FF 83 FF FF FF FF before the payload; the shadp2p servers take the
4-byte vport header we sent. The public server dropped our STUN pings, never
learned the players' addresses, and summons through it timed out (#121 blamed
the server). Packets to the shadNet server now go out in both framings, and
replies are taken in either.

SHADPS4_P2P_SAME_HOST=1 sends a peer at this network's own public address to
loopback, for two instances on one machine.

From Supermedo/bloodborne_pc#21 (004d2c3). Its UPnP part is left out: our
transport already maps its port (issue #121).

Co-authored-by: Joao Ramos <[email protected]>
2026-10-15 12:13:19 +01:00
ayozetr 8c7f4c53fa Launcher: thanks for the work in 0.51-linux.7
Supermedo's skipped draws with an empty vertex table, Awakenchan's fewer
stutters while areas load and the controller in use, and nr1971's reports
of the camera on diagonals and the crash on quitting, in 7 languages.
2026-10-15 12:04:47 +01:00
ayozetr d79bcb27a6 0.51-linux.7 2026-10-15 11:51:30 +01:00
ayozetr cb3252eb62 bbbridge: warn of a missing STUN ping only without the P2P gateway
bbport starts its co-op context before its first STUN ping, so every
session logged "no STUN ping seen ... P2P endpoint unknown" a moment
before the ping came. With the gateway (the default) that address is not
used: bbhost gets the gateway's endpoints, and the port's socket falls
back to the pinned port until the ping comes. The warning is kept for the
case without the gateway, where bbhost's address does come from it.
2026-10-15 11:51:30 +01:00
ayozetr 3f07ea173c Merge branch 'fix/guest-exit' 2026-10-15 11:50:53 +01:00
ayozetr 219be762f8 Merge branch 'feature/fault-skip-textures' 2026-10-15 11:50:53 +01:00
ayozetr 74e061091b Merge branch 'feature/mouse-camera' 2026-10-15 11:50:53 +01:00
ayozetr bd1b901698 Runtime: the game's exit() ends the process like its _exit()
The game's own quit uses _exit(), but a title menu patch that adds Exit
Game calls exit(). That ran the game's static destructors while its other
threads kept running: they tore down Dantelion's mutexes under them
("DLLightMutex.cpp(126) Mutex is not initialized"), and the game faulted
and ended with code 139 instead of 0 (seen in the log of issue #7). The
host's atexit handlers would also free the GPU's objects under the render
thread. exit() now reports, flushes the log and ends the process at once,
as _exit() does; saves are already on disk, written whole.
2026-10-15 10:46:37 +01:00
ayozetrandAwaken ab6e5f4162 GPU: write faults on pages no image watches skip the texture cache
Every write fault on guest memory the GPU side protects went through
TextureCache::InvalidateMemory, which takes the texture cache's lock.
The draw recording thread holds that lock while it creates or uploads an
image, so a game thread writing its constants into a buffer's page (most
faults) waited for that: 0.3-0.6 s with memory short, a stutter of that
frame.

The texture cache now counts, per 4 KiB page and without its lock, the
tracked images that protect each page (watched_pages.h, kept with the
page manager's watchers: up before the pages are protected, down after
they are unprotected). A fault on a page no image watches only
invalidates the buffer cache. An image stops watching its pages only
once it is marked dirty or maybe dirty, so it is refreshed anyway.
BB_FAULT_SKIP_TEXTURES=0 takes the lock for every fault, as before.

From Awaken's BloodBorneButPc (WatchedPages, BB_FAULT_SKIP_TEXTURES).

Co-Authored-By: Awaken <[email protected]>
2026-10-15 02:24:21 +01:00
fd253355e5 Mouse: direct 1:1 camera (mouse_camera=direct)
Mouse look moves the right stick, so it inherits the game's stick dead
zone and turn curve: slow movements fall inside the dead zone, then the
camera jumps. Controls in the launcher gains Direct mouse camera
(mouse_camera=direct in bbport.ini, at mouse_sensitivity: 1 = 900 counts
per radian): the mouse then writes the camera's yaw and pitch itself,
the Monocular's too, like a PC game.

gpu/shim/bloodborne_cam.cpp hooks the camera update of game 1.09 at its
fixed address (the image is at 0x800000000, as in shadPS4, so no memory
scan): the update jumps to a stub that records the camera object, and
the game's own four writes of its angles become NOPs (the same four as
the Disable Camera Auto Rotation game patch, so NOPs there are accepted
and put back as they were). All five sites are checked against the 1.09
bytes first; other code is reported once and the mouse stays on the
stick. The hook is in while the window holds the mouse and out when it
lets it go (F1, the menu), so the stick turns the camera again. While
locked on, the motion goes to the stick as before, so a flick switches
targets. Motion is applied as the window reads it, with no sampler
thread.

From Supermedo/bloodborne_pc edd20a5 by Jhony-Jhansen, ported from
Windows; based on mcrib884's bbmouse camera (Supermedo/bloodborne_pc#3)
and shadlixps4's Bloodborne mouse camera. Thanks updated in 7 languages.

Co-Authored-By: João Pedro Valadares <[email protected]>
Co-Authored-By: mcrib884 <[email protected]>
2026-10-15 02:20:46 +01:00
ayozetrandAwaken fdd924b5e7 Pad: the controller in use takes over when none is chosen
With several controllers connected (or one behind a receiver that shows
as two) and none chosen in the launcher, the first one connected was the
only one read. Now the other ones are looked at five times a second, and
the first with a button held or an axis well away from where it rested
when it was opened takes over ("Runtime: gamepad in use"). Comparing
with where each axis rests keeps a wheel's pedals, which rest at one end
of their axis (issue #15), from taking over. A chosen controller is kept
as before. The launcher's Auto choice now says so.

From Awaken's BloodBorneButPc (follow_active_pad).

Co-Authored-By: Awaken <[email protected]>
2026-10-15 02:12:06 +01:00
ayozetrandAwaken a8f913a9c3 Launcher: install another released version, an earlier one too
The Updates group gains Another version: every release the launcher can
install (tagged <upstream>-linux.<n>, with an AppImage, not a pre-release
or a draft), newest first, the running one marked. Install downloads the
chosen one over the running AppImage like an update, after asking, and
says when it is older and how to come back (the same list, or Update).
Releases before 0.5-linux.1 are left out: their launcher has no updater,
so they could not come back. The list is fetched with the update check;
from a git checkout Install opens the release page instead.

The test reads this repository's real /releases reply, trimmed to the
fields the launcher uses.

Idea from Awaken's BloodBorneButPc (its version selector).

Co-Authored-By: Awaken <[email protected]>
2026-10-15 01:59:34 +01:00
ayozetrandAwaken 14ae3076ca Launcher: import saves from shadPS4, another port or a PS4 export
The saves folder row gains Import saves: a folder is searched for the
game's save folder (SPRJ0005, an export named after it, or any folder
with the system save userdata0010), the newest one is offered, and its
save files are copied in as user 1's saves under the game's save id
(INSTALL_DIR_SAVEDATA, else TITLE_ID, as src/probe.c). The saves already
there are copied to user/savedata-backups/<date>/ first, and the new ones
are staged next to them and swapped in, so a failed copy keeps them. A
console's USB copy (sdimg_*) is encrypted with its keys: the launcher
says to export it unencrypted (Apollo Save Tool, Save Wizard) instead.
It refuses while the game runs. scripts/save_import.py does the work and
also runs on its own.

From Awaken's BloodBorneButPc (SaveImport.cs).

Co-Authored-By: Awaken <[email protected]>
2026-10-15 01:44:32 +01:00
ayozetrandAwaken 27d5d3802f Pad: swap the sticks and invert the controller camera
Controls in the launcher gains three switches, kept in bbport.ini:
pad_swap_sticks moves with the right stick and looks with the left, and
pad_invert_look_x / pad_invert_look_y invert the controller's camera
stick. They apply to the controller after the round gate; keyboard and
mouse keep their own directions (mouse_invert_y). Inverting mirrors
around the centre (256 - v), so a stick at rest stays at rest.

From Awaken's BloodBorneButPc (bbport.ini options of the same names).

Co-Authored-By: Awaken <[email protected]>
2026-10-15 01:40:30 +01:00
ayozetr cc4999adc5 bbbridge: understand bbport's STUN ping and its client version
bbport sends its STUN ping inside a P2P signaling frame (virtual ports
FF FF, FF FF first), which the bridge dropped, so every session logged
"no STUN ping seen ... P2P endpoint unknown" and fell back to the pinned
co-op port. The framed ping now registers the port's P2P socket; it gets
no echo, which would hand the port 127.0.0.1 as its external address.

SetClientVersion (13), which bbport sends after logging in, was answered
Unsupported and logged as "SetClientVersion returned error=33"; the
bridge now accepts it. Both are covered by the end-to-end tests.
2026-10-15 01:38:07 +01:00
ayozetr 9b90958953 Fix translation-memory-test after the copy shader moved to the game profile
Upstream 0.5 replaced the BufferCopyShaderHash constant with the game
profile's buffer copy shader (Profile::buffer_copy_shader_hash), so the
test no longer built. It now sets the hash in the profile it emits with,
so the guest-store path it checks is the one the game uses.
2026-10-15 01:35:52 +01:00
cc28a796ae Pad: Back + d-pad left/right clicks the touchpad halves on Xbox pads
Pads without a touch surface had only Back, a click on the left half, so
the right half (key items, the right choice of some prompts) could not be
reached. While Back is bound to nothing but the touchpad (the default),
Back + d-pad left/right clicks that half and the d-pad press is not passed
on; Back alone clicks the left half for 150 ms once it is released. Pads
with a touchpad are unchanged.

Ported from Supermedo/bloodborne_pc a04e1e9 (Windows 1.7.3), on our
pad.<input> bindings instead of input.ini; the idea and timing are
parsifal295's.

Co-authored-by: Supermedo <[email protected]>
Co-authored-by: parsifal295 <[email protected]>
2026-10-15 01:11:15 +01:00
ayozetrandSupermedo 004c8d40ad Skip draws whose vertex buffer table pointer is null
Some draws leave the vertex buffer table pointer in user data null (four
"Patched SRT walker ... fault address 0x0" lines, then a read fault at 0
in ReadUdReg while specializing the vertex shader), reported at the
cutscene after Rom and in normal play. Such a table now reads as null
buffers and the draw, which has no vertex input to build, is skipped.

From Supermedo/bloodborne_pc 2c4ddeb (Windows 1.7.3).

Co-authored-by: Supermedo <[email protected]>
2026-10-15 01:10:14 +01:00
ayozetr 7191ac0ec7 0.51-linux.6 2026-10-15 00:25:28 +01:00
ayozetr 1cc27f4223 Launcher: what's new in this version, Install from PKG first, more thanks
- About → Updates: "What's new in this version", under the latest version,
  opens the installed version's release notes on GitHub.
- Settings → Game: Install from PKG comes before the game folder it fills.
- Thanks: paulomanrique (ps4-pkg-extractor, in the package for Install from
  PKG), Monochrome0907 (which effects freeze NVIDIA loading screens,
  deadinside28/bloodborne_pc#184) and nr1971's line names the DualShock 4
  camera report too (#7). 7 languages.
2026-10-15 00:25:15 +01:00
ayozetr e107d9e56f Merge branch 'feature/pkg-install'
# Conflicts:
#	build.sh
#	launcher/bbport_i18n.py
#	launcher/bbport_launcher.py
#	packaging/appimage-sharun.sh
2026-10-15 00:18:20 +01:00
ayozetr aedbe8779e Merge branch 'fix/awaken-crash-fixes'
# Conflicts:
#	launcher/bbport_i18n.py
#	launcher/bbport_launcher.py
2026-10-15 00:18:13 +01:00
ayozetr fab33687cf Merge branch 'fix/vram-over-budget' 2026-10-15 00:18:07 +01:00
ayozetr 49e6c535bb Merge branch 'fix/issue163-residency'
# Conflicts:
#	launcher/bbport_i18n.py
#	launcher/bbport_launcher.py
2026-10-15 00:18:07 +01:00
ayozetr 5531775af3 Merge branch 'fix/issue7-ds4-diagonal' 2026-10-15 00:18:00 +01:00
ayozetr 597ad6bad9 Merge branch 'feature/bbbridge-linux' 2026-10-15 00:18:00 +01:00
ayozetrandAwaken ea13d1c243 Patches: the 1 MiB GX command heaps on by default
Awaken's BloodBorneButPc enlarges the same two heaps (the imm32 at 0x2195c09
and 0x2195c46) to 1 MiB by default since 0.9.16, for a GXWorker crash in a
boss intro with Model LOD -2 at 2560x1440: the same NULL allocation as
deadinside28/bloodborne_pc#162 and #169. Their players run it, so the risk
noted when it came in (a parent heap that cannot give 1.5 MiB more) did not
show. On unless BB_GX_COMMAND_HEAP=0.

Co-Authored-By: Awaken <[email protected]>
2026-10-14 23:11:06 +01:00
ayozetrandAwaken 27f3c6d061 Patches: "1.09" is "01.09", and copies of Bloodborne.xml are not third-party
- Third-party patch files that write AppVer="1.09" were skipped; the
  version now compares by number.
- A copy of the built-in Bloodborne.xml in the patches folder (an older
  package's) was read as a third-party file and could apply its frame-rate
  patches over the port's own. A file of that name is skipped.

tests/test_patches.py covers both. From Awaken's BloodBorneButPc.

Co-Authored-By: Awaken <[email protected]>
2026-10-14 23:10:31 +01:00
ed7dbcf626 Pad: buttons still held when a menu closes are not new presses
The Cross that accepted a name in the text dialog, or the button that closed
the port's menu, was still down when the game took input back, and the game
took it as a new press. Buttons held then stay hidden until all are released.
tests/test_pad.c covers it.

From Supermedo/bloodborne_pc 1d34202 (hold_after_capture), as Awaken's
BloodBorneButPc carries it.

Co-Authored-By: Supermedo <[email protected]>
Co-Authored-By: Awaken <[email protected]>
2026-10-14 23:10:31 +01:00
ayozetr 189c03b25e Thanks: Awaken's line as on fix/awaken-crash-fixes
The VRAM work and the crash fixes in one line, so the two branches carry the
same row. 7 languages.
2026-10-14 23:08:01 +01:00
ayozetr a1ba326fdc Thanks: Awaken and AwesomeObserver
Awaken (Awakenchan, BloodBorneButPc): the VRAM work for 4 GB cards and the
crash fixes for flips, the swapchain, garbage descriptors and page faults.
AwesomeObserver (Evgeniy Doronin): large recorded command groups and the
swapchain after display changes. 7 languages.
2026-10-14 23:08:00 +01:00
cc30687384 Swapchain: display events recreate it instead of ending the game
Several display events were assertions or UNREACHABLE (exit 23): a swapchain
that cannot be created, a surface whose capabilities cannot be queried, a
present failing with anything but out-of-date or suboptimal, and an acquire
result outside the known ones. Each is now reported a few times ("Display:
...") and the swapchain recreated, frames skipped meanwhile; a lost surface
(a display or driver reset) is made again on the same window. A lost device
still stops.

- A suboptimal acquire owns an image and signals its semaphore: it is
  presented, then the swapchain recreated (dropping it left the semaphore
  signalled and never waited on).
- The indices start over with a new swapchain, which may have fewer images.
- The device waits for idle under Scheduler::submit_mutex, as the recording
  threads submit to the same queue; the presenter recreates after releasing
  that lock.

From Awaken's BloodBorneButPc engine (bbhost-changes.patch, 0.9.24), with
AwesomeObserver b3164c1 (Evgeniy Doronin); its HDR changes are not taken.

Co-Authored-By: Awaken <[email protected]>
Co-Authored-By: Evgeniy Doronin <[email protected]>
2026-10-14 23:07:35 +01:00
ayozetrandAwaken 3b826a415e Faults: within the page, a stuck tracked page gets its access back
- A write or read fault on the last bytes of a mapping invalidated or read 8
  bytes past its end: IsMapped failed and the fault went unhandled (the game
  ended). It now stays within the faulting page.
- The same access faulting again and again within microseconds (the tracking
  claimed the fault but left the page protected) retried forever; after 64
  in a row, and for a fault nobody claims, the page gets the guest's own
  protection back (runtime_memory_restore_access) when the guest may access
  it. The GPU may then miss writes there until it protects the page again,
  which beats ending the game; instruction fetches and real faults stay
  unhandled. The log says when it happens.
- KernelServiceThread cleared its request count after run(): a request made
  during the run (a timer armed after run() found no work) was lost. It is
  taken before the run now.

From Awaken's BloodBorneButPc engine (bbhost-changes.patch, 0.9.24).

Co-Authored-By: Awaken <[email protected]>
2026-10-14 23:05:14 +01:00
084c7f7d6d Scheduler: a group of recorded data larger than a chunk spills, not exit 23
ReserveRecordData asserted that a command's data fits in a 128 KiB record
chunk: thousands of buffer copies or barriers in one group ended the game
(exit 23). RecordChunk::ReserveData keeps groups that fit inline and gives a
larger one a heap block owned by the chunk, freed when the chunk is recycled,
so no span outlives the commands that read it.

From AwesomeObserver/bloodborne_pc 54bd756 (Evgeniy Doronin), as Awaken's
BloodBorneButPc carries it.

Co-Authored-By: Evgeniy Doronin <[email protected]>
Co-Authored-By: Awaken <[email protected]>
2026-10-14 23:02:54 +01:00
ayozetrandAwaken 23728115f0 VideoOut: no exit 23 for an out-of-order flip IRQ or a full flip queue
- Flip released the buffer the flip replaced (its label back to 0) after the
  flip status and events: the game, rendering into that buffer next, could
  already have set its label to 1 again, and the reset cleared it. The next
  flip IRQ then found no label and stopped the game ("Out of order flip IRQ",
  exit 23). The buffer is released first now; frames are prepared at
  submission, so it is no longer read there.
- The flip IRQ waits up to 20 ms for a label still on its way and then flips
  anyway (at worst a frame shown twice, and the game gets its flip event).
- A full flip queue refused an EOP flip, which nobody could take back (an
  assertion, exit 23): it is queued anyway.

From Awaken's BloodBorneButPc engine (bbhost-changes.patch, 0.9.24).

Co-Authored-By: Awaken <[email protected]>
2026-10-14 23:01:24 +01:00
ayozetrandAwaken eb20620aaf Runtime: failed allocations in audio decoding and the save list are not fatal
- AJM: a job's joined input, sized by the game, is refused above 64 MiB or
  when malloc fails (the job reports an invalid parameter) instead of writing
  through NULL.
- Save list: a failed realloc stops the listing with what it has instead of
  losing the list and writing through NULL.

From Awaken's BloodBorneButPc engine (bbhost-changes.patch, 0.9.24).

Co-Authored-By: Awaken <[email protected]>
2026-10-14 23:00:05 +01:00
ayozetrandAwaken 037a24df0e GPU: a garbage descriptor is reported and read as a known one, not exit 23
Several lookups ended the game (UNREACHABLE or an assertion, exit 23) on a
value outside their tables, as a stale or garbage sharp brings: unknown tile,
macro tile, array modes and pipe configs, sample counts and bpp out of range
(tiling.cpp), a data/number format combination the surface table lacks and
an invalid sample count (liverpool_to_vk.cpp), anisotropy ratios 5-7
(resource.h), format names while logging (pixel_format.cpp). Each is now
reported a few times and replaced by a known value; at worst that surface
renders wrong. The CE/DE counter check compares across the u32 wrap.

From Awaken's BloodBorneButPc engine (bbhost-changes.patch, 0.9.24).

Co-Authored-By: Awaken <[email protected]>
2026-10-14 23:00:05 +01:00
ayozetrandAwaken d637e22258 Guest memory: unused VRAM copies leave under pressure, with the 0.3 model too
deadinside28#163: on NVIDIA and Intel the port runs the 0.3 memory model,
where every block of the game's memory a draw used kept its VRAM copy for
good, so a 4 GB card filled up while areas loaded and stopped (exit 23).
After Awaken's BloodBorneButPc 0.9.17:

- UpdateVramLevel, once a second: VRAM used against the driver's budget,
  tight from 85 % and full from 90 % (BB_VRAM_PRESSURE_PERCENT, 0 off); a
  failed VRAM allocation counts as full for 30 s. The log says each change.
- EvictIdleCopies (0.3 model): while tight, the copies unused for 30 s
  (10 s when full) leave VRAM; their page table entries are cleared and their
  slots take the next blocks. Kept: blocks with GPU-written data, blocks with
  a twin (their version turned this off with twins, which are on here by
  default), and all of them while shaders read the game's memory through the
  page table.
- The PC memory model's idle demotions and chunk evacuations get shorter under
  pressure; no new VRAM copies and no spare block while full.
- EnsureResident: evicted blocks come back with their whole 2 MiB group, and a
  reused slot is cleared before the uploads: it held the previous block's
  data, and draws reading past their range (vertex buffers bound without a
  size) got stretched polygons and flashing lights.

Their spill to system memory is not taken: on NVIDIA the sparse arena takes
VRAM only (an RTX 3060 reports memoryTypeBits 0x2). Not tried in play yet:
BB_VRAM_LIMIT_MB=3000 makes the budget small enough to see it work.

Thanks: Awakenchan, in 7 languages.

Co-Authored-By: Awaken <[email protected]>
2026-10-14 22:58:11 +01:00
ayozetrandMaskZeus 95878adbba Docs: The Hunter's Dream and your own server, as shadPS4's public one refuses bbport
The README, docs/ONLINE.md and docs/SERVER.md still sent players to
shadPS4's public shadNet server, which now signs bbport builds out after the
login. They now describe the two pools: The Hunter's Dream's through bbbridge
(sign in with Discord, or an account with a name and a recovery code; no
router port; the play log stays blocked, so no live map) and a server of your
own. ONLINE.md's log table has the refusal's line.

Credits: bbbridge by MaskZeus in the README and in About → Thanks (7
languages).

Co-Authored-By: MaskZeus <[email protected]>
2026-10-14 16:01:41 +01:00
Supermedo dac2621116 VRAM full: allocate past the budget instead of stopping
On 6 GB cards the game went over the driver's VRAM budget and the next
image or buffer allocation stopped it ("Failed allocating image"). An
allocation the budget refuses is now made past it, where the driver may
keep it in system memory: slower, but the game goes on. Logged once per
kind.

Past the critical mark, old GPU-written tiled images unused for a minute
(render targets of an area left behind) are freed too, unwritten, where
before they could never go. The pressure report counts them.

From Supermedo/bloodborne_pc@44e7644, adapted to this texture cache's
collector. Thanks entry updated.
2026-10-14 15:59:18 +01:00
ayozetr 5a88481947 Guest memory: VRAM full, empty copies go back to the driver at once
deadinside28#163 (a 4 GB card, NVIDIA): "Failed to allocate 1 MiB for guest
memory copies: VRAM is full" while an area loads, and the game stops. Chunks
whose blocks had all left VRAM were kept for 5 s before going back to the
driver, so during a load VRAM could be full of empty chunks.

When a chunk cannot be allocated (and there is no system memory to spill to),
AllocateResidency now frees the empty chunks the GPU is done with at once
(ReleaseEmptyResidencyChunks, the idle pass's loop with no wait), takes the
spare block being allocated in the background if it came, and tries again;
the log says how much it freed. Only then does it stop as before, saying how
much was freed. Evicting blocks still in use is not done there: their unbinds
have to reach the GPU before their memory can go.

BB_RESIDENCY_VRAM_LIMIT_MB=N (tests: VRAM "full" for residency on a large
card) now counts the chunks in VRAM, freed ones subtracted; it counted every
chunk ever allocated, so freeing never made room under it. Not tried in play
yet: BB_RESIDENCY_VRAM_LIMIT_MB=256 to try it.
2026-10-14 15:58:44 +01:00
ayozetr 636621403f README: credit ps4-pkg-extractor for Install from PKG
Tried with real packages (the US release: game 1.00, the 1.09 update and
three add-ons): installed in 3.5 minutes, game_check.py found the 1.09
executable and all 26837 .dcx files unpack.
2026-10-14 15:51:00 +01:00
ayozetr 35acaa9e59 Launcher: install the game from your own PS4 package files
Settings → Game → Install from PKG: choose the game's .pkg, its updates and
add-ons (any number, any order) and a folder. scripts/pkg_install.py tells
them apart by their header's content ID and their param.sfo (category gd, gp,
ac), not by file name: the game goes to <folder>/<title ID>, the updates are
merged into it oldest first, the add-ons go to <folder>/DLC/<label>, and the
launcher then points at both. game_check.py checks the result.

Nothing is replaced: a game folder that exists stops the install, each package
is extracted into a new folder under the destination (pkgextract's -f would
delete an existing one), and the space needed is checked first.

pkgextract (github.com/paulomanrique/ps4-pkg-extractor, Unlicense) is built by
build.sh with cargo at a reviewed revision and its Cargo.lock, and the AppImage
carries it; without it the row is hidden. tests/test_pkg_install.py covers it
with a stand-in extractor. 7 languages.
2026-10-14 15:47:45 +01:00
ayozetr a0148d4930 Patches: an experimental larger GX command heap (BB_GX_COMMAND_HEAP=1)
The game hands out its per-frame GX commands from two 256 KiB heaps (the
size is the imm32 of `mov $0x40000,%edx` at 0x2195c09 and 0x2195c46, before
each heap's init call). When a frame fills one, its allocator returns NULL
and several callers write through it unchecked: the "Guest fault at
0x2199310 (address 0x8)" of deadinside28/bloodborne_pc#162 and the host
fault in memcpy called from 0x21d5b40 of #169 are the same thing. ("Increased
Graphics Heap Sizes" changes other heaps, GFX_GraphicsPrivate/B.)

"Larger GX Command Heap (1 MiB)" makes both 1 MiB. Off by default and not
tried in play yet: their parent heap is not identified, and if it cannot
give the extra 1.5 MiB the game stops at start. BB_GX_COMMAND_HEAP=1 turns it
on. tests/test_patches.py checks the two writes and, with a build, the
instruction they land in.
2026-10-14 15:41:23 +01:00
ayozetr 7e7cedd4b0 Upscaler: one copy of the UI mask's depth handling
OnDraw had the block that sends a UI mask draw (no color target) into the
UI's depth twice: one from bmy's patch (d0318b1, via 0xCydral) and the same
from upstream 0.5. The first returns, so the second never ran; it is gone.
2026-10-14 15:40:32 +01:00
ayozetr ba79de106f Online: a The Hunter's Dream account without Discord
As bbhost's own account screen offers: an account held by this PC, created
with just a name (bbbridge-cli --create), and a recovery code to bring it back
on another PC (--recover NAME CODE). The Online page has both next to the
Discord sign-in; a new account's recovery code is shown once to be kept (it is
also in <user>/bbbridge/bbbridge_state.json). 7 languages.
2026-10-14 15:38:34 +01:00
ayozetrandSupermedo 8f67573bd3 Online: The Hunter's Dream's co-op pool through bbbridge
The public shadNet server no longer accepts bbport. The Online page now also
offers The Hunter's Dream, with bbhost's players: bbbridge stands in for the
shadNet server on this PC and talks to The Hunter's Dream as bbhost does.

- build.sh builds bbbridge-cli when libcurl and OpenSSL are there; the
  AppImage carries it with its licence (share/bbport/bbbridge)
- scripts/online.py: the "thd" server kind (the game signs in to the bridge
  at 127.0.0.1:31313 with the Discord account's online ID, P2P port 3658,
  no UPnP) and `online.py bridge`, which writes the bridge's config and the
  game's host overrides for it into <user>/bbbridge. The game's play log
  stays blocked, so The Hunter's Dream's live map is not fed.
- run.sh starts bbbridge-cli for the launch (or uses one already listening)
  and a watcher stops it once the game has ended
- launcher: the choice shows only when the build has the bridge; a Discord
  sign-in (bbbridge-cli --link-discord) shows the code and opens the page;
  the password, UPnP and port rows are hidden for it; 7 languages

After Supermedo/bloodborne_pc bb08740 (the same pool for bbport for Windows).

Co-Authored-By: Supermedo <[email protected]>
2026-10-14 15:35:53 +01:00
ayozetr 21e0edd40a bbbridge: the core, bbbridge-cli and the tests build and pass on Linux
The Windows build is unchanged; everything for Linux is under #ifdef _WIN32's
else branches:
- util.hpp: BSD sockets under Winsock's names (SOCKET, closesocket, Sleep,
  WSAGetLastError, localtime_s), and fspath()/replace_file() for the state
  and log files, whose names stay std::wstring (UTF-32 on Linux)
- closesocket() shuts the socket down first: on Linux closing it does not
  wake a thread blocked in accept() or recvfrom() on it, as it does on Windows
- net_init() ignores SIGPIPE (a send to a closed peer ended the process)
- listening sockets use SO_REUSEADDR on Linux, which otherwise refuses a port
  still in TIME_WAIT from the last run (Windows allows it)
- http.cpp: libcurl in place of WinHTTP, with the same behaviour (certificates
  checked against the system's CA bundle, no redirects, the request's timeout,
  a Pool keeping one connection alive until it is max_age old)
- SHA-256 from OpenSSL in place of bcrypt; gethostname() for the device name
- p2p.cpp: select() gets the highest descriptor + 1; no SIO_UDP_CONNRESET
- bbbridge-cli: main() and SIGINT/SIGTERM; --route (pointing bbport for Windows
  at the bridge, setup.cpp) is Windows-only, as the bbport launcher does that
  on Linux
- CMake: on Linux the core, bbbridge-cli and bbbridge-unit, with Threads, CURL
  and OpenSSL::Crypto; the window (Win32 + DirectX 11) stays Windows-only

bbbridge-unit: 24 passed. tests/e2e/test_e2e.py against the Linux
bbbridge-cli: 54 passed; its part E (routing bbport for Windows) runs on
Windows only.
2026-10-14 15:29:37 +01:00
ayozetrandMaskZeus a7e6d44076 bbbridge 0.3.2 by MaskZeus (GPL-3.0-or-later), as released
MaskZeus/bbbridge at 7cb7b68, unchanged: it stands in for the shadNet server
on this PC and talks to The Hunter's Dream the way bbhost does, so bbport
plays co-op with bbhost's players. Windows-only as released; the Linux port
of its core and command-line bridge follows.

Co-Authored-By: MaskZeus <[email protected]>
2026-10-14 15:23:19 +01:00
ayozetr 2b8d9e1fe1 Pad: a controller's sticks stay within a round gate
#7: with a DualShock 4 the camera turned two or three times faster at one
point of a diagonal, less so at 30 FPS; with an Xbox-style pad it does not.
The likely cause (not confirmed yet): a stick that reports a diagonal near the
corner of the square, up to ~1.41x the full deflection of one axis, which a
stick with a round gate never does. A stick past the gate is now brought back
onto it, keeping its direction; one axis all the way stays as it is. The log
says how far past the gate each stick went, once and again when further, so a
log tells whether a controller does this. pad_round_gate=0 in bbport.ini
passes the sticks on as they come.

tests/test_pad.c covers it (and now links libm).
2026-10-14 15:16:55 +01:00
ayozetrandSupermedo 1ccef67d2c Log: the memory in use once a minute
"Memory:" in the log every minute: VRAM, the texture cache, its images unused
for a minute and of them the GPU-written tiled ones (which the collector
cannot free), and the process's resident and anonymous memory. A log that ends
in a crash after an hour of play then shows what was growing (#1, #163).

After Supermedo/bloodborne_pc 5dc1f11; the process's memory comes from
/proc/self/status, as statm's first field is the address space, huge with the
game's reservations.

Co-Authored-By: Supermedo <[email protected]>
2026-10-14 13:51:32 +01:00
ayozetrandAndiMajore a643904df0 Debugging: BB_FAULT_WAIT=1 keeps a crashed process alive for gdb
A fatal fault in bb-probe or a GPU-library assertion ended the process at
once, leaving one line in the log. With BB_FAULT_WAIT=1 (launcher: extra
environment) the fault handler and assert_fail_impl print how to attach, send
a desktop notification and wait instead of exiting; any process of the user
may ptrace it (PR_SET_PTRACER_ANY, set at start only then). Nothing changes
while the game runs.

tools/debug_attach.sh [pid] [--keep] attaches gdb in batch mode and writes
logs/crash-<time>-<pid>.txt: every thread's stack, the faulting thread's
registers and code (game code as a guest offset), and bt full of an asserting
thread. docs/DEBUGGING.md describes it.

From deadinside28/bloodborne_pc#165.

Thanks: AndiMajore, in 7 languages.

Co-Authored-By: AndiMajore <[email protected]>
2026-10-14 13:49:52 +01:00
ayozetr a3e31c7227 Online: an exit() with the network worker running no longer aborts
NpHandler::Shutdown() is never called, so at exit() (a STOP with code 21 or
22, the online test) the worker thread was still joinable and std::thread's
destructor aborted the process, which also hid the real exit code. The
handler's destructor now tells the worker to stop and lets it go. The game's
normal quit uses _exit() and was not affected. tests/test_online.c passes again.
2026-10-14 13:48:24 +01:00
ayozetr 6b12b22376 Online: the CRLF line endings back in four files
2bcc093, 576a214 and b3c8b1d rewrote net_util.cpp, np_handler.cpp and
shadnet/client.cpp/.h with LF endings, so against upstream every line of
them showed as changed. Endings only; no code changes.
2026-10-14 13:48:24 +01:00
ayozetrandJoão Pedro Valadares a36201f796 Keyboard movement: a round diagonal, and the last of two opposite keys wins
- W+A gave the left stick the corner of a square, about 1.41x a thumbstick's
  reach. A diagonal of the movement keys is now full deflection within a round
  gate, as on a controller; stick_circular=0 in bbport.ini gives the corner.
- Opposite keys held together cancelled out, so switching A <-> D (both down
  for a moment) let go of the stick and ended a sprint. The one pressed last
  wins now, on both sticks; stick_socd=neutral gives the old behaviour.

tests/test_pad.c covers both. After João Pedro Valadares's keyboard movement in
Supermedo/bloodborne_pc (576514c, a147eb8), adapted to bbport.ini's controls;
its optional release delay and smooth turns (off by default there) are left out.

Thanks: Jhony-Jhansen, in 7 languages.

Co-Authored-By: João Pedro Valadares <[email protected]>
2026-10-14 13:48:15 +01:00
ayozetr 3c2033b5d0 Steam Deck Game Mode: pressure-vessel's Vulkan settings are not the user's
In Game Mode Steam runs a non-Steam game in pressure-vessel, which points
VK_DRIVER_FILES, VK_ICD_FILENAMES and the layer paths at its own manifests.
The Nix package took them for the user's own override and never registered
its Mesa: no VK_KHR_surface, no window. The AppImage (sharun) merged them
with its drivers, so pressure-vessel's proxy driver came along too.

Both entry points (packaging/bbport-entry.c and the AppImage's bin/bloodborne)
now drop the Vulkan variables that point into pressure-vessel, keep them as
BB_HOST_* for the log, and turn off the host's gamescope WSI layer with them.
A variable the user set elsewhere stays. Checked with the AppImage's own
loader: only the package's Mesa and the host's NVIDIA driver are listed.

From deadinside28/bloodborne_pc#172. Thanks: segasonicye (diagnosis and
workaround), in About → Thanks in 7 languages.
2026-10-14 13:44:23 +01:00
ayozetr d5c2611f72 0.51-linux.5 2026-10-14 13:20:52 +01:00
ayozetr 73f3bb512a Merge branch 'fix/issue5-loading-freeze' 2026-10-14 13:13:42 +01:00
ayozetr 5e86d0bde7 Thanks: Supermedo's line names the skipped pipelines too
49a37b4 (draws of a pipeline the driver cannot create are skipped) came from
Supermedo/bloodborne_pc 188c1f6; his line in About → Thanks now says so, in
7 languages.
2026-10-14 13:13:37 +01:00
ayozetr 30b3409674 Merge branch 'main' into fix/issue5-loading-freeze 2026-10-14 13:00:42 +01:00
ayozetr b3c8b1dadd Online: a server that refuses this build is said plainly, and not retried
The public shadNet server now refuses bbport's client version (ClientRejected,
"doesn't accept your shadPS4 build") and signs the game out. We then retried
the sign-in, which the server refuses with LoginError for a while, longer with
each try, and the player only saw the game's "not signed in" message.

- the client remembers the refusal; the handler signs that user out without
  reconnecting
- the game prints "Online: the shadNet server ... does not accept this build"
  with the server's message, and the launcher shows it in a dialog that points
  to the own-server option (7 languages)
2026-10-14 12:48:44 +01:00
ayozetr 697af8936f Merge branch 'main' into fix/issue5-loading-freeze
# Conflicts:
#	launcher/bbport_i18n.py
#	launcher/bbport_launcher.py
2026-10-14 12:21:14 +01:00
ayozetr 576a214b5f Online: the game's play log telemetry is not sent
With online play on, the game uploads FromSoftware's "Playlog" telemetry
(deaths, map changes, weapon switches, ...) to bb-playlog-prod or
bb-playlog-test on Amazon S3, hosts written in the game's own executable.
Their names no longer resolve, as with no network; the log says so once.
2026-10-14 12:16:06 +01:00
ayozetrandpmwork-personal 8d1db30f9e Ultrawide: widen the view for outputs wider than 16:9, and keep custom sizes
- An output_res wider than 16:9 (5120x2160, 3440x1440, ...) filled the screen
  but kept the game's 16:9 projection, so the picture was stretched. patches.py
  now takes the community resolution patch for that size (Kyo's 21:9 and 32:9
  ones in Bloodborne.xml, or one of the same aspect) and applies only its writes
  that widen the game's screen: not the scene and UI sizes or the heap patch,
  which bbport sets itself. bbport.ini aspect_patch=0 turns it off.
- The in-game menu and the launcher listed four sizes: a custom output_res
  became 3840x2160 with the first saved setting, and 1280x720 in the launcher.
  Both keep it now until another size is chosen; tests/test_settings.cpp
  covers the menu side.

From deadinside28/bloodborne_pc#183.

Thanks: pmwork-personal, in 7 languages.

Co-Authored-By: pmwork-personal <[email protected]>
2026-10-14 12:14:54 +01:00
ayozetrandAryxa 16a08695c3 Launcher: expose the host's libcuda.so.1 to the AppImage for DLSS on NVIDIA
With DLSS loaded, NGX makes NVIDIA's driver dlopen libcuda.so.1, but the
driver cache linked only libnvidia-*, libGLX_nvidia and libEGL_nvidia, and the
AppImage's glibc does not search the host's library folders: the load failed.
libcuda.so* joins the patterns; test_packaged_vulkan checks the link.

From deadinside28/bloodborne_pc#137.

Thanks: Aryxa, in 7 languages.

Co-Authored-By: Aryxa <[email protected]>
2026-10-14 12:11:52 +01:00
ayozetrandAyrtonSanabio b4d74c88f7 Menu language: --play passes the launcher's, and only a chosen one is saved
- play() never called set_language(), so --play (Steam Deck game mode) gave
  the in-game menu BB_MENU_LANGUAGE=ru whatever the launcher's language was.
- Save() wrote menu_language every time, so the language the menu merely
  started with went into bbport.ini with the first changed setting and stayed
  there. It is written now only when chosen in the menu or already in the file.
  tests/test_settings.cpp covers it.

From deadinside28/bloodborne_pc#153 and #154.

Thanks: AyrtonSanabio, in 7 languages.

Co-Authored-By: AyrtonSanabio <[email protected]>
2026-10-14 12:11:31 +01:00
ayozetr bb3a9d126d GPU breadcrumbs: off when the marker function is missing, instead of a crash
deadinside28#177: built against other Vulkan headers, the buffer marker
function stayed null although VK_AMD_buffer_marker was on, and the first
marker crashed. Breadcrumbs now check the function too and say they are off.
2026-10-14 12:10:36 +01:00
ayozetrandemurphy04 3d2b8b3067 AvPlayer: a queue cleared while a frame is taken no longer crashes
AvPlayerQueue::Pop checked the size outside the lock, so a Clear() from
another thread (the player stopping, the game closing) between the check and
front() read an empty queue, and GetVideoData/GetAudioData used the empty
result without checking it. The check is under the lock now and both callers
give up when nothing came back.

Thanks: emurphy04, in 7 languages.

Co-Authored-By: emurphy04 <[email protected]>
2026-10-14 12:10:04 +01:00
ayozetrandparsifal295 186a342750 Overlay: Quit game works from a controller, and the menu stays on screen
- The Quit button's label was its ImGui ID: at the first press the label
  changed, the new button did not have the pad's or keyboard's focus and the
  button disarmed at once, so quitting worked only with the mouse over it.
  "###bb_quit" keeps one ID for both labels.
- The menu sized itself to its contents: on a short screen its last row
  (Close, Quit game) was off screen. Its height is now limited to the screen
  below where it opens, and the contents scroll.

Thanks: parsifal295, in 7 languages.

Co-Authored-By: parsifal295 <[email protected]>
2026-10-14 12:09:42 +01:00
ayozetrandStefano Scalfari 2bcc093bd9 shadNet: report the client version after signing in
A shadNet server with clientversions.cfg rules signs out a client that never
sends SetClientVersion, then refuses its logins with LoginError for a while.
We sent nothing, so on the public server the game went offline a few seconds
after signing in and the next logins failed.

- send SetClientVersion (command 13) right after the login reply; the string
  is "shadPS4 bbport", BB_CLIENT_VERSION overrides it
- log the ClientRejected notification (19) with the server's message
- keep the SetClientVersion reply away from OnScoreReply

Thanks: Vorshim92, in 7 languages.

Co-Authored-By: Stefano Scalfari <[email protected]>
2026-10-14 12:08:54 +01:00
ayozetr 8937a721ca GPU: say so when a submission is not finished after 10 s
Semaphore::Wait waited without a limit, so a GPU stuck on a draw (the
presenter waiting for frames ahead, as in deadinside28#184) froze the game with
nothing in the log. It now waits in 10 s slices, prints once that the
submission is late with the GPU breadcrumbs, and says when it finishes after all.
2026-10-14 12:08:16 +01:00
ayozetr 4f3187843f Launcher: a failed update check says why instead of staying on "Checking"
When the check failed (offline, GitHub's rate limit) the result is the
error text, and the update row read its "tag" before seeing that, so it
raised and the row stayed on "Checking…". It now shows the error.
2026-10-13 22:22:55 +01:00
artemkaVG da5561d6c7 Texture cache: do not take a color image at the stencil address as the stencil
FindDepthTarget linked whatever image sat at the stencil address as the
depth target's stencil. When a color target reuses that memory, reads and
storage writes to it went to the depth buffer through an invalid view.
Only images with a stencil-compatible format (or none yet) are linked now.

From shadps4-emu/shadPS4#5118. Its author saw the NVIDIA GPU hang (Xid 109)
go away in Uncharted 2 with it; maybe related to #5. Thanks entry added.
2026-10-13 22:22:55 +01:00
ayozetr f9186dbb8d Presenter: say so when a frame or a swapchain image does not come for 10 s
The frame fences and the swapchain acquire were waited for without a
limit, so a GPU that never finished a frame, or a compositor that kept
every image, froze the game with nothing in the log (#5: frozen loading
screens, GPU at 0 %, NVIDIA Xid 109). They are now waited for in 10 s
slices: past the first one the log says which wait it is, once, with the
GPU breadcrumbs for a frame, and again when it does finish.
2026-10-13 22:22:55 +01:00
Mrsuss60 1b93552dca Pad: the touchpad from the keyboard as a real touch, for the gestures and personal effects menus
Tab and Backspace (and an injected touchpad press) sent a touch with id 0
and no hold time. Each press now gets its own id (1..127) and the time held
since it went down, as the pad reports a touch.

From Mrsuss60/bloodborne_pc_windows_port@fb0d4db (#56). Thanks entry updated.
2026-10-13 22:17:40 +01:00
ayozetr d7a94e5dfc README: badges for downloads, latest release, license, platform and Ko-fi 2026-10-13 16:52:03 +01:00
ayozetr e2b3f73e71 Launcher: icons in About and Settings, whole tab names, uncut text
- About: GitHub's mark beside the three project links and a certificate
  beside the license, which now reads "GNU GPL v2 or later · No game files
  included."; that Bloodborne belongs to Sony Interactive Entertainment and
  FromSoftware is plain text under everything.
- Settings: Steam's and Discord's marks beside Add to Steam and the Discord
  status.
- The icons are the desktop theme's (github-, steam-, discord-symbolic); a
  theme without them gets the launcher's own (launcher/icons, from Simple
  Icons, CC0), not the installed app's colour icon.
- The tabs show their names whole (icon above the name), where they were cut
  off ("Ajus…", "Trof…") at the default width.
- Text is drawn with GTK's cairo renderer: its GPU renderers cut the top row
  of pixels off the letters at fractional display scales. GSK_RENDERER set by
  the user still wins; the game draws with its own Vulkan.
- The launcher language's title says "Launcher language" once in English.
2026-10-13 12:58:05 +01:00
ayozetr 1ce06532c2 Thanks: GerasimosKan's trophy chime too
His line in About → Thanks names the chime as well as the English trophy names.
2026-10-13 11:26:47 +01:00
ayozetr 49a37b4459 GPU: skip the draws of a pipeline the driver cannot create
"Failed to create graphics pipeline: ErrorUnknown" (some AMD and older
drivers) stopped the game with code 23. The pipeline now stays empty and is
logged; its draws and dispatches are skipped, on the GPU thread and when a
background compile finishes, and it is not saved to the cache, so the next
start tries it again. A cached one the driver rejects already rebuilt the
cache.

After Supermedo/bloodborne_pc 188c1f6, adapted to the background compiles.
2026-10-13 11:26:21 +01:00
ayozetr 25cdef7482 Trophies: a short chime when one is earned
When a trophy's notice shows, a bell of our own plays: two notes, a third for
the platinum, synthesized on its own SDL stream beside the game's (no audio
from the console). "Sound when a trophy is earned" in the menu's Other section
turns it off (trophy_sound in bbport.ini); BB_AUDIO=none keeps it silent like
the game.

The chime is GerasimosKan's, from deadinside28/bloodborne_pc#127.
2026-10-13 11:26:21 +01:00
ayozetr 9413c82bfb Trophies: English names without the trophy key
Without the PS4's trophy key the lists in the dump stay encrypted, and the
notices and the Trophies page had only icons and numbers. For Bloodborne the
names, descriptions, grades and hidden flags now come from PSN's English list
(scripts/trophies.py BLOODBORNE_ENGLISH), with the platinum counting 1-33; with
the key they are still the game's own, in its language. Checked against the
dump's encrypted list.

The list is GerasimosKan's, from deadinside28/bloodborne_pc#127; About →
Thanks names him.
2026-10-13 11:26:16 +01:00
ayozetr f4a12043f6 Launcher: the shader cache's size, and a button to clear it
Settings → Performance shows how much the port's shader cache takes (the
shaders the GPU library translated, <user>/cache) and clears it after asking,
while the game is not running: the next start builds the shaders again, with
some stutter at first in each area. For a picture that looks wrong after an
update. Saves, settings and the drivers' own caches stay.
2026-10-13 11:15:23 +01:00
ayozetr 7e02e06139 FSR 4: the balanced model for Performance and Ultra Performance
The v07 performance and ultra performance models leave trails behind moving
characters in Bloodborne at 2x and 3x upscaling; the balanced model handles
those ratios cleanly. Both presets keep their render size and use it. Tested
at 1280x720 to 2560x1440 and the Ultra Performance ratio.

From IFreemz/shadPS4-Bloodborne-DLSS-FSR (9b2766e), whose line in About →
Thanks now says so.
2026-10-13 11:08:33 +01:00
ayozetr 7135aa9288 0.51-linux.4
The launcher's version for the About page's updates.
2026-10-13 10:40:20 +01:00
ayozetr 48de7625b5 AppImage: Mesa's Vulkan drivers for AMD and Intel again
quick-sharun collects Mesa's Vulkan drivers from the build host, and the host
had NVIDIA's driver only: no AMD or Intel driver went into the package, and
the game found no GPU on those cards ("Failed to enumerate physical devices:
ErrorInitializationFailed", #4), in every release built that way. The script
now stops when vulkan-radeon or vulkan-intel is missing on the host or from
the AppDir. sharun points Vulkan at the packaged drivers, plus the host's
NVIDIA driver when there is one.

Thanks: nr1971, who reported it.
2026-10-13 10:40:20 +01:00
ayozetr 1ca5a39b3a Launcher: a Thanks page in About
"None of this would be possible without them": everyone whose work is in
this fork, with what it brought, and those who helped it with reports and
tests. Each name opens its GitHub profile; the people credited on Nexus Mods
and the game patches' authors are listed after them. Someone whose work was
only looked at is not listed: whoever a commit brings something from is
added in that commit. In every launcher language.
2026-10-13 01:49:20 +01:00
ayozetr 34ca9d1340 0.51-linux.3
The launcher's version for the About page's updates.
2026-10-13 01:26:23 +01:00
ayozetr b18055557b On-screen keyboard: the letters of the menu's language
The keyboard had the English letters only, so a controller could not type
the ñ that a physical keyboard gives the name. A row with the menu
language's own letters now sits above the actions: Spanish ñ, the accented
vowels, ü, ¿ and ¡; the accents of Italian, French and Portuguese, ç among
them; German's umlauts and ß. English, Russian and Chinese keep four rows.
2026-10-13 01:22:04 +01:00
ayozetr ebb79d3774 Loading and smoothness: three game patches from bbhost
A new group in the launcher's Settings page, with patches from
droogie/bbhost (patches/*.toml), each checked against the 1.09
executable's original bytes, noted beside it:

- Quick loading after death (on): a load after a death or lamp travel
  ends when the area is ready, 2-3 s, not after the 12-second minimum
  the in-game step waits out. The multiplayer wait stays the game's.
- No stutter every ~50 s (on): the player's recording is deflated on the
  main thread at level 9, ~23 ms in one frame; level 1 does it in a
  fraction of that, and inflate reads either.
- Third resource preparation thread (off, experimental): the resource
  system's third post-processor keeps running in play.

The first two are on also when bbport.ini does not name them yet.
2026-10-13 01:22:04 +01:00
ayozetr 479e964d90 Overlay: a notice while shaders compile
"Compiling shaders…" in the bottom-left corner while the pipeline cache
compiles a shader or pipeline, kept 400 ms after the last one and faded
out over 400 ms more, so a burst does not flicker. A stutter at that
moment is the shader cache filling, once per place, not a fault worth
reporting. On by default; "Notice while shaders compile" in the menu's
Other section turns it off (shader_notice in bbport.ini).

From SrShadowy/bloodborne_pc (gpu/shim/bbport_shader_tracker), counted
in the existing CompileTimer.
2026-10-13 01:22:00 +01:00
ayozetr fb25a78b5d On-screen keyboard for the character name, typed with a controller
The game's text dialog (ImeDialog: the character's name) could only be
typed on a physical keyboard, so in the Steam Deck's Game Mode or with a
controller on the sofa a character could not be named. The dialog now
shows a keyboard: the D-pad or the left stick moves, Cross types,
Square deletes, Triangle is a space, L1/R1 switch caps, Options
confirms and Circle cancels. A physical keyboard types as before.

From SrShadowy/bloodborne_pc (gpu/shim/bbport_keyboard.cpp), reworked:
the keyboard is drawn in the existing dialog and its edits are queued
to the window thread, which owns the text and the window. There, the
keyboard's mouse clicks changed the text and the window title from the
render thread. Its texts are in the menu's languages.
2026-10-13 01:22:00 +01:00
ayozetr 5bf966b85a Launcher: Test the connection signs in with the account too
It only checked that the servers answered, so a wrong name or password
showed up in the game as "not signed in to PlayStation Network". With
the account filled in, the test now signs in to the shadNet server as
the game does (ServerInfo, then a Login request) and leaves at once,
and says what the server answered: signed in, wrong name or password,
no such account, or already signed in (the game running with the same
account). srv.shadps4.net answers "wrong password" for an unknown name
as well, so the text names both.

Tests against a stand-in server check the request and each answer.
ONLINE.md says so, and that the game now keeps trying when the server
did not answer at start.
2026-10-13 01:07:23 +01:00
ayozetr e7f5b9f575 shadNet: keep trying in the background when the server does not answer at start
A server that did not answer when the game started (still starting,
Tailscale not up yet, a short outage) left the game offline for the whole
run: the start-up probe disabled shadNet for the session, and a dropped
connection was retried only by the worker a first successful sign-in
started. An unreachable server now schedules the same background retries,
with a backoff of up to 30 s, and the game signs in once it answers. A
sign-in that fails because the server did not answer is retried too.

Each user's latest attempt keeps how it ended. A server that answers and
refuses (wrong name or password, an incompatible protocol) is not retried,
at start or after a drop, since retrying cannot help.
2026-10-13 01:07:23 +01:00
Supermedo 51927fb018 SRT walker: leave speculative walks to their recovery point
Draw preparation workers and the recording thread's table check walk resource
tables ahead of the game and may read memory it has reused. Their faults went
to the SRT walker handler, which patched the walker for good: one stale read
made that load zero for the rest of the session. They now go back to their
recovery point, and the GPU thread walks the tables itself.

The patch is also written in an order that keeps every intermediate state
harmless for threads running that code: the nops from the end, then the ModRM
byte, the REX prefix and the xor opcode last, with atomic stores.

From Supermedo/bloodborne_pc 479f2f5, on top of 0xCydral's lock and retry.
2026-10-13 00:57:32 +01:00
0xCydral c5c983c248 fix(gpu): draw preparation steps over an invalid PM4 header like the GPU thread
The GPU thread steps over an invalid packet header (a type 0 header
Bloodborne leaves in a command buffer at an area load) instead of ending
the game, but the draw preparation scanner still stopped reading that
buffer there. Its register state then differed from the GPU thread's for
the rest of the session, so no prepared draw was used again and every
new pipeline was compiled on the GPU thread: on an RX 6700 XT draw
preparation fell from about 96% used to 0% after one such header, with
stutter. Both now skip by one rule (pm4_resync.h); pm4-resync-test
checks it on the bytes from that log.

From 0xCydral's ed19a84
2026-10-13 00:57:32 +01:00
0xCydral a80e1ed7d5 fix(shader): an exception in speculative draw preparation ends the attempt, not the game
Draw-preparation workers specialize shaders from game memory the game may
still be writing. A fault there already ends the attempt and leaves the
draw to the GPU thread; a C++ exception from the same data (a container
over its capacity, an empty optional) escaped the worker and ended the
process through std::terminate. One cold start in fourteen died that way
(exception 0x20474343, exit 3) and could not be reproduced. The
speculative specialization now treats an exception like a fault.
siglongjmp restores registers without unwinding, so a fault jump out of
the new try block is unaffected.

From 0xCydral's df619c7 (adapted: here only the specialization runs
ahead of the GPU thread)
2026-10-13 00:57:32 +01:00
0xCydral a07910eb89 fix: a failed check in speculative draw preparation abandons it instead of stopping the game
Draw-preparation workers specialize shaders ahead of the GPU thread from
descriptors the game may still be rewriting. Faults there already
returned to the worker's recovery point; a failed check stopped the port
with exit 23. It now leaves through the same recovery point and the GPU
thread prepares the draw in order. The SRT walker fault handler disarms
the recovery point while it holds its lock.

From 0xCydral's 917d2c0 (adapted: here the workers specialize programs
already translated, they do not translate new ones)
2026-10-13 00:57:32 +01:00
0xCydral 2e4a55f8bb fix(gpu): skip a draw whose shader program address is not readable
A malformed command buffer can leave a shader program address that is
not mapped (0x4c0012d0000 during an area load), and reading the program
there ended the game. The pipeline cache checks that a stage's program
is readable and skips the draw or dispatch otherwise; the last readable
mapping is cached per thread against the mapping table's generation.

From 0xCydral's b99535b, itself from yumlevi/bloodborne_pc ce385b6 and
GoncaloLobo0/bloodborne_pc_win ef70ed2. Its other half, hashing textures
through the backing view, is left out: here a read-protected page holds
GPU-written data the fault reads back, and the backing view would hash
the stale copy.
2026-10-13 00:57:32 +01:00
0xCydral f1d897ea0d fix(shader): SRT walker fault handler is safe for concurrent draw-prep workers
Two draw-preparation workers can fault on the same SRT-walker store. The
second one decoded the already-patched instruction and tripped the MOV
ASSERT (exit 23). The handler now takes a mutex and returns early when the
instruction is already the xor patch. Registration of the handler goes
through call_once, and walkers loaded through RegisterWalkerCode register it
too: when every walker came from the pipeline cache, it was never
registered.

From Mrsuss60/bloodborne_pc_windows_port b7001bc (fault handler hunk only,
merged by hand)
2026-10-13 00:57:32 +01:00
0xCydral 9779cbbc70 fix(fsr4): depth barriers name the depth and stencil aspects
The vendored FSR 4 backend issued its layout barriers on every external
image with VK_IMAGE_ASPECT_COLOR_BIT, invalid for the D32_SFLOAT_S8_UINT
depth image (VUID-VkImageMemoryBarrier-image-03319 and
subresourceRange-09601, 20 each in a validation run).

New fsr-vulkan patch 0002 adds an optional aspectMask to
FfxFsr4VkExternalImageState (0 = color); Register passes DEPTH|STENCIL for
the depth image.
2026-10-13 00:57:32 +01:00
andrecabralgit e36717374e Audio: the game at full level on stereo devices
The game opens 8-channel ports and SDL turned them into stereo with its 7.1
matrix, which scales the front pair by about 0.21 so a full 8-channel sum
stays under 1. The game's mix lives in the fronts, so on a stereo device it
played about 13 dB quieter than everything else, on PipeWire too. Ports now
open at the device's channel count when it has fewer, and the runtime folds
the 8 channels down itself with the fronts at unity (stereo, 2.1, quad, 5.1,
6.1). BB_AUDIO_GAIN (0-8) scales the whole output. BB_AUDIO_DUMP names the
channels it writes, the device's after a fold.

From Supermedo/bloodborne_pc#18 (d41e3da) and Supermedo's ff0e421.
2026-10-13 00:48:43 +01:00
Jgamrot 542e9ada19 Menu: checkboxes and sliders keep their changes with any compiler
Store(value, v, ImGui::Checkbox(label, &v)) read v before the checkbox had
changed it on compilers that evaluate arguments left to right: C++ leaves the
order unspecified. GCC happens to call the widget first, so our builds worked;
clang lost every change. The widget now runs before Store.

From Supermedo/bloodborne_pc#19 (5c660f9).
2026-10-13 00:48:26 +01:00
ayozetr 9e629d2efd Launcher: one shadNet account for each kind of server
shadPS4's public server and a server of your own have their own accounts, but
the Online page kept a single online ID and password, so switching servers
sent one server's account to the other. The page now keeps one account for
each (online_accounts) and swaps them when the server changes. Settings from
before give their account to the server chosen then.
2026-10-12 17:43:38 +01:00
ayozetr 74584d57b5 Trophies: the platinum no longer waits for the add-on's trophies
On the PS4 the platinum needs the base game's trophies only; ours waited for
every other one, so without The Old Hunters' six (34-39) it never came. The
trophy list says which ones count: their pid names the platinum, and an
add-on's is -1. scripts/trophies.py keeps that as a sixth column of
trophies.tsv, and the runtime awards the platinum once those are earned. A
list without the column, from an older run, still needs every other trophy.
2026-10-12 13:05:43 +01:00
ayozetrandQiming-Liu 40b67fc784 Translations: native wording for the Simplified Chinese launcher
Takes Qiming-Liu's wording from deadinside28/bloodborne_pc#157 for the
21 strings where ours differed: 联机 and 账号 for online play and the
account, 「」 quotes, 检测 for the connection check, 自定义 for a custom
file, and a plain "{} / {}" for the DLL pass counter.

Co-authored-by: Qiming-Liu <[email protected]>
2026-10-12 13:01:22 +01:00
ayozetr 82923859db Launcher: swap A and B for Nintendo-style controllers
A switch under Controls binds cross to the gamepad's east button and
circle to the south one (pad.cross=b, pad.circle=a in bbport.ini), and
the Xbox and Switch prompts swap their cross and circle glyphs to match.

Closes #3
2026-10-12 10:20:59 +01:00
ayozetr 2ea5b61641 Issue templates: a bug report form, a feature request, and links to the guides
The bug report asks for the version, the system, the graphics settings, the online mode and the exported log, and that the dump is the reporter's own. Blank issues are off; the troubleshooting and online guides are linked instead.
2026-10-11 23:52:14 +01:00
ayozetr 0ca95492c4 In-game menu in Simplified Chinese
The overlay menu (Insert / L3+R3) and the trophy notice get Simplified
Chinese, the launcher's eighth language: a column in
bbport_menu_i18n.h, zh_CN in bbport.ini and BB_MENU_LANGUAGE, and
简体中文 in the menu's language list.

The menu's font has no Chinese characters. The first time the menu
opens or speaks Chinese, a system font is merged into it: BB_CJK_FONT,
else fontconfig's choice for zh-cn (Noto Sans CJK, WenQuanYi), checked
to cover zh-cn since fontconfig answers with some font regardless.
ImGui 1.92 rasterizes glyphs as they are drawn, so only the file is
loaded, not a texture of every character. Without such a font the log
says what to install.

The game's own System menu pages are drawn with the game's font, which
has no Chinese characters in the western releases: they stay in English
for Chinese.
2026-10-11 18:52:39 +01:00
ayozetr 4fd797f7e9 README: the launcher is in Simplified Chinese too 2026-10-11 18:32:18 +01:00
ayozetr 1ebe35c2e8 Translations: Simplified Chinese launcher complete, and the game menu's missing texts
The launcher's Simplified Chinese came from upstream and stopped with
the texts it had; the 87 added since (Online, Trophies, Discord, Steam,
DLC, the log export, Ko-fi) are translated now, and the test keeps it
complete like the other languages.

Nine texts of the in-game menu (Insert / L3+R3) showed in English in
Spanish, Italian, French, German and Portuguese: the DLSS, FSR 4.1.1
and FSR 4 hints, the free camera and debug menu hints, and the quit
buttons. Four of them were translated in an older wording that still
said FSR where the menu now says upscaler.

test_menu_i18n.py checks that every text of the overlay and the game
menu has a row in bbport_menu_i18n.h, with every language and the same
printf fields.
2026-10-11 18:31:06 +01:00
devilismyfriend 17e7741934 LRU cache: the early stop of ForEachItemBelow works
It compared std::invoke_result<...> (the trait, not its type) with bool,
so a callback returning true never stopped the walk: every texture cache
collection visited every old image, past its deletions and its 256-image
cap.

From yumlevi's "Texture cache: images it must keep no longer block
evictions" (d7a53a5); its other half, kept images not using up the
deletions, was already here.
2026-10-11 18:14:05 +01:00
ayozetr 97a60dd497 0.51-linux.2, and co-op on your own server tested with two players
The launcher's version for the About page's updates. docs/SERVER.md and the README say that a
private server has been tested with a Linux and a Windows player: summons, a boss fight together
and summoning again after the helper died.
2026-10-11 16:51:45 +01:00
0xCydral a3a8a3af6e fix(vulkan): include vulkan.hpp with NDEBUG so the dispatcher layout matches
vulkan.hpp adds two fields to DispatchLoaderBase when NDEBUG is undefined, which moves every
function pointer in vk::detail::DispatchLoaderDynamic by 16 bytes. libbbgpu is built with NDEBUG
while the renderer tests build with -UNDEBUG so their asserts run, so the two sides disagreed on
the layout. The linker keeps one inline copy of DispatchLoaderDynamic::init(Instance): the test
translation unit's copy read vkGetInstanceProcAddr at offset 88 while CreateInstance had stored it
at offset 72, and Instance construction called a null pointer (exit 139 on Windows).

vk_common.h now defines NDEBUG around the vulkan.hpp include and restores assert() afterwards,
so every translation unit sees the same layout while test asserts stay active.

(cherry picked from commit 03b383b34ccb74085d74baa1e3b87ca0b50013aa)
2026-10-11 16:50:59 +01:00
0xCydral f1091cc0b1 fix(gpu): keep the camera vector where an object vector strays far from it
Object motion history that was not the object's gave still geometry huge
vectors. At one moment of the Hunter's Dream test route, bushes behind the
workshop moved 100-650 px a frame while the camera vectors around them were
right, and FSR and frame generation smeared them. The motion merge now keeps
the camera vector when the object vector differs from it by more than 5% of
the render width (at least 32 px; 85 px at 1706x960).

Measured on 24 frames at the same moment: object vectors over 100 px went
from 2.8% of the picture on the worst frames of two runs to none. The camera
motion test gains two cases, a far object vector that is rejected and a 30 px
one that is kept; that test does not run on Windows yet.

(cherry picked from commit a12911460872b37aba2bff4895903ad20aabf1e2)
2026-10-11 16:50:59 +01:00
0xCydral c496667284 feat(log): a performance line every 30 seconds in normal play
Players' last_run.log carried no frame rate, so a report of low FPS or stutter came with nothing
to measure. Without BB_FRAME_STATS the game now prints one line per 30 seconds: game frames per
second, the 1% low, the worst frame and the shader/pipeline compiles of the window, for example
"Perf: 68.0 FPS over 30 s, 1% low 62.8 FPS, worst frame 22.1 ms, 0 shader/pipeline compiles".

(cherry picked from commit 6c73bf375ea55972fd19bb5dcc254a92ab57ee54)
2026-10-11 16:50:59 +01:00
0xCydral a8b7a8eaa1 fix(gpu): keep the default indirect guard limit when BB_INDIRECT_GUARD_MAX is not a positive number
An unparsable value gave strtoul 0, so the shader skipped every indirect dispatch without a word. A value that is not a number from 1 to 4294967295 now keeps the default and prints one warning line.

(cherry picked from commit 62177377cf6c4a390d95d61b804505b077e7648c)
2026-10-11 16:50:59 +01:00
0xCydral 4d934b7529 fix(run.sh): give an explicit render size above 1080p the larger direct memory
run.sh raised BB_DMEM_MB to 9152 MiB only on the scaled path. An explicit BB_RENDER_RES skips that path, but patches.py still grows the graphics heap above 1080p, so the game asked for more memory than it had and stopped at start (exit 139). run.sh now does what run.py does since e5276cf: a WxH size above 1920x1080 by pixel count gets 9152 MiB unless BB_DMEM_MB is already set.

(cherry picked from commit 674b72e650f990dce906bc43446d024ab7109520)
2026-10-11 16:50:59 +01:00
0xCydral 442c2b66bd fix(gpu): resync the graphics command decoder at an invalid PM4 header
A cut-short SET_SH_REG leaves the next packets inside its body, and the
decoder then hit UNREACHABLE (Unimplemented PM4 type 0) at area loads. On
that path only, decoding now resumes at the next dword that can start a
type 3 packet, so the rest of the buffer and its fences still run. Each
resync logs the header dword and its offset; the buffer report stays
limited to the first 8.

From GoncaloLobo0/bloodborne_pc_win a334d26 and 9dc909d (adapted: crash path
only, no look-ahead on valid register packets)

(cherry picked from commit e0b464db61362d5c8b966ec645c592cf0a680667)
2026-10-11 16:50:59 +01:00
0xCydral d7a42fde94 fix(motion): object vectors for 16-64 KiB bone palettes; unknown SnormNz format is not fatal
Palettes of whole bones between 16 and 64 KiB (24576, 49152 bytes: the
large beasts in Central Yharnam) are now LargeSkeleton: gated like small
skeletons (history only while the palette changes). BB_MOTION_LARGE=0
restores the old rule (camera vectors only).

MapNumberConversion returns NumberConversion::None for a data format with
no SnormNz mapping instead of hitting UNREACHABLE (seen with data format 45,
a stale or unused descriptor).

From bmy/bbport-mac 80898cf and 5cf5684 (gpu part only)

(cherry picked from commit 4ff6b6292df844129332d75c6a1eb56eda14232b)
2026-10-11 16:50:59 +01:00
0xCydral d0318b1ab5 fix(upscaler): UI mask draws write the UI's output-size stencil
Scaleform masks are drawn with color writes off, so they reached the UI
composition without a color target and stayed in the game-size depth. The
next UI draw switched to the output-size UI depth, whose stencil
PrepareUiDepth clears, and the mask was gone: images cut out through a mask
(the loading screen's item picture) vanished at output sizes other than
1080p.

The frame generation HUD-less capture is unaffected: it runs in the
!Scaled() path before ui_phase, and the new branch only applies in ui_phase.

From bmy/bbport-mac e4f7d33

(cherry picked from commit 55b4a0d7247ec05dafc0d40643a2a146869c9531)
2026-10-11 16:50:59 +01:00
0xCydral 1bc4f37504 fix(texture-cache): hash the same guest range at marking and refresh
MarkAsMaybeDirty hashed the whole image but RefreshImage only its first 8x8
pixels, so the two never matched and stale guest memory replaced
GPU-written small images (black character preview, missing loading-screen
item picture). Both now use MaybeDirtyHash, and every upload records its
mip hash so an unchanged guest copy is not re-uploaded over GPU contents.

From yumlevi/bloodborne_pc 43fcf95

(cherry picked from commit 0d2dfd2f4ba3a13d5bee415769f5f0ff4f58fb2e)
2026-10-11 16:50:59 +01:00
0xCydral 44a6b7c3ce fix(shader): ReadUdReg on a null user-data table returns an empty sharp
An unbound descriptor table dereferenced a near-null pointer (access
violation at 0x8 on map load). Return a zeroed value so the existing
empty-sharp handling runs.

From Mrsuss60/bloodborne_pc_windows_port 8d8ac28 (info.h hunk only)

(cherry picked from commit 5e4513bad8b952bea95f6df76d94c97d5e827de8)
2026-10-11 16:50:59 +01:00
0xCydral efdf7b176e fix(runtime): split flexible mappings keep their own pool offset
split_at moved the offset only for direct memory, so the right piece of a
split flexible mapping kept the left piece's. Unmapping it then freed and
zeroed the left piece's pages (still mapped) and leaked its own; read/write
backing used the wrong pages too.

From bmy/bbport-mac 9ffc78d

(cherry picked from commit 389a1e5c3573460f73f96f255f5fbf7da43c981c)
2026-10-11 16:50:59 +01:00
ayozetr 4f7df3de5c Trophies: a notice in the game and a Trophies page
The game's trophies come from the player's own dump: scripts/trophies.py reads
sce_sys/trophy/trophy00.trp, keeps its icons, and decrypts the trophy lists with the PS4's
trophy key (shadPS4's TrophyKey, entered in the launcher), in the game's language. Without the
key, only the icons. AES is in Python, as the AppImage has no crypto package.

When the game unlocks a trophy, the runtime records it in earned.tsv, the first time only, and
the overlay shows a card in the top-left corner in the style of the PS4's: the trophy's icon,
"You have earned a trophy." and its name. The platinum follows once every other trophy is
earned. The launcher's Trophies page lists them all, earned first, with the date; hidden ones
stay hidden until earned. BB_TROPHY_PREVIEW=<id> shows a trophy's notice at the game's first
trophy call, to see how it looks.
2026-10-11 15:35:42 +01:00
ayozetr e3c51c48aa Discord Rich Presence: the area and the co-op partners
Discord shows the area the player is in, and under it "Co-op with <partners>", "Online" or
"Offline". The game writes both into BB_PRESENCE_DIR, which run.sh makes new for each start:

- The runtime takes the area from the MapStudio files the game opens, one per map it loads.
  The player's own map comes first, then the maps around it (Central Yharnam brings the
  Forbidden Woods), so a map opened 5 s or more after the previous one starts a new load and
  names the area. It changes at loading screens only.
- The online module writes the online IDs of the players it has an established connection
  with, when a connection is established or removed.

The area names are those of Smithbox's Bloodborne map aliases (MIT); the Chalice Dungeons are
one name.
2026-10-11 15:11:09 +01:00
ayozetr 33393185b9 Support on Ko-fi: the About page, the README and GitHub's Sponsor button
A "Support this project" row in the launcher's About page opens ko-fi.com/ayozetr, the
README has a Support section, and .github/FUNDING.yml shows the link in GitHub's Sponsor
button.
2026-10-11 14:51:58 +01:00
ayozetr c294942db4 Launcher: the game folder names the release that is in it
The row said "Game folder (CUSA03173)" and the check's tooltip "Bloodborne CUSA03173,
version 1.09" whatever the folder held, from when only the European Game of the Year edition
ran. Both now name the title ID in the folder's param.sfo (CUSA00900, ...), or none without a
game.
2026-10-11 14:33:28 +01:00
obrikash 6c6974a742 Fix Vulkan startup when optional DLSS device extensions fail 2026-10-11 13:34:39 +01:00
ayozetr 83246710d7 0.51-linux.1, and what each tool is for
The launcher's version for the About page's updates. tools/README.md tells the tools the port,
the launcher and the AppImage use from bbport's development tools, kept as they are so that
its updates merge cleanly.
2026-10-11 02:53:20 +01:00
ayozetr b71eb7cfee Online: your own co-op server
shadPS4's public server never answers on its UDP port, so summons through it cannot connect
(deadinside28/bloodborne_pc#121). The Online page now chooses between that server and your
own, a shadnet-p2p server for a group of friends: its address and WebAPI, and seamless co-op,
which the server has to have on as well. The co-op port is a setting of its own instead of
SHADPS4_P2P_PORT in the extra variables, and the firewall warning uses it and says that UPnP
opens the port on the router only.

Test the connection pings the shadNet server's UDP port the way the game does and shows the
public address the server sees, or that it does not answer. It uses a made-up online ID: the
server keeps one address per account, and the player's own would replace the address of a
game running at the same time.

tools/shadnet-server builds shadnet-p2p in Docker on Debian 13, for the Qt 6.8 it needs
(Ubuntu 24.04 has 6.4), and keeps the configuration and accounts in a volume. With the
server's Tailscale address, it waits for the address before starting: shadnet-p2p keeps
running without its listeners when the address is missing.

Checked against shadnet-p2p 5a849a1: login, the UDP ping, matchmaking and a summon sign
reaching its summon service. docs/SERVER.md is the guide; ONLINE.md and TROUBLESHOOTING.md
cover online play and the common problems.
2026-10-11 01:41:53 +01:00
ayozetr b6c25e1fb4 Merge upstream 0.51 (master)
The online module from upstream replaces this fork's copy: the same shadp2p libraries, now
in gpu/bbnet as libbbnet.so, loaded only for online play, and UPnP opens the co-op port on
the router. Kept from this fork: game region, firewall warning, About page and updates,
cheats, Discord, title-ID mod folders and the sharun AppImage, which now bundles
libbbnet.so (BB_NET_MODULE). DLSS: the launcher's choice of NVIDIA's library looks in
BB_DLSS_DIR, the AppImage's copy. The launcher's new texts are translated.
2026-10-11 00:28:06 +01:00
ayozetr 0e2b626358 Mods and patches: every retail release, and a guide in English
A mod wrapped in a title ID folder was found only as CUSA03173/dvdroot_ps4; one made with the
US release (CUSA00900/dvdroot_ps4) was not. Any title ID folder is accepted now. Third-party
patch files listed six of the nine retail releases: a file for the Japanese release or the US
and Japanese The Old Hunters editions was skipped. The list is now the game check's.

docs/MODS.en.md is the guide to mods and third-party patches in English, based on bbport's
docs/MODS.md and checked against the code; the README and docs/README.md link to it, and the
Russian original moves to the development notes.
2026-10-10 22:33:31 +01:00
ayozetr b060b9fa2b Document which game releases run, and which have been checked
docs/COMPATIBILITY.md lists the nine retail releases the launcher accepts, with their title and
content IDs (from deadinside28/bloodborne_pc#117), and which ones have had their 1.09
executable checked: the US release (CUSA00900) and the European Game of the Year edition
(CUSA03173). The others are accepted on the expectation that they share that executable, and
the image hash still decides. It also lists what was tested with this fork. The README's note
on game files links to it.

game_check.py said the Asian The Old Hunters Edition (CUSA03023) shares the executable; nobody
has checked it, so it now says what is known.

docs/README.md is an index of docs/: what is for players, what is this fork's, and bbport's
development notes by topic, which stay as they are so that upstream updates merge cleanly.
2026-10-10 22:26:45 +01:00
ayozetr 18a7dd7e39 AppImage: only what the port needs, and DLSS's files intact
quick-sharun's OpenGL and Vulkan deployment copies every libEGL*/libGLX* library and every ICD
manifest of the build host. Built on a PC with NVIDIA's driver, the AppImage carried that
driver's user-space libraries (one exact version, 615.71.09 in 0.4-linux.1) and manifests,
which load next to the player's own driver and cannot match another kernel module. They are
now removed before the AppImage is made; NVIDIA's driver always comes from the host, where
launcher/bbport_vulkan.py points Vulkan.

quick-sharun also strips and patches every library in the AppDir, DLSS's among them. NVIDIA's
DLSS library is signed: changed, NGX refused it (0xbad00012) and DLSS was unavailable in the
AppImage on an RTX 3060 that runs it from the repository. The DLSS files are now copied after
quick-sharun, as built.

DEPLOY_PYTHON copied the build host's whole site-packages (scipy, Qt bindings, matplotlib, ...:
431 MB). Only PyGObject, pycairo and Pillow stay, which is all the launcher imports. The
AppImage goes from 342 MB to 225 MB.
2026-10-10 21:38:02 +01:00
ayozetr 28a9233034 Launcher: About page, and updates from this fork's releases
A new About page shows the version and links this project, the original bbport by
deadinside28 and Supermedo's Windows port, and names the license (GNU GPL v2 or later, no
game files included).

It also checks GitHub for the latest release: at start, unless turned off, and with a Check
now button; a newer release at start is announced with a toast. From the AppImage, Update
downloads the release's AppImage next to the running one and renames it over it (a failed
download leaves the old one as it was); the running copy keeps its mount, and Restart starts
the new one once this launcher has exited (with the same application ID still held, the new
one would only raise the old window). From a git checkout, or an AppImage in a folder that is
not writable, the button opens the release page instead.

The version lives in launcher/bbport_update.py (0.5-linux.1); tags follow
<upstream version>-linux.<n>, and a release carries the AppImage as an asset.
2026-10-10 21:38:02 +01:00
ayozetr 9c056c437f Launcher: warn when a firewall blocks co-op
Co-op peers connect straight to each other over UDP, and UPnP's discovery replies come from the
router on random source ports. With ufw enabled or firewalld running, both are dropped, and
nothing in the launcher said why co-op could not connect.

The Online page now names the firewall and gives the commands: pin the P2P port with
SHADPS4_P2P_PORT=3658, open it, and with ufw let the router's answers in (the default gateway
read from /proc/net/route). Both are detected without root.
2026-10-10 21:38:02 +01:00
ayozetr 2683273a17 Launcher: game region
Bloodborne picks a language's regional text from USER_DEFINED_PARAM_1 in param.sfo: 13 Europe
(Spanish from Spain), 2 the Americas (Latin American Spanish). A US dump played in Spanish showed
the Latin American text, and the only way to change it was editing the dump's param.sfo.

The launcher's Game region (As in the game dump, Europe, the Americas) sets BB_REGION, and
content_profile.py writes the matching parameter for the launch; the dump stays as it is.
2026-10-10 21:38:02 +01:00
ayozetr 4d297b96bb Translate 0.5's new texts, and the game's System menu pages
The launcher's new texts (memory model, log export, monitors, the offline patch, damaged game
files) in Spanish, Italian, French, German and Brazilian Portuguese; Portuguese also gets the
52 texts it never had (controls, game check, frame limit, memory options, diagnostics).

The Display and Effects pages in the game's System menu were English or Russian only. They now
follow the game's language (BB_LANGUAGE, else the desktop's locale, as the runtime tells the
game), with the texts in bbport_menu_i18n.h. The README lists 0.5's new features.

tests/test_i18n.py now checks that every text the launcher translates exists in English,
Spanish, Italian, French, German and Portuguese, and that translations keep the placeholders:
the 18 texts 0.5 added had slipped through unnoticed.
2026-10-10 21:38:02 +01:00
ayozetr 917c4bdd20 Record large copy lists in slices
Loading an area on NVIDIA with 0.5, a download of the buffer cache carried 178563 buffer copies
(4.3 MB) into one deferred command. A command's data shares a 128 KiB recording chunk with it,
so ReserveRecordData's assertion stopped the game. Buffer copies, guest chunk copies and
buffer-to-image uploads are now recorded as one command per 64 KiB of regions.
2026-10-10 21:38:02 +01:00
ayozetr 8e24cfdcf5 Merge main: the region fix and every retail release
main corrected the regions (CUSA00900 is the US release, CUSA03173 the EU GOTY) and accepts
every retail release; the 0.5 merge still had upstream's older texts. Conflicts take main's
side; the Chinese launcher text of the old message is replaced by the new one.
2026-10-10 21:38:02 +01:00
ayozetr cfede387ed Merge upstream 0.5 (master)
0.5 on top of 0.5-pre4: the memory model chosen by GPU (the new one on AMD, the 0.3 one
elsewhere, or either by hand in the launcher), the command decoder, game profiles, GPU
timestamps, less VRAM churn, the finished Display and Effects pages of the game's System menu,
NVIDIA EGL discovery and RADV nohiz for older AMD GPUs.

Conflicts: README.md keeps this fork's own; tools/gpu_capabilities.c's header now lists both
the gamepad type (button prompts) and upstream's new --device.
2026-10-10 17:37:45 +01:00
ayozetr f44390da19 Game check: every retail release of Bloodborne
The list of content IDs posted by h0kd33 in deadinside28/bloodborne_pc
PR #117 covers every Bloodborne release: the US, EU, UK, Japanese and
Asian ones and the four editions with The Old Hunters. A base game of
any of them without the update is now asked for 1.09 instead of being
called another edition; whether the executable runs is still decided by
its image hash. The message is reworded in every launcher language.
2026-10-10 16:02:32 +01:00
ayozetr c89f4d432c Fix the regions: CUSA00900 is the US release, CUSA03173 the EU GOTY
CUSA00900 was called the EU release and CUSA03173 the US one, the other
way round: their content IDs are UP9000-CUSA00900 (America) and
EP9000-CUSA03173_00-BLOODBORNE0000EU (Europe, Game of the Year edition).
Only names change: the README, BBPORT.md, the release template, the
game check's texts and the launcher's message in every language.
2026-10-10 15:21:24 +01:00
ayozetr 4fd1cd6ee6 Restore what the 0.5 merge dropped: menu languages, cheats, co-op setup
Resolving the merge, three files of the in-game menu were taken whole
from upstream to settle conflicts that were only about DLSS, and
probe.c to drop the old add-on profile. That also dropped this fork's
own changes in them: the menu's Language selector and its Spanish,
Italian, French, German and Portuguese texts, the cheats and gameplay
tweaks, and the bbnet_configure call that gives the co-op code the
game's identity. With online play on, the game stopped at start
(ElfInfo GameSerial assertion). Now merged line by line, with upstream's
version only in the DLSS hunks.
2026-10-10 15:12:23 +01:00
ayozetr cfe7af67a5 Add to Steam: Flatpak Steam
Flathub's Steam keeps its data in ~/.var/app/com.valvesoftware.Steam
and starts non-Steam games inside its sandbox, which has neither the
game folder nor the host's drivers and cannot mount an AppImage. The
launcher now finds it, closes and starts it through flatpak run, and
writes a shortcut that runs the game on the host with
flatpak-spawn --host. That needs Steam to talk to org.freedesktop.Flatpak:
the launcher explains what the permission allows and grants it
(flatpak override --user) only when the player agrees.

A flatpak-spawn shortcut is only replaced when it is this port's (same
name, runs --play), so other launchers' games in Steam are left alone.
2026-10-10 14:10:32 +01:00
ayozetr 124384eac1 Merge upstream 0.5-pre4 (testing)
bbport 0.5-pre1 to pre4: the memory module (now on NVIDIA too), native
GPU copies, background pipelines, settings in the game's System menu,
DLSS, DLC folders and the damaged-extraction check.

Where both sides did the same thing, upstream's version is kept:
- DLSS: upstream's bridge and loader (libraries next to bb-probe, built
  by build.sh with DLSS_SDK_ROOT). BB_DLSS_DIR still overrides the
  folder, for the AppImage; tools/build_dlss.sh now installs into out/.
- DLC: upstream's <user>/addcont/<title>/<label> reporting. The
  launcher's DLC folder (BB_DLC_DIR) is linked there by run.sh, a real
  folder in its place left alone; our content.bin add-on list is gone.
- The memory-model switch is no longer AMD-only, as upstream has it.

Kept from this fork: launcher and in-game menu languages (Simplified
Chinese joins them), the game check for the EU and Asian releases and
the 60 fps-patched eboot (as sent upstream in PR #117), button prompts
and the controller type in bb-gpu-capabilities --gamepads, which also
lists displays now. Upstream's README changes went into BBPORT.md.
2026-10-10 14:07:39 +01:00
ayozetr 5227432b1b Git: keep game files and the mods folder out, quiet FSR-Vulkan
The mods folder of runs from source (mods/) holds the user's modded game
files and was not ignored; neither were dumps and their parts
(eboot.bin, param.sfo, *.pkg, *.dcx, *.7z, CUSA* folders). The repository
never takes any of them.

FSR-Vulkan always showed as modified because build.sh applies
gpu/patches/fsr-vulkan to its working tree: it is now ignore = dirty.
2026-10-10 03:49:42 +01:00
ayozetr 81db68f552 Docs: release notes template, credit for the keyboard prompts
docs/RELEASE_TEMPLATE.md fixes the format of every release: tag and
title, requirements, download with its SHA-256, New / Improved / Fixed,
Upgrading, Known issues, and links to the changes and credits.

The credits now name Abken's KBM Icons Generator (Nexus, Bloodborne mod
537), which showed which textures keyboard prompts need; bbport draws
its own and uses nothing of it.
2026-10-10 03:48:43 +01:00
ayozetr f723f6a972 Button prompts: Xbox, Switch and keyboard, and a cleaner gestures menu
Controls get a Button prompts row (automatic, PlayStation, Xbox,
Nintendo Switch, keyboard) and a switch for the gestures menu without
the motion-control icons. Automatic follows the controller in use: bb-
gpu-capabilities --gamepads now also prints SDL's controller type, and
with no controller the keyboard's prompts are used.

The Xbox and Switch prompts and the gestures menu are Dommo's (Nexus,
Bloodborne mod 30, whose permissions allow reuse with credit). Only what
the mods drew is in assets/menu_prompts: the 20 icons as their BC7
blocks, the strip of big prompts from the UI atlas, and the edits to
gesturetop.gfx; tools/extract_prompt_assets.py rebuilds them from the
mods' ZIPs. Keyboard prompts are drawn with Pillow for the keys bound in
bbport.ini.

At start, scripts/menu_prompts.py puts them into the player's own
menu/common.tpf.dcx (scripts/menu_textures.py: DCX/TPF, PS4 tiling, a
small BC7 encoder) without touching any other texture, caches the
result, and mods.py lays it under the user's mods (--ui-mods). The
AppImage and the Nix package now carry Pillow and DejaVu Sans Bold. The
README also says the in-game menu speaks Portuguese, since c428122.
2026-10-10 03:38:39 +01:00
ayozetr 82a96e0d25 Support the EU release (CUSA00900)
The EU 1.09 executable is the same image as the US one: with a clean
eboot.bin the EU dump passes the check and plays, gestures menu
included. The check now takes CUSA00900 as a supported title, so an EU
base game without the update is told to merge 1.09 instead of being
called another edition. README and BBPORT.md name both releases; the
latter also notes that run.sh follows the desktop's locale without
BB_LANGUAGE (left out of the previous commit).
2026-10-10 02:39:04 +01:00
ayozetr 71717863d5 Runtime: game language follows the desktop without the launcher too
The launcher passes BB_LANGUAGE, but run.sh started directly (scripts,
the terminal) left the game in English. Without BB_LANGUAGE the runtime
now maps the desktop's locale the same way the launcher does: Japanese,
English, French, Spanish, German, Italian or Russian, English otherwise.
2026-10-10 02:37:02 +01:00
ayozetr c4281227da In-game menu: Brazilian Portuguese
The launcher already speaks pt_BR, but the overlay fell back to English
for it. Adds Português (Brasil) to the menu's Language selector and a
pt_BR column to the overlay translations; the launcher's pt_BR (and any
pt_* locale) now reaches the menu through BB_MENU_LANGUAGE.
2026-10-10 02:35:32 +01:00
ayozetr ee20c8b147 Game check: recognise the eboot with the 60 fps patch baked in
Some dumps (seen on EU CUSA00900) ship the 1.09 eboot.bin with Lance
McDonald's 60 fps patch already applied. Apart from that patch it is the
supported executable, but one of its edits inverts a branch in the menu
code (+0xf6d90d) and the game crashes as soon as the gestures menu opens.

The check now names this image and asks for a clean 1.09 eboot.bin,
instead of the misleading "other edition" message; bbport has its own
60 fps. Launcher message in en/es/it/fr/de.
2026-10-10 02:27:24 +01:00
ayozetr a39a0073de Launcher: game language follows the desktop by default
The game takes its text and voices from the PS4 system language, which
the launcher set to English unless changed. System language now starts
on "System": the desktop's locale picks Japanese, English, French,
Spanish, German, Italian or Russian, and English for anything else.
A language chosen in the launcher still wins.
2026-10-09 23:38:24 +01:00
ayozetr 09a1e4981a DLC folder: report dumped add-ons to the game
The base game (not the GOTY edition) learns which add-ons were bought
from sceAppContentGetAddcontInfoList, which always answered none. Their
data ships with the 1.09 update; a dumped add-on is just its sce_sys.

- content_profile.py --dlc DIR lists the add-on folders of this title
  (CATEGORY ac, CONTENT_ID of the same title id) into content.bin, now
  BBCONT02 with the entitlement labels when there are any.
- The runtime returns them as SceAppContentAddcontInfo entries (status
  "no extra data" for key-only folders, "installed" otherwise).
- run.sh passes BB_DLC_DIR; the launcher has a DLC folder row showing
  how many add-ons it found, translated in every launcher language.
2026-10-09 23:19:51 +01:00
ayozetr 992fc95ed1 New README for the fork
What the fork adds and how to play, build, play online and enable DLSS, with
credits to bbport (deadinside28) and the Windows port (Supermedo). The original
README moves to BBPORT.md (and its Russian version to BBPORT.ru.md) for its
technical details.
2026-10-09 21:06:24 +01:00
ayozetr 851712537b Discord Rich Presence
"Playing Bloodborne" on the player's Discord profile while the game runs, online
or offline, with the time played and the icon. scripts/discord_presence.py talks
to Discord's local IPC socket (also the Flatpak, Snap and Vesktop ones), keeps
looking for Discord when it is not running and ends with the game; run.sh starts
it unless BB_DISCORD=0, and the launcher has a switch for it.
launcher/bloodborne.svg: the icon redrawn as vector art, for the 1024 px assets
Discord needs.
2026-10-09 18:58:48 +01:00
ayozetr e289b722b5 DLSS Super Resolution for NVIDIA RTX on Linux
From the Windows port of Supermedo/bloodborne_pc (commit 9514b19; the bridge is
adapted from IFreemz/shadPS4-Bloodborne-DLSS-FSR): a new upscaler "dlss" that
gets the inputs FSR 3 gets, falls back to FSR 3.1 when DLSS fails, and changes
nothing without the DLSS files, on other GPUs or with BB_DLSS=0.

Linux: the bridge builds as libbbport_dlss.so against the SDK's libnvsdk_ngx.a
(only BbDlssGetApi exported) and is loaded with dlopen from BB_DLSS_DIR or the GPU
library's directory, next to NVIDIA's libnvidia-ngx-dlss.so.<version>.
tools/build_dlss.sh fetches the SDK files (v310.9.1, cached) and puts the bridge,
the library and its license into out/gpu; the AppImage ships them when present.
The launcher lists DLSS with a note on its files and the RTX requirement. Merged
with 0.4: FSR 4 keeps its reactive blend, Scheduler::WaitSubmittedWork.
2026-10-09 18:42:42 +01:00
ayozetr c7cae2a585 Launcher: online account checks before playing
As in the Windows launcher: with an account filled in and Play online off, ask
whether to play online or offline; an email address as the Online ID is refused
with a note (the NPID is the account name) and the Online page is shown.
2026-10-09 18:35:30 +01:00
ayozetr 7d53c5e9d6 AppImage: bundle Python's ssl and hashlib modules
quick-sharun leaves them out by default; the launcher's connection test and
scripts/online.py talk HTTPS to the co-op servers.
2026-10-09 18:32:08 +01:00
ayozetr 7dfbf49367 Online co-op on Linux
Online play through shadNet and The Hunter's Dream, from the Windows port of
Supermedo/bloodborne_pc (commit e076119), which built it from the network and PSN
libraries of shadp2p (github.com/Wozzardman/shadp2p): sign-in, matchmaking,
signaling, WebAPI and HTTPS. With BB_ONLINE=1 the game's network and PSN imports
go to these libraries instead of the offline stubs; trophies, voice and the store
dialogs stay in the runtime. It uses the same servers as the Windows version, so
players of both play together.

Linux changes: UPnP through the system's miniupnpc (the Windows port linked it
there only), no wepoll or Windows libraries; scripts/online.py replaces run.py's
host overrides (game server -> community server, summon signs -> the shadNet
WebAPI when it has the summon service) and is called by run.sh. Launcher: an
Online page (play online, game server, co-op server and WebAPI, shadNet account,
UPnP, connection test), translated; settings.json is now private (0600) since it
holds the shadNet password. tests/test_online.c checks the import routing.
2026-10-09 18:26:14 +01:00
ayozetr 52a6daf288 Add to Steam: replace this port's shortcut from another place
A shortcut named like ours with the same launch options (--play) is updated
rather than duplicated, so moving from a checkout to the AppImage keeps one
entry; other shortcuts with the same name (shadPS4) stay as they are.
2026-10-09 18:10:45 +01:00
ayozetr 912641c298 AppImage without Nix (packaging/appimage-sharun.sh)
Collects the build, the scripts, the GTK4 launcher and every library they load
(glibc, Python with PyGObject, GTK 4, libadwaita, SDL3, FFmpeg, Mesa's Vulkan
drivers) with quick-sharun from pkgforge-dev/Anylinux-AppImages, pinned and
checksummed. Downloads the FSR 4 assets when they are missing. Runs the launcher,
or the game alone with --play, from ~/.local/share/bbport.
2026-10-09 18:10:20 +01:00
ayozetr dca76e386f Add to Steam from the launcher
A non-Steam shortcut in every Steam account's shortcuts.vdf that runs the launcher
(or the AppImage) with --play. Steam is closed first when it runs and started again
afterwards. Under Steam, --play restores the system's LD_LIBRARY_PATH.
shortcuts.vdf handling and the icon come from the Windows launcher of
Supermedo/bloodborne_pc. bb-launcher.sh now passes its arguments through nix-shell,
and the desktop entry uses the icon.
2026-10-09 17:49:35 +01:00
ayozetr 0b465f8ee1 In-game menu in Spanish, Italian, French and German
The Language selector lists six languages; menu_language in bbport.ini and
BB_MENU_LANGUAGE take their codes (en, ru, es, it, fr, de), and the launcher's
language or the system locale picks one until the menu sets it. The translations
of the English texts are in gpu/shim/bbport_menu_i18n.h; missing ones stay English.
2026-10-09 17:45:56 +01:00
ayozetr 82782d8155 Launcher in Spanish, Italian, French and German
Every launcher string translated; the system locale picks them, and so does the
launcher language selector. Strings missing in a translation fall back to English.
2026-10-09 17:45:56 +01:00
ayozetr 89ea3deb70 Cheats and gameplay tweaks page in the launcher and in-game menu
Community patches for 1.09 already in patches/Bloodborne.xml (never die, stealth,
silent, Rally that never fades, enemy control, no Rally, camera distance, no camera
auto-rotation, easier running, ragdoll physics), switched from bbport.ini.
Ported from the Windows fork (Supermedo/bloodborne_pc).
2026-10-09 17:45:56 +01:00
1450 changed files with 272744 additions and 214313 deletions

No files matched your search

+1 -2
View File
@@ -1,3 +1,2 @@
# GitHub's language bar: this C++ table of game texts (its <?name?> placeholders) is not PHP.
gpu/bbnet20/bbsc/core/bbsc_messages.inc linguist-language=C++
gpu/bbnet20/bbsc/core/bbsc_translations.inc linguist-language=C++
bbsc/emulator/src/core/bbsc_messages.inc linguist-language=C++
+8 -6
View File
@@ -23,6 +23,8 @@ perf.data
/bloodborne_upscalers_*.md
mods.json
patches.json
# The mods folder of runs from source (the launcher's default, DATA_DIR/mods)
/mods/
# Local: game logs, messages and archives from users, internal plans
/logs/
/message*.txt
@@ -30,10 +32,6 @@ patches.json
/docs/ISSUE_ACTION_PLAN_*.md
# AMD FidelityFX DLLs (FSR 4.1.1 is built from the user's own): never in the repository
*.dll
# The mods folder of runs from source (the launcher's default, DATA_DIR/mods), and the list of the
# last run's mods
/mods/
/mods.last
# Game files and dumps: never in the repository
eboot.bin
param.sfo
@@ -41,7 +39,11 @@ param.sfo
*.dcx
*.7z
/CUSA*/
# The launcher's icon (shipped)
!launcher/bloodborne.png
# Hunter's Atlas's maps and their tools (launcher: Prepare maps)
navigation-map*/
atlas-tools/
# Hunter's Atlas's own art (shipped; the launcher converts it for the game)
!tools/hunters-atlas/art/*.png
# The players' guide, built by mkdocs (mkdocs.yml)
_site/
+170 -264
View File
@@ -1,23 +1,26 @@
THIS PROJECT IS NOT RELATED TO SHADPS4. ALL QUESTIONS RELATED TO THIS PROJECT SHOULD BE SENT TO THE DISCORD SERVER https://discord.gg/KYZRKk9CB, NOT TO THE SHADPS4 SERVER.
# bbport — a native Linux and Windows port of Bloodborne
# bbport — a native Linux port of Bloodborne
> This is the README of the original project (deadinside28/bloodborne_pc, version 0.7), kept for
> its technical details. The Linux fork's own README is [README.md](README.md).
> This is the README of the original project (deadinside28/bloodborne_pc), kept for its
> technical details. The fork's own README is [README.md](README.md).
bbport is the counterpart of Wine + DXVK for a single game: *Bloodborne* for PlayStation 4
(CUSA03173, game version 1.09) on an x86-64 PC with Linux or Windows 10/11 (since 0.7, see
[Windows](#windows)). The game's original executable runs directly on the PC:
(CUSA03173, game version 1.09) on an x86-64 Linux PC. The game's original executable runs
directly on the PC:
- **as in Wine**, the game's x86-64 code runs on the CPU directly, and a runtime written for
this one game replaces the PS4 system libraries;
- **as in DXVK**, the game's graphics are translated to Vulkan — by the port's own renderer and
shader translator (they started from [shadPS4](https://github.com/shadps4-emu/shadPS4)'s and were
rewritten), including temporal upscaling with AMD FSR 3.1, FSR 4, FSR 4.1.1 and NVIDIA DLSS,
and DLSS Frame Generation;
- memory and the GPU's work follow the PC model on every GPU: the new memory and translation
model is the only one (the default since 0.6; the old model of 0.3 was removed in 0.7).
- **as in DXVK**, the game's graphics are translated to Vulkan — by a renderer derived from
[shadPS4](https://github.com/shadps4-emu/shadPS4) and heavily extended for this game, including
temporal upscaling with AMD FSR 3.1, FSR 4 and FSR 4.1.1;
- memory and the GPU's work are moving to the PC model: the *New memory and translation model* is
used by default on AMD GPUs; other GPUs keep the old memory model, as in 0.3 (launcher →
*Advanced → Memory and translation model*).
One step remains to the full Wine + DXVK model: make the new mode the default on every GPU and
remove the old memory model (see below).
> **No game files are included.** You need your own dump of Bloodborne with update 1.09: any retail
> release (CUSA00900 in the US, CUSA03173 for the European Game of the Year edition, ...), as they
@@ -26,17 +29,20 @@ bbport is the counterpart of Wine + DXVK for a single game: *Bloodborne* for Pla
**Status: experimental, playable.** The game boots, loads saves and plays (the Hunter's Dream
and several areas of Yharnam were played with it) with sound, gamepad and saving.
A full play-through has not been verified. Tested thoroughly: AMD Radeon RX 7800 XT (Linux with
Mesa/RADV, and Windows 11), the Steam Deck; on Windows also two laptops with NVIDIA GTX 1050 and
GeForce 930MX; other NVIDIA and Intel GPUs by players.
A full play-through has not been verified, and only one machine (Linux, AMD Radeon RX 7800 XT,
Mesa/RADV) has been tested thoroughly.
## Highlights
- **Native execution.** The eboot is converted offline into a flat memory image; PS4 libc and
libSceFios2 are linked into it as native code. No CPU emulation and no per-instruction
translation: the game code runs at full speed.
- **One memory and translation model** (the model of 0.3 is gone):
- The game's memory lives
- **Three modes** (launcher → *Advanced → Memory and translation model*): *Auto* (the default:
new on AMD, old on others), *New* and *Old (as in 0.3)*.
- **Old (as in 0.3).** The old memory model: VRAM copies of the game's
memory, writes tracked through page protection. With every fix made since 0.3 (motion
vectors and the upscaler, flicker with DoF on, a damaged shader cache).
- **New memory and translation model.** The game's memory lives
in system RAM and the GPU reads it where it is, as a PC game's buffers; data it reads often
is kept in VRAM and given back when unused (textures after 20 s, buffers after 60 s). The
layer's memory module tracks CPU writes to mirrored blocks to keep those copies coherent;
@@ -44,37 +50,25 @@ GeForce 930MX; other NVIDIA and Intel GPUs by players.
is translated into Vulkan commands rather than emulated on the CPU: memory writes
(`WRITE_DATA`, DMA) are done by the GPU in command-stream order, and fences are written by the
GPU itself at the end of the pipeline, at the same point of the stream as the PS4's command
processor writes them. On every GPU the game's memory goes through the layer's memory module:
bound in place, VRAM copies as separate buffers, no sparse binding (NVIDIA's sparse binding
froze the GPU for seconds; on AMD the module is faster than the former sparse arena too);
`BB_LAYER_MEMORY=0` gives AMD's sparse arena. Tested thoroughly on an RX 7800 XT; on NVIDIA
by testers. A driver that fails the startup checks stops the game with a line beginning
`STOP: the GPU driver cannot use the game's memory in place`.
processor writes them. **It may crash**; hardware testing has been performed on an
RX 7800 XT, and performance depends on the scene and settings. On AMD the game's memory is kept
in a sparse buffer (the arena). **On NVIDIA, Intel and others** it goes through the layer's
memory module: the game's memory is bound in place and VRAM copies are separate buffers, with
no sparse rebinding. This avoids the sparse-binding path associated with reported freezes
on a GTX 1660 Ti. On NVIDIA the module runs on a tester's GTX 1660 Ti, its speed is still
being checked; not tested on Intel. Without the launcher: `BB_PC_MODEL=1`; to choose the memory
path: `BB_GUEST_MEMORY=host|dmabuf`; the memory module: `BB_LAYER_MEMORY=1/0`.
The graphics heaps of the game grow as it takes memory and give it back when freed.
[0.5-pre4 changes](https://github.com/deadinside28/bloodborne_pc/blob/master/docs/CHANGES_0.5-pre4.md): no stutter of the memory module (NVIDIA) while areas
stream in, shader compilation in the background, VRAM on 4–6 GB cards, damaged game extractions
reported, the settings menu as in 0.3. [0.5-pre3](https://github.com/deadinside28/bloodborne_pc/blob/master/docs/CHANGES_0.5-pre3.md): the memory module on
NVIDIA — VRAM copies, one VRAM budget with the texture cache, watched blocks. [0.5-pre2](https://github.com/deadinside28/bloodborne_pc/blob/master/docs/CHANGES_0.5-pre2.md): the memory
module, native GPU copy shader, portable GPU completion labels, and shader-cache migration.
[Changes in 0.7](https://github.com/deadinside28/bloodborne_pc/blob/master/docs/CHANGES_0.7.md), [0.6](https://github.com/deadinside28/bloodborne_pc/blob/master/docs/CHANGES_0.6.md), [0.6-pre1](https://github.com/deadinside28/bloodborne_pc/blob/master/docs/CHANGES_0.6-pre1.md),
[0.51](https://github.com/deadinside28/bloodborne_pc/blob/master/docs/CHANGES_0.51.md), [0.5](https://github.com/deadinside28/bloodborne_pc/blob/master/docs/CHANGES_0.5.md).
Unused textures are freed, so VRAM no longer grows with every area visited; *Texture quality*
(full, half, quarter, auto = half on cards with up to 3 GB) skips the top levels of big
textures on small cards.
- **Unlocked frame rate.** Hooks in the game's code make the simulation use the real frame time
(the community patches in `patches/Bloodborne.xml` remain for comparison, `BB_FRAME_HOOKS=0`);
~90 FPS at 4K with FSR 4 Balanced on an RX 7800 XT, ~150 FPS at 1440p with FSR 4 Quality.
Also 30/60/90 FPS modes; **F3** switches a 30 FPS limit on and off while playing (some
cinematics, e.g. Laurence's, are safest at 30).
- **Any aspect ratio, live.** 4:3, 16:10, 16:9, 21:9 and 32:9 with the HUD at the screen's edges,
through hooks (the game's code is not patched); cinematics are wide too.
- **Keyboard and mouse.** Mouse look turns the game's camera directly; a mouse pointer in the
game's menus (F1 releases the mouse); a layout close to Dark Souls III's (Space dodge, Left
Shift run, Shift+Space jump); movement keys ramp up like a stick; button prompts show the bound
key, or the buttons of the gamepad in use (Xbox and others), PlayStation icons only with a
PlayStation pad.
- **Online play and seamless co-op.** Two pools on shadPS4 0.20's network stack: **Seamless
Hunt** (default; bells, summons, messages, bloodstains and seamless co-op, BBSC, up to five
hunters; an account from seamlesshunt.com) and **The Hunter's Dream** (sign-in with Discord; no
BBSC).
Unused textures are freed in both modes, so VRAM no longer grows with every area visited.
- **Unlocked frame rate.** Community patches (`patches/Bloodborne.xml`) make the simulation
use the real frame time; ~90 FPS at 4K with FSR 4 Balanced on an RX 7800 XT, ~150 FPS at
1440p with FSR 4 Quality. Also 30/60/90 FPS modes.
- **Temporal upscaling built for this game.** Bloodborne has no velocity buffer, so bbport
computes motion vectors itself: camera motion from depth and the scene matrices, and object
motion (characters, cloth, weapons) from the vertex positions of the previous frame. The
@@ -89,18 +83,6 @@ GeForce 930MX; other NVIDIA and Intel GPUs by players.
9000; picked automatically). The assets are built on your machine from one DLL of your own
(4.1.x): the launcher's *FSR 4.1.1 from your own AMD DLL → Choose DLL…* button (2–5 minutes,
twice that on RDNA4; needs a recent Proton), or `tools/fsr4cap`.
- **DLSS Super Resolution and DLSS Frame Generation** (NVIDIA GeForce RTX) through an
optional bridge built against NVIDIA's SDK (see Building). Frame generation makes the frames
between two of the game's (2x on RTX 40, up to 4x on RTX 50) from the finished frame, the
frame before its UI, the scene depth, the motion vectors and the game's camera matrices;
*Overlay menu → Frame generation*, or the launcher.
- **FSR 4.1.2's models from the AMD driver** (INT8 on RDNA2/3 and the *Lite* model), taken
from the driver's `amdxcffx64.dll` with the upscaler DLL (*FSR 4 model* in the menus).
- **FSR 1** (spatial, no motion vectors) for weak GPUs, and **FSR 3.1 frame generation**
(experimental, every GPU; the port's own optical-flow and interpolation shaders).
- **Anti-aliasing** separate from the upscalers: *auto* (the game's AA only without an
upscaler), the game's, FXAA (Kyo's *Force FXAA2*), SMAA 1x (the port's own, before the UI), off;
sharpening (RCAS) also without an upscaler. **GT-VBAO** can replace the game's SSAO.
- Faster than AMD's own shaders on RDNA3: the final passes of FSR 4 and 4.1.1 were rewritten
to store through workgroup memory (3.5× and 2.3× faster, bit-exact); FSR 4 costs ~4 ms at
4K on an RX 7800 XT instead of ~6 ms.
@@ -108,37 +90,29 @@ GeForce 930MX; other NVIDIA and Intel GPUs by players.
and draws are bound and recorded on another (two-stage pipeline), with a Vulkan recording
thread and helper threads for memory copies. Early on the single GPU thread capped the game
at ~26 FPS; now it runs at 90–150 FPS depending on resolution and scene.
- **Settings inside the game's own menu:** *System → Display / Effects* (after *Screen/Sound*),
built and drawn by the game itself — output resolution, upscaler, preset, sharpness, FPS
counter, model detail and the game's effects, in the game's language. Chromatic aberration,
depth of field, motion blur, SSAO, the game's AA and dynamic-light shadows switch at once;
screen-space reflections, the intro, free camera and the debug menu need a restart.
- **Overlay menu** (Insert, or hold L3+R3), styled like the game's dialogs: everything above plus the
- **Settings inside the game's own menu:** *System → Display / Game effects / Game patches*
(after *Screen/Sound*), built and drawn by the game itself — output resolution, upscaler,
preset, sharpness, FPS counter, model detail and the effect patches, in the game's language.
- **Overlay menu** (Insert or L3+R3), styled like the game's dialogs: everything above plus the
advanced settings, with gamepad, keyboard and mouse.
- **Shaders before you play.** A pack of shader recipes from players' caches (655 programs, 1149
pipelines) is compiled in the background at start, and the cache survives updates: a repeat
session compiles nothing.
- **Launchers:** GTK4 on Linux, WPF in the Windows 11 style on Windows (both with sections and
descriptions); an **AppImage** for Linux and the Steam Deck, the same as a **folder**
(`tar.zst`, `Bloodborne.sh`), a **zip** for Windows.
- **GTK4 launcher** and an **AppImage** for the Steam Deck.
## How it differs from shadPS4
| | shadPS4 | bbport |
|---|---|---|
| Scope | General PS4 emulator, many games | One game: Bloodborne v1.09 |
| Loading | Its own ELF loader and kernel emulation at run time | The eboot is converted offline (`scripts/`) into an image with PS4 libc/Fios2 linked in; a C loader maps it and jumps into the game (loader and runtime: ~5k lines) |
| Memory | The GPU's view of PS4 memory is kept in VRAM copies, synchronized through page-protection write tracking | The game's memory in system RAM, used by the GPU in place, frequently read data in VRAM, freed when unused |
| Command processor | Emulated: memory writes, DMA and fences are done by the CPU while decoding | Translated into Vulkan commands that the GPU runs in stream order, fences written after the work has really finished |
| Loading | Its own ELF loader and kernel emulation at run time | The eboot is converted offline (`scripts/`) into an image with PS4 libc/Fios2 linked in; a C loader maps it and jumps into the game (loader and runtime: ~8k lines) |
| Memory | The GPU's view of PS4 memory is kept in VRAM copies, synchronized through page-protection write tracking | On AMD by default the *New memory and translation model*: the game's memory in system RAM, used by the GPU in place, frequently read data in VRAM, freed when unused; on other GPUs the same model as in 0.3 |
| Command processor | Emulated: memory writes, DMA and fences are done by the CPU while decoding | With the old model the same; with the new one translated into Vulkan commands that the GPU runs in stream order, fences written after the work has really finished |
| System libraries | Broad HLE of the PS4 OS | A small runtime (`src/runtime_*.c`) that implements exactly what Bloodborne calls: memory, threads, sync, files, audio (incl. ATRAC9), pad, saves, AppContent |
| GPU | shadPS4 video core and shader recompiler | Its own command processor, renderer, caches and shader translator (they started from shadPS4's, `gpu/VENDOR.txt`): two-stage draw pipeline, render-state and texture-set memoization, render-scale proxies, motion vectors, FSR 3.1/4/4.1.1, DLSS, frame capture and GPU profiler |
| GPU | shadPS4 video core and shader recompiler | The same core (vendored, GPL) with ~500 marked changes (`bbport:`) plus new modules: two-stage draw pipeline, render-state and texture-set memoization, render-scale proxies, motion vectors, FSR 3.1/4/4.1.1, frame capture and GPU profiler |
| GPU thread | One thread processes the whole command stream (the bottleneck in Bloodborne) | Decode and draw recording run on separate threads; the work scales with the hardware threads (Steam Deck included) |
| Upscaling | — | Temporal (FSR 3.1, FSR 4, FSR 4.1.1/4.1.2, DLSS) with motion vectors computed for the game and jitter; frame generation (DLSS, FSR 3.1) |
| Platforms | Linux, Windows, macOS | Linux and Windows (x86-64) |
| Upscaling | — | Temporal (FSR 3.1, FSR 4, FSR 4.1.1) with the game's own motion vectors and jitter |
| Game patches | Patch files applied by the emulator | The same community patches, compiled at start (`scripts/patches.py`); render resolution, effects and FPS from the launcher |
Without shadPS4 there would be no bbport: its renderer and shader recompiler were the starting
point of the graphics side.
Without shadPS4 there would be no bbport: its renderer and shader recompiler are the base of
the graphics side.
### Wine + DXVK for one game
@@ -147,8 +121,8 @@ point of the graphics side.
- **System libraries.** As Wine replaces the Windows API, the bbport runtime (`src/runtime_*.c`)
implements exactly the PS4 OS functions Bloodborne calls: memory, threads, files, audio, pad,
saves.
- **Memory.** As in a PC game (the default model): the game's data in system RAM, VRAM used the
way a PC game uses it.
- **Memory.** In the new mode, as in a PC game: the game's data in system RAM, VRAM used the way
a PC game uses it. The mode is still experimental.
- **Graphics.** The PS4 GPU's command stream (PM4) is a recording of the game's graphics API
calls: it is written by 99 functions of the statically linked libGnm, so decoding the stream
and translating the calls themselves come to the same thing. GCN shaders are translated to
@@ -158,23 +132,29 @@ point of the graphics side.
bindings: a PS4 texture has to be converted from the PS4 tiling into a Vulkan image before the
draw runs, so the CPU must know its descriptor beforehand — translation, not emulation. The
descriptor tables (the constant engine's dumps) are read only by the translator, so the CPU
performs them. The game's occlusion queries (~60 a frame) are
Vulkan occlusion queries; predication, `COPY_DATA` and `COND_EXEC` are translated too (check:
`BB_PM4_SELFTEST=1`). Details:
[docs/EMULATION_REMOVAL_PLAN.ru.md](https://github.com/deadinside28/bloodborne_pc/blob/master/docs/EMULATION_REMOVAL_PLAN.ru.md), "Шаг 4" (Russian).
performs them. The game's occlusion queries (~60 a frame) can be
Vulkan occlusion queries (`BB_OCCLUSION_QUERIES=1`, opt-in; by default a fixed "everything
visible" sequence written in command-stream order, `BB_ZPASS_IN_ORDER=0` turns the ordering off);
predication, `COPY_DATA` and `COND_EXEC` are translated too (check:
`BB_PM4_SELFTEST=1`). Details: upstream bbport's
[EMULATION_REMOVAL_PLAN.ru.md](https://github.com/deadinside28/bloodborne_pc/blob/master/docs/EMULATION_REMOVAL_PLAN.ru.md),
step 4 (in Russian).
"Port" here means a build for this one game, not a rewrite of its source code, which the project
neither has nor includes.
## Requirements
- Linux x86-64 or Windows 10/11 x86-64, a Vulkan 1.3 GPU. Tested: AMD RX 7800 XT with Mesa 26
(RADV) and with AMD's Windows driver; NVIDIA and Intel by players (please report how they run).
- Linux x86-64, a Vulkan 1.3 GPU. Tested: AMD RX 7800 XT with Mesa 26 (RADV).
On NVIDIA the new memory model is still being measured with testers, so *Auto* keeps the 0.3
model there.
FSR 4 / 4.1.1 require shader Float16, Int8/Int16, integer dot products, linear compute
derivatives and extended storage image formats; FSR 4.1.1 additionally requires
`VK_VALVE_shader_mixed_float_dot_product`. Unsupported choices fall back to FSR 3.1
before the first frame and are disabled in the in-game menu.
- Your decrypted game dump: the `CUSA03173` folder (eboot.bin, sce_module, ...), version 1.09.
The US release (`CUSA00900`) runs too: its 1.09 executable is the same as this European
Game of the Year edition's.
A dumped update is a separate folder: copy it over the base game, replacing files. The base
game alone (1.00) crashes at start (guest offset 0x20348b8); the launcher and `run.sh` check
the executable and say what is missing (`BB_SKIP_GAME_CHECK=1` skips the check). The other
@@ -187,36 +167,56 @@ neither has nor includes.
## Build and run
```bash
git clone --recursive https://github.com/deadinside28/bloodborne_pc.git bbport && cd bbport
git clone --recursive https://github.com/ayozetr/bloodborne_linux.git && cd bloodborne_linux
bash build.sh # builds out/bb-probe and out/gpu/libbbgpu.so
BB_GAME_DIR=/path/to/CUSA03173 bash run.sh
```
or the launcher (pick the game folder, settings, *Start*):
or the launcher (pick the game folder, settings, *Play*):
```bash
bash launcher/bb-launcher.sh # launcher/install-desktop.sh adds it to the app menu
```
By default the game folder is expected next to the repository (`../CUSA03173`). Saves and the
shader cache go to `user/` (the launcher lets you choose another folder); settings to
`bbport.ini`. A gamepad is used through SDL3 (the launcher's *Controls → Controller* picks one
when several are connected; `BB_GAMEPAD=<GUID or part of the name>`). The keyboard works too,
shader cache go to `user/` (the launcher's *Setup → Saves* lets you choose another folder, exports
the saves into a .zip, and imports them from such a .zip, shadPS4, another port or a PS4 save
exported unencrypted, after backing up the current ones in `user/savedata-backups/`, and can
copy them there before each game, keeping as many copies as you choose; `scripts/save_import.py`); settings to `bbport.ini`. A gamepad is used
through SDL3 (the launcher's *Controls → Gamepad → Controller* picks one when several are connected,
`BB_GAMEPAD=<GUID or part of the name>`; with none picked, the one you press a button on or move
takes over). The keyboard works too,
also next to a connected gamepad (the Steam Deck always has one); both are remapped in the
launcher (*Controls*). The character name is typed on the keyboard in a box over the game.
launcher (*Controls*); holding Left Alt with the move keys walks instead of running (`key.walk`). The character name is typed on the keyboard in a box over the game.
The touchpad: its left half (Tab, Back/Select) opens the gestures, the right half (Backspace)
the key items. There is no PSN: the *Play online / offline* screen is skipped and the game opens
its main menu offline (a game patch; the launcher's *Game effects → Skip the “play online / offline” choice*,
`BB_SKIP_NETWORK_CHOICE=0` shows it). With several monitors, *Screen → Monitor* in the launcher
the key items. On a controller without a touchpad (Xbox), Back + d-pad left / right clicks the
left / right half. *Controls* in the launcher also swaps the sticks and inverts the controller's
camera (`pad_swap_sticks`, `pad_invert_look_x`, `pad_invert_look_y` in bbport.ini). With *Direct
mouse camera* (`mouse_camera=direct`) the mouse turns the camera itself, 1:1, with no stick dead
zone or turn curve (the game's 1.09 code is hooked while the window holds the mouse; locked on, the
mouse moves the stick again so a flick switches targets). *Graphics → Display → Field of view* (`fov`, 30-90 degrees, the game's
43) scales every camera of the game's camera table, as a layer under the mods (`scripts/fov.py`; a mod's own
`gameparam.parambnd.dcx` wins). The *Play online / offline* screen is skipped: the game opens its main menu online when online
play is on, offline otherwise (a game patch; the launcher's *Multiplayer → Online → Skip the “play online / offline” choice*,
`BB_SKIP_NETWORK_CHOICE=0` shows it). With several monitors, *Graphics → Display → Monitor* in the launcher
(`BB_DISPLAY=<number or part of the name>`) picks the one for the game.
**Resolution and preset changes:** the game starts at the scene size of the chosen output
and preset (a patch at start, the fastest path); the output is the monitor's resolution by
default (*Auto*). With *Live resolution changes* (on by default; `bbport.ini`
`live_resolution=0|1`) the preset, the output resolution and the aspect ratio then switch while
playing, to smaller and to larger sizes, at the same FPS as a start at that size; only another
upscaler kind needs a restart. GPUs that cannot give the game's depth a proxy (no D32S8 blits
and no shader stencil export, e.g. GTX 10xx) apply a change at the next start instead.
Add-ons: `BB_DLC_DIR=/path/to/dlc` (the launcher's *Setup → DLC folder*) holds the dumped add-ons of
the game, one folder each named by its entitlement label (`SPEXPANSIONDLC03/sce_sys/...`).
Their data ships with the 1.09 update, so a folder with only `sce_sys` is enough. `run.sh` links
it as `user/addcont/<title id>`, where the runtime finds add-ons (see **DLC** below); a real
folder already there is kept. The European Game of the Year edition (CUSA03173) needs none.
**Resolution and preset changes:** for outputs other than 1080p (720p on the Steam Deck,
1440p, 4K) the whole game renders at the preset's resolution, set by a patch at start — the
fastest path. Changing the output or the preset in the in-game menu then needs *Apply and
restart the game*. The *Live resolution changes* setting (launcher, in-game menu,
`bbport.ini` `live_resolution=0|1|auto`; **off by default**) instead keeps the game at
1080p internally and scales its render targets at run time, so 720p/1080p/1440p/4K and the
presets switch without a restart. It costs more: the game then believes it renders 1080p
and draws more (e.g. ~8× more small lights), and some targets are copied between sizes —
use it on strong desktop GPUs only (`auto` turns it on for discrete GPUs with 8+ GB that are
not pre-Turing NVIDIA). 1080p output and TAA always use the live path.
**TAA:** a separate native-resolution temporal AA mode in the launcher and overlay,
switchable live without an FSR model. The saved FSR preset is restored when returning
@@ -228,36 +228,42 @@ temporal accumulation and leaves its history and HUD unchanged.
**Mods:** the launcher accepts separate loose-file mod folders (with `dvdroot_ps4/`, an extra
wrapper folder, or the game folders such as `chr/` directly; file name case does not matter),
with enable switches and load order. A sibling `CUSA03173-mods/` overlay also works.
The original game is preserved; later mods override conflicting files. Texture packs with
upscaled map textures (from the mods folder or already in the game folder) get larger graphics
heaps by themselves (`BB_LARGE_HEAPS=1/0` forces it on or off).
The original game is preserved; later mods override conflicting files.
**Third-party patches:** shadPS4-format XML patch files in the data directory's `patches/`,
switched on and off in the launcher. See [mods and patches](https://github.com/deadinside28/bloodborne_pc/blob/master/docs/MODS.md).
switched on and off in the launcher. See [mods and patches](docs/MODS.en.md).
**DLC:** put your add-on dumps in `user/addcont/<title id>/<entitlement label>/` (shadPS4's
layout, e.g. `user/addcont/CUSA03173/SPEXPANSIONDLC03` for The Old Hunters): each folder there is
reported to the game as an installed add-on.
**Launcher language:** Russian, English, Brazilian Portuguese, Simplified Chinese or French (follows the system language by default).
The game's own text language is chosen separately, from all 21 languages the game has.
**GPU choice:** *Mode → GPU* picks the graphics card on machines with two (*Auto* prefers the
discrete one; stored by name; `BB_GPU_ID=N` from the shell wins).
**Launcher language:** Russian, English, Brazilian Portuguese, Spanish, Italian, French, German or Simplified Chinese (follows the system language by default).
**Game language:** the PS4 system language, *Setup → Game language* in the launcher; by default it
follows the desktop's locale (Japanese, English, French, Spanish, German, Italian or Russian,
English for the rest). `run.sh` takes it as `BB_LANGUAGE` (PS4 code: 0 Japanese, 1 English,
2 French, 3 Spanish, 4 German, 5 Italian, 8 Russian; without it, English: following the desktop's
locale is the launcher's job).
**Game region:** *Setup → Game region* in the launcher, `BB_REGION=eu|na` for `run.sh`: the regional text
of a language, such as Spanish from Spain (`eu`) or Latin American Spanish (`na`). It sets the
game's `USER_DEFINED_PARAM_1` (13 Europe, 2 the Americas) for the launch; by default the dump's
`param.sfo` decides.
**Free camera and game debug menu** (v1.09): enable the corresponding switches in the
launcher or in-game menu and restart. Free camera uses Lance McDonald's
launcher (*Mods → Menus and debugging*) or in the in-game menu and restart. Free camera uses Lance McDonald's
[GoldHEN patch](https://github.com/GoldHEN/GoldHEN_Patch_Repository/blob/main/patches/xml/Bloodborne-Orbis.xml):
hold Cross and press L3 to cycle modes (keyboard: hold F and press Left Ctrl). It needs no fonts
hold Cross and press L3 to cycle modes (keyboard: hold Space and press Z). It needs no fonts
and conflicts with *Enemy Control*.
For the game debug menu, install `DbgFont14h.ccm` and `DbgFont14h.tpf` from
[Debug Menu and XML Patch](https://www.nexusmods.com/bloodborne/mods/253) into the game's
`dvdroot_ps4/font/` first. Startup rejects missing or empty font files instead of launching
the unsafe patch. Open it with the left touchpad / Tab (with the debug menu on, the left half
For the game debug menu, install the `adhoc` folder of
[Debug Menu and XML Patch](https://www.nexusmods.com/bloodborne/mods/253)
(`adhoc/font/DbgFont14h.ccm` and `.tpf`) into the game's `dvdroot_ps4/`, or as a mod, first.
Without them (or with empty files) the debug menu patch is left out at start and the log says
why. Open it with the left touchpad / Tab (with the debug menu on, the left half
no longer opens the gestures); Backspace is the right touchpad.
Touch coordinates are forwarded from SDL gamepads; Back/Select emulates a left click on
pads without a touch surface. The port's settings menu remains Insert / holding L3+R3.
pads without a touch surface, Back + d-pad left / right a click on that half (while Back is bound
to the touchpad only, the default). The port's settings menu remains Insert / L3+R3.
With the *Restore Debug Camera* patch the mouse turns the free camera. The game's occlusion
queries are Vulkan occlusion queries in the default model (`BB_OCCLUSION_QUERIES=0`: everything
counts as visible, as in the old model).
By default GPU occlusion queries return synthetic "visible" counters (`BB_OCCLUSION_QUERIES=1`
counts real samples). Free camera allows visual investigation; it does not change occlusion
culling.
**Upscaler assets** (not included; FSR 3.1 needs none):
@@ -267,7 +273,7 @@ bash tools/fetch_fsr4_assets.sh # FSR 4 v07 (MIT, built from AMD's source b
bash tools/fsr4cap/build_assets.sh <amd_fidelityfx_upscaler_dx12.dll>
```
**FSR 4.1.1 from your own DLL.** The easiest way is the launcher: *Upscaler → FSR 4.1.1 from your
**FSR 4.1.1 from your own DLL.** The easiest way is the launcher: *Graphics → Upscaler → FSR 4.1.1 from your
own AMD DLL → Choose DLL…*, then pick `amd_fidelityfx_upscaler_dx12.dll` version 4.1.x: from
OptiScaler's `FSR4_LATEST` folder or from a game with FSR 4.1. That one file is enough: AMD's DLL
exports the FidelityFX API itself, so no loader is needed (only a DLL without these exports would
@@ -275,11 +281,10 @@ be recorded through `amd_fidelityfx_loader_dx12.dll`, and the launcher would ask
without a long capture; these do not fit: AMD's official FSR 4.0.x (e.g. Pragmata's 4.0.3 —
AMD enables it on RDNA4 only, and under Proton it does not start on other GPUs) and community
builds (4.0.2b, 4.1.1b and similar OptiScaler INT8 builds: another model and pass count). Then
the DLL runs under Proton and is recorded at 20 sizes (progress in the row and in the *Log* tab;
the DLL runs under Proton and is recorded at 20 sizes (progress in the row and in the *Log* section;
on RDNA4 a second time, for the FP8 variant),
the passes are translated to SPIR-V, and FSR 4.1.1 is selected. The Proton build is picked
automatically — the first under which the DLL enables FSR 4.1: GE-Proton 10 or newer (also the
AUR's `proton-ge-custom` in `/usr/share/steam/compatibilitytools.d`),
automatically — the first under which the DLL enables FSR 4.1: GE-Proton 10 or newer,
Proton-CachyOS, Proton Experimental (tested: GE-Proton 11, Proton-CachyOS 11, Experimental of
October 2026; Steam's Proton 11.0 and GE-Proton 9 do not). It runs in the Steam runtime it
requires (Steam installs it the first time any game runs with that Proton), or through
@@ -287,7 +292,9 @@ umu-launcher when installed. On NixOS umu-launcher comes from `nix-shell` (downl
time, ~1.7 GB); the AppImage runs the recording on the system itself, through the user's systemd
(`systemd-run --user`), since the system's `/nix` is out of its sight. Recording on the Steam Deck is not verified yet; if the DLL does not
enable FSR 4.1 there, build on a PC and copy the `fsr4_411` folder. The same from the command
line: the launcher's (and the AppImage's) `--build-fsr411 <DLL>`.
line: the launcher's (and the AppImage's) `--build-fsr411 <DLL>`. From source, `tools/fsr4cap/build_assets.sh` takes its tools from
the system (MinGW GCC for the capture program run under Proton, CMake, Ninja, Python 3,
SPIRV-Tools, Git) or from Nix; the AppImage has them prebuilt.
**DLSS (NVIDIA GeForce RTX).** NVIDIA's DLSS SDK is not included: check out
[github.com/NVIDIA/DLSS](https://github.com/NVIDIA/DLSS) (`include/` and `lib/Linux_x86_64` are
@@ -299,41 +306,20 @@ DLSS gets the inputs FSR 3.1 gets (scene color, depth, motion vectors, jitter).
libraries, on other GPUs or with `BB_DLSS=0` it is listed as unavailable, and a DLSS setting falls
back to FSR 3.1.
On Linux, DLSS also needs NVIDIA's CUDA/UVM runtime. If device creation fails with the DLSS
extensions, the renderer retries without them and uses FSR 3.1. If `/dev/nvidia-uvm` is missing,
load the installed driver module with `sudo modprobe nvidia_uvm` and restart the game. Installing
the distribution's `nvidia-modprobe` package lets NVIDIA's libraries load the module when needed.
If `modprobe` reports `Key was rejected by service` with Secure Boot enabled, enroll the
module's signing key using the distribution's [MOK procedure](https://documentation.ubuntu.com/security/security-features/platform-protections/secure-boot/#mok-generation-and-signing-process).
The AppImage built with `DLSS_SDK_ROOT` carries the bridge and NVIDIA's library (NVIDIA's RTX SDK
license allows that in an application; its text is `NVIDIA-DLSS-LICENSE.txt` in the package). A
newer `libnvidia-ngx-dlss.so.<version>` (from the SDK's `lib/Linux_x86_64/rel`) is chosen in the
launcher: *Upscaler → DLSS: NVIDIA's library → Choose a file…*, as the FSR 4.1.1 DLL; it is
launcher: *Graphics → Upscaler → DLSS: NVIDIA's library → Choose a file…*, as the FSR 4.1.1 DLL; it is
copied to `<user>/dlss/`, which the game prefers. A game's `nvngx_dlss.dll` is the Windows
library: NVIDIA's Linux driver does not load it.
**DLSS Frame Generation (GeForce RTX 40 and newer).** The same SDK (310.7 or newer) has NVIDIA's
Linux frame generation library, `lib/Linux_x86_64/rel/libnvidia-ngx-dlssg.so.<version>`;
`build.sh` copies it next to `bb-probe` with the Super Resolution one, and the launcher's
*DLSS: NVIDIA's library → Choose a file…* takes it too (into `<user>/dlss/`, beside or instead of
the package's). Then *Frame generation → DLSS* in the overlay menu (Insert, or hold L3+R3) or the
launcher: between each two of the game's frames one generated frame is shown (RTX 40), or up to
three (*Multiplier*, RTX 50); the display needs the refresh rate for them. It works with any
upscaler, DLSS or FSR; the inputs are the frame as shown, the frame before its UI, the scene depth,
the motion vectors bbport computes and the game's cameras as clip-space matrices. Without the
library, on an RTX 30 or older or with `BB_DLSS=0` the entry says why it is unavailable, and the
game runs without generated frames. `BB_FRAME_GEN=dlss` and `BB_FRAME_GEN_MULTIPLIER=2..4` set it
from the command line. FSR 3.1 frame generation is offered on every GPU as experimental
(*Frame generation → FSR 3.1*).
**Low latency (NVIDIA Reflex).** On NVIDIA's driver (550 or newer) the port uses the Vulkan
form of Reflex, `VK_NV_low_latency2`, the same mechanism Proton gives Windows games: the game's
thread waits for the driver before each frame so that frames do not queue up in front of the GPU,
and the frame's simulation, submissions and present are marked for the driver. On by default where
the driver has it: the overlay menu (*Output → Low latency (NVIDIA Reflex)*) and the launcher
offer Off, On and On + Boost (`low_latency=off|on|boost` in `bbport.ini`, `BB_LOW_LATENCY`);
on AMD the same setting uses **Anti-Lag 2** (`VK_AMD_anti_lag`: AMD's Windows driver, or Mesa's
anti-lag layer on Linux; `BB_ANTI_LAG=0` leaves it out). With DLSS frame generation it is always
at least On, as NVIDIA requires of games: the generated frames add latency that Reflex takes back.
Reflex and DLSS frame generation came from PR #260 by adds39939.
From source the script takes its tools from the system (MinGW GCC, CMake, Ninja, Python 3,
SPIRV-Tools, Git) or from Nix; the AppImage has them prebuilt.
**FP8 on RDNA4.** When vkd3d-proton offers FP8 cooperative matrices (RDNA4), the DLL runs
another variant of its passes: other shaders and weights, other dispatch sizes. The build records
both, INT8 into `fsr4_411/` and FP8 into `fsr4_411/fp8/`, and the game picks FP8 itself when the
@@ -350,9 +336,8 @@ the launcher's button (see above); they go to `~/.local/share/bbport/fsr4_411`
game is 16:9; on the 1280×800 screen it gets thin bars).
**Adding the AppImage to Steam** (*Add a Non-Steam Game*) needs no options; the compatibility tool
does not matter. (Steam preloads its overlay into every non-Steam game; the AppImage keeps it out
of the launcher and gives it to the game only, so it can open in the game with Shift+Tab; `BB_STEAM_OVERLAY=0`
leaves it out.) Where Steam
does not matter. (Steam preloads its overlay into every non-Steam game; the AppImage removes it
before its own programs start, so the Steam overlay is not shown in the game.) Where Steam
runs games without FUSE (NixOS: Steam's FHS sandbox; the AppImage then exits with *Cannot mount
AppImage*), set the launch options to
@@ -371,9 +356,7 @@ NVIDIA userspace driver matched to the host kernel module. Standard Linux distri
these libraries under `/usr/lib*`; on NixOS the AppImage's internal `/nix/store` may hide them.
In that case copy the NVIDIA libraries into an accessible directory and set
`BB_NVIDIA_LIB_DIR=/path/to/libraries` (the NVIDIA manifest must also be accessible).
With the host's NVIDIA driver the AppImage also passes the host's EGL vendor files, NVIDIA's
first (`__EGL_VENDOR_LIBRARY_FILENAMES`; without them NVIDIA's ICD gave no `vkCreateInstance` on
some hosts, #107; `BB_NVIDIA_EGL=0`: off). Explicit `VK_DRIVER_FILES`/`VK_ICD_FILENAMES` overrides are preserved. Diagnose drivers inside
Explicit `VK_DRIVER_FILES`/`VK_ICD_FILENAMES` overrides are preserved. Diagnose drivers inside
the package with:
```bash
@@ -390,7 +373,8 @@ When running from source, install MangoHud separately. A diagnostic launch with
`VK_LOADER_LAYERS_DISABLE=~implicit~` also disables MangoHud.
Useful variables: `BB_FRAME_STATS=1` (frame statistics, including a `Memory:` line: VRAM, GTT,
RSS, images and guest blocks in VRAM), `BB_ANISO=N` (anisotropic filtering of scene textures; 16 by
RSS, images and guest blocks in VRAM), `BB_PC_MODEL=1/0` (the new memory and translation
model / the old one as in 0.3; unset: new on AMD, old elsewhere), `BB_ANISO=N` (anisotropic filtering of scene textures; 16 by
default, 0 = the game's own),
`BB_GC_IDLE_SECONDS=N` / `BB_VRAM_IDLE_SECONDS=N` (how long unused textures / buffers stay in VRAM;
20 / 60), `BB_BREADCRUMBS=0` (no GPU breadcrumbs; with them a GPU hang names the draw or dispatch
@@ -406,72 +390,19 @@ budget taken as at most N MiB: checks the behaviour of cards with little VRAM),
with `BB_PRESENT_DUMP_COUNT=N` (dump N consecutive presented frames),
`BB_FSR411_VARIANT=int8|fp8|fp8emu` (FSR 4.1.1 variant; by default FP8 where the GPU has FP8
matrices), `BB_READBACKS=0|1|2` (reads of GPU-written memory by the game: 1 by default, 2
precise and slow, 0 off), `BB_RENDER_RESOURCE_CACHE_MB=160` (the game's render resource cache
at its own size instead of 512 MiB of the port's memory; `BB_DMEM_EXTRA_MB`, 512 by default, is
the direct memory the port keeps past the game's own). All of them, each with what it does:
[docs/ENVIRONMENT.md](docs/ENVIRONMENT.md); the log's `Environment:` line names those set and any
unknown `BB_*` name (a typo). More in [docs/](docs); recent changes: [docs/CHANGES_0.7.md](https://github.com/deadinside28/bloodborne_pc/blob/master/docs/CHANGES_0.7.md),
[docs/CHANGES_0.6.md](https://github.com/deadinside28/bloodborne_pc/blob/master/docs/CHANGES_0.6.md).
precise and slow, 0 off).
More in [docs/](docs); what changed in each release: the
[releases page](https://github.com/ayozetr/bloodborne_linux/releases).
**Online play.** A separate module, `out/gpu/libbbnet20.so` (`gpu/bbnet20`: shadPS4 v0.20's
network, NP and shadNet code, the port's BBSC and The Hunter's Dream backend), built when
protobuf, nlohmann_json, OpenSSL, zlib and miniupnpc are there (`shell.nix`) and included in the
AppImage. The launcher's *Online* page turns it on (`BB_ONLINE=1`) and offers two pools:
- **Seamless Hunt** (default): a shadNet server with BBSC's game server
(`play.seamlesshunt.com:31313`, WebAPI `:31315`); bells, summons, messages, bloodstains and
**seamless co-op (BBSC)**: the session survives deaths, lamps, area changes, bosses and the
Hunter's Dream, up to five hunters (*Seamless co-op (BBSC)*, on by default; every player needs
it and the same server). The account (online id and password) is made on
https://seamlesshunt.com. Details: [docs/BBSC.md](https://github.com/deadinside28/bloodborne_pc/blob/master/docs/BBSC.md).
- **The Hunter's Dream** (thehuntersdream.com): sign in with Discord from the launcher (a code is
confirmed on the site); no BBSC.
On Windows the module is `online\bbnet20.dll` (Seamless Hunt with BBSC; the launcher's *Online*
section). The Linux folder package keeps it open as `online/libbbnet20.so`, and `BB_NET_MODULE`
points the port at any other build of it (a newer BBSC, say).
The former module on shadp2p (`libbbnet.so`) was removed (0.7). shadPS4's
public server (`srv.shadps4.net`) accepts only shadPS4's own builds. Offline the module is never
loaded; a build without it plays offline only.
## Windows
Since 0.7 bbport runs natively on Windows 10/11 (x86-64, a Vulkan 1.3 GPU with a current driver:
AMD Adrenalin, NVIDIA GeForce, Intel Arc). The same code as on Linux: the game's x86-64 code runs
directly, the runtime's POSIX layer is implemented on Win32 (`gpu/platform/win32`), and the
game's memory is shared with Vulkan through `VK_EXT_external_memory_host`.
**Package:** `Bloodborne-bbport-<version>-windows-x64.zip`. Unpack it anywhere (not into *Program Files*), start
`Bloodborne.exe`, choose the game folder (the one with `eboot.bin`), press *Play*. Insert (or
holding L3+R3) opens the port's menu in the game. Everything the port writes stays in that folder:
`bbport.ini` (settings), `user\` (saves, shader cache), `out\` (prepared game files), `logs\` (one
log per run: for a report send the newest one). `Bloodborne.exe --play` starts the game without
the launcher window (through `bbport-run.exe`).
**Launcher:** WPF in the Windows 11 style, following the system's light or dark theme, in five
languages; the same settings as on Linux (sections with descriptions), monitor, controller and
GPU choice, the log of the running game, *Stop*.
**Upscalers:** FSR 3.1 and FSR 4 as on Linux. FSR 4.1.1: choose *Build from an AMD DLL…* and pick
`amd_fidelityfx_upscaler_dx12.dll` 4.1.x; the recording runs on vkd3d-proton and DXVK from the
package (`tools\fsr4cap\bin`), so any GPU with Vulkan can build the set (a minute or two, into
`fsr4_411\`). FSR 4.1.2's models (INT8 for RDNA2/3, *Lite*): pick the driver's `amdxcffx64.dll`
(Adrenalin 26.10.1 or newer, `C:\Windows\System32\DriverStore\FileRepository\u0*\`) together with
the upscaler DLL. DLSS and DLSS Frame Generation (GeForce RTX): with `bin\bbport_dlss.dll` and
NVIDIA's `nvngx_dlss.dll` / `nvngx_dlssg.dll` in the package.
**NVIDIA:** at start the port adds a *prefer maximum performance* power profile for
`bb-probe.exe` to the driver, unless you chose a mode in NVIDIA's Control Panel yourself
(`BB_NV_POWER=0`: leave it alone); presentation uses FIFO (`BB_NVIDIA_MAILBOX=1`: Mailbox).
**Online play:** the launcher's *Online* section, the same two pools as on Linux (Seamless Hunt
with BBSC, The Hunter's Dream with Discord sign-in), UPnP and a connection check; the module is
`online\bbnet20.dll`. **Mods:** the launcher's *Mods and patches* section, as on Linux.
**Building:** in MSYS2's CLANG64 shell (`mingw-w64-clang-x86_64` toolchain, CMake, Ninja, Python,
SDL3, glslang), or cross-built from Linux with llvm-mingw; the package
is made by `packaging/windows/package.sh` (the launcher needs the .NET 9 SDK). DLSS:
`DLSS_SDK_ROOT=<NVIDIA's DLSS SDK for Windows>` (`sdk/DLSS-windows`).
**Online play (shadNet).** A separate module, `out/gpu/libbbnet.so` (`gpu/bbnet`: shadPS4's network
and PSN libraries from shadp2p, a shadPS4 fork, with the shadNet client), built when protobuf,
nlohmann_json, OpenSSL, zlib and miniupnpc are there (`shell.nix`) and included in the AppImage.
The launcher's *Multiplayer → Online* tab turns it on (`BB_ONLINE=1`): bells and summons go through
a shadNet server with a shadNet account (Online ID and password), messages, bloodstains and ghosts
through a game server. Seamless Hunt (the default) runs both; The Hunter's Dream's pool goes
through bbbridge; or a server of your own (`docs/SERVER.md`). shadPS4's public one
(`srv.shadps4.net:31313`) no longer accepts bbport builds. Offline the module is never loaded; a
build without it plays offline only.
## Repository layout
@@ -479,71 +410,46 @@ is made by `packaging/windows/package.sh` (the launcher needs the .NET 9 SDK). D
|---|---|
| `src/` | Loader (`probe.c`) and the HLE runtime |
| `scripts/` | Offline preparation of the game image, module linking, patch compiler |
| `gpu/` | Renderer library, the port's own (`gpu/VENDOR.txt`): command processor, shader translator, renderer, caches, ImGui menu, FSR 4.1.1 runtime (`gpu/upscale/fsr411`); the online module (`gpu/bbnet20`) |
| `launcher/`, `packaging/` | GTK4 launcher (Linux), WPF launcher (`launcher/windows`), the shared settings schema; Nix package, AppImage, Windows package |
| `gpu/` | Renderer library: vendored shadPS4 video core with this port's changes (`gpu/VENDOR.txt`), shims, ImGui menu, FSR 4.1.1 runtime (`gpu/shadps4/video_core/renderer_vulkan/fsr411`) |
| `launcher/`, `packaging/` | GTK4 launcher; Nix package and AppImage |
| `patches/` | Community patches for Bloodborne |
| `tools/` | Developer tools: scripted runs, A/B toggles, FSR benchmark helpers, FSR 4 shader rewrites, `fsr4cap` (FSR 4.1.1 recording/extraction) |
| `tests/` | Loader, runtime, patch and renderer tests |
| `docs/` | Design notes and measurements ([upscaler](docs/upscaler.md), [parallel GPU](docs/parallel_gpu.md), [motion vectors](https://github.com/deadinside28/bloodborne_pc/blob/master/docs/motion_vectors.md), [roadmap](https://github.com/deadinside28/bloodborne_pc/blob/master/docs/ROADMAP.md)) |
Tests: `bash build.sh --test`, `python3 -m unittest discover -s tests`, and
`ninja -C out/gpu motion-history-test ui-composition-test scene-resolution-test motion-shader-test settings-test camera-matrices-test`.
`ninja -C out/gpu motion-history-test ui-composition-test scene-resolution-test motion-shader-test settings-test`.
## Roadmap
- NVIDIA on Windows: more reports from RTX cards in the heaviest views.
- The new memory and translation model on by default on NVIDIA and Intel too (it already is on
AMD) once tested there; the old model is removed after that.
- Textures as objects created at load time (less guessing by address, uploads outside the
frame).
- All of the game's shaders translated ahead of time (today: recipes from players' caches).
- Shaders translated ahead of time, at install, not during play.
- More CPU parallelism in GPU command processing (split the draw-recording stage further),
scaling to all hardware threads — most important for the Steam Deck.
- Async compute for the upscaler (the frame is GPU-bound at 4K).
- XeSS (super resolution) and XeFG frame generation through a Wine helper sharing Vulkan
memory (a memory-bridge prototype is in `tools/bridge_helper`); inputs exposed so that OptiScaler-style mapping works.
- Reactive and transparency masks for particles and fog.
- Frame generation (FSR 3.1 FG first), reactive and transparency masks for particles and fog.
- Fix the races in AMD's FSR 4.1.1 shaders at output widths that are not multiples of 64
(e.g. 1600×900), as already done for the left-edge race in FSR 4 v07 at 1080p.
- Steam Deck validation of the AppImage; HDR output.
## Related projects
- [ducvd89/Bloodborne-Android](https://github.com/ducvd89/Bloodborne-Android): Bloodborne on
Android, a fork of bbport (GSR2 / FSR 3.1 and LSFG, an on-screen controller; tested on
Snapdragon 8 Gen 2 and 8 Elite).
- [bloodborne-native/bloodborne-mac](https://github.com/bloodborne-native/bloodborne-mac):
Bloodborne running natively on Apple Silicon, a macOS app built on bbport and shadPS4.
## Credits and licenses
bbport is licensed under the **GNU GPL v2 or later** ([LICENSE](LICENSE)) — it contains code
from shadPS4 (GPL-2.0-or-later). Reusing bbport's code: keep its copyright and license notices
(`SPDX-FileCopyrightText: Copyright 2026 bbport contributors` at the top of its files) and
[NOTICE](NOTICE), and say where the code comes from, with a link to this repository. Third-party components keep their licenses:
[shadPS4](https://github.com/shadps4-emu/shadPS4) (GPL-2.0+: the renderer and shader translator were derived from it and rewritten; the online module keeps its network and NP code),
This fork is licensed under the **GNU GPL v3 or later** ([LICENSE](LICENSE)). bbport, which it is
built on, is GPL-2.0-or-later and contains code from shadPS4 (GPL-2.0-or-later); the fork also
includes code from Lantern Engine by Awaken (https://github.com/Awakenchan/LanternEngine), GPL-3.0-or-later. Third-party components keep their licenses:
[shadPS4](https://github.com/shadps4-emu/shadPS4) video core and shader recompiler (GPL-2.0+),
[sirit](https://github.com/shadps4-emu/sirit), [half](https://half.sourceforge.net/),
[FSR-Vulkan](https://github.com/FireBurn/FSR-Vulkan) by FireBurn (MIT; FSR 3.1 on Vulkan and the
FSR 4 v07 provider), AMD FidelityFX SDK (MIT), [LibAtrac9](https://github.com/Thealexbarney/LibAtrac9)
(MIT), [Dear ImGui](https://github.com/ocornut/imgui) (MIT), DejaVu fonts,
[BBSC](https://github.com/CLSAMG22/BBSC) seamless co-op (GPL-2.0+, in the online module), the
online module's network code from [shadPS4 v0.20](https://github.com/shadps4-emu/shadPS4/tree/v.0.20.0),
The Hunter's Dream backend from
[droogie/bbhost](https://github.com/droogie/bbhost) (GPL-3.0-or-later, so `libbbnet20.so` as a
whole is GPL-3.0-or-later),
[dxil-spirv](https://github.com/HansKristian-Work/dxil-spirv) (MIT, used to build the
FSR 4.1.1 assets), [wepoll](https://github.com/piscisaureus/wepoll) (BSD-2-Clause, the Windows
online module). Game patches by Kyo, Lance McDonald, auser1337, illusion, emoose and other
community members (`patches/Bloodborne.xml`). The DLSS bridge (`gpu/dlss_bridge`, MIT) is adapted
FSR 4.1.1 assets). Game patches by Kyo, Lance McDonald, auser1337, illusion, emoose and other
community members (`patches/Bloodborne.xml`). The DLSS bridge (`gpu/dlss_bridge`, MIT) and
loader come from [Supermedo's Windows port](https://github.com/Supermedo/bloodborne_pc), adapted
from [IFreemz/shadPS4-Bloodborne-DLSS-FSR](https://github.com/IFreemz/shadPS4-Bloodborne-DLSS-FSR).
AMD's FSR 4 DLLs and model data and NVIDIA's DLSS SDK and libraries are not distributed here.
Contributions merged into bbport (thank you; the full list with each release is in
[docs/CONTRIBUTORS_0.7.md](https://github.com/deadinside28/bloodborne_pc/blob/master/docs/CONTRIBUTORS_0.7.md)): SrDeTs (#10), IgorWarzocha (#36), itachi-re
(#43), Bainianzzz (#71), hideoneric (#78), guitaripod (#80), unTanana (#83, #84, #89), andrR89
(#93), SaeedShow (#108), AndiMajore (#112, #113), ayozetr (#117), JuanraCM (#120), Supermedo and Awaken (the online
module's network stack, from Supermedo's Windows fork), wheat32 (#360 larger heaps for texture packs),
Sarxzer (#347 French), whp199 (#210, #211, #214, #342, #343), DominickCobb-rs (#346), maiconjsv
(#339), iannitram (#293), adds39939 (#260 DLSS Frame Generation and Reflex), AndiMajore (#166,
#224, #225), Qiming-Liu (#75, #157 Chinese), AyrtonSanabio (#153, #154), axpico (#206), msisovic (#193),
noelmiller (#235), Obrikash (#140), Aryxa (#137), ArenaMMORPG (#352/#353), guutol (trophies),
redditor69 (GT-VBAO), SrShadowy and Teefpyre (engine and BBSC fixes from SrShadowy's fork), and
the players who sent their shader caches and logs.
-36
View File
@@ -1,36 +0,0 @@
bbport — a native PC port of Bloodborne (CUSA03173, 1.09)
https://github.com/deadinside28/bloodborne_pc
This copy is the Linux fork, https://github.com/ayozetr/bloodborne_linux. bbport's own files stay
GPL-2.0-or-later, as below. The fork as a whole is distributed under the GNU General Public
License, version 3 or (at your option) any later version (LICENSE has its text): it includes code
under GPL-3.0-or-later (the online module's BBSC and The Hunter's Dream parts), and everything
else in it allows that. Files from other projects keep their own notices.
Copyright 2026 bbport contributors (the authors of this repository's commits and merged pull
requests). Licensed under the GNU General Public License, version 2 or (at your option) any later
version (the fork's LICENSE has version 3's text). No game code or data is part of this project.
Files of this project carry, in their first lines,
SPDX-FileCopyrightText: Copyright 2026 bbport contributors
SPDX-License-Identifier: GPL-2.0-or-later
(or the license and copyright holders of the code they came from). The GPL requires these notices
to be kept intact in copies and in works based on them (GPL-2.0, sections 1 and 2): if you reuse
bbport's code — the runtime in src/, the GPU layer in gpu/, the scripts, the launchers — keep
them, keep this file, and say in your own documentation that the code comes from bbport, with a
link to the repository above. Code reused from a fork of bbport is bbport's code too.
Code bbport is built on, with its own notices kept in its files:
- shadPS4 (https://github.com/shadps4-emu/shadPS4, GPL-2.0-or-later, Copyright shadPS4 Emulator
Project): the GPU layer and the shader translator started from its video core and shader
recompiler and were rewritten part by part (gpu/VENDOR.txt). The online module keeps shadPS4
v0.20.0's network, NP and shadNet code (gpu/bbnet20/vendor/shadps4, gpu/bbnet20/SHADNET-PORT.md).
- The online module's other parts (gpu/bbnet20: BBSC, The Hunter's Dream pool; GPL-3.0-or-later
where their files say so) keep their authors' copyright (gpu/bbnet20/SHADNET-PORT.md).
- The DLSS bridge (gpu/dlss_bridge, MIT), adapted from IFreemz's shadPS4-Bloodborne-DLSS-FSR
(https://github.com/IFreemz/shadPS4-Bloodborne-DLSS-FSR).
- AMD FidelityFX shaders and headers (MIT) in gpu/upscale/shaders/ffx, gpu/third_party and
tools/fsr4cap/ffx, with AMD's notices.
- The libraries in gpu/third_party (sirit, imgui, half, httplib, fsr-vulkan and others), each under
its own license in its folder.
+11 -19
View File
@@ -28,22 +28,16 @@ seamless co-op, the Steam Deck, and what to do when something goes wrong.
**local co-op** (split screen), 2 to 4 players on one PC, each with their own controller.
- **In-game map** (Hunter's Atlas): a fullscreen map with where you are, lamps and items, and fast
travel to the lamps you've found.
- **Frame rate unlocked** (up to 120 FPS recommended; the game's movement can break above it)
or capped at **30, 60 or 90**, and changed while you play from the in-game menu. Resolutions from
**720p to 4K**, and any **aspect ratio** (4:3 to 32:9 ultrawide, [how](docs/TROUBLESHOOTING.md#the-picture-is-stretched-on-an-ultrawide-screen)).
- **Upscaling:** AMD **FSR 3.1** and **FSR 4**, NVIDIA **DLSS** (GeForce RTX) and TAA, and
**frame generation** (FSR 3.1, or DLSS on GeForce RTX 40 and newer).
- **Frame rate unlocked** (up to 120 FPS) or capped at **30, 60 or 90**. Resolutions from
**720p to 4K**, and **ultrawide** (21:9, 32:9, [how](docs/TROUBLESHOOTING.md#the-picture-is-stretched-on-an-ultrawide-screen)).
- **Upscaling:** AMD **FSR 3.1** and **FSR 4**, NVIDIA **DLSS** (GeForce RTX) and TAA.
- **Any controller:** PlayStation, Xbox and Switch pads with their own **button prompts in the
game**, and **keyboard and mouse**, with keys you can rebind (bbport 0.7's Dark Souls
III-style layout, or the classic one of earlier Linux versions), movement keys that keep a
sprint through turns (the last of two opposite keys wins; `keyboard_movement=0.7` in
`bbport.ini` for bbport 0.7's ramped stick) and the mouse as a pointer in the game's menus.
- **Exit to desktop** from the game's title and System menus.
game**, and **keyboard and mouse**, with a direct 1:1 mouse camera and keys you can rebind.
- **Quit to Desktop** from the game's title and System menus (it saves first).
- **Cheats and gameplay tweaks:** never die, stealth, camera distance, ragdoll physics and more.
- **Trophies and the platinum**, with a notice in the game like on the PS4.
- **Add to Steam** in one click, and **Discord Rich Presence** with your area, your co-op
partners and a button to this repository.
- **Launcher and in-game menu** (Insert, or hold L3 + R3) in English, Spanish, French, German, Italian,
- **Add to Steam** in one click, and **Discord Rich Presence** with your area and co-op partners.
- **Launcher and in-game menu** (L3 + R3 or Insert) in English, Spanish, French, German, Italian,
Portuguese, Russian and Simplified Chinese.
- **AppImage** ready for any distribution and the Steam Deck, which **updates itself** from the
launcher (and can go back to an earlier release).
@@ -73,8 +67,9 @@ in the **Status** row (every line should get a check mark):
- **Seamless Hunt** (the default): the community server of Seamless Co-op (BBSC), with players on
the Windows port. Make an account from the launcher with **Create** ([docs/ONLINE.md](docs/ONLINE.md#seamless-hunt)).
- **The Hunter's Dream**: sign in with Discord. You play with bbhost's players
([docs/ONLINE.md](docs/ONLINE.md#the-hunters-dream-with-bbhosts-players)).
- **The Hunter's Dream**: sign in with Discord, or create an account with just a name. You play
with bbhost's players. This connection is unofficial: report problems here, not to The Hunter's
Dream.
- **Your own server**: for your group, with Docker and Tailscale ([docs/SERVER.md](docs/SERVER.md)).
- **Seamless Co-op (BBSC)**, in the same tab: every player needs it, and summons across areas need
its game server, which your own server can run ([docs/ONLINE.md](docs/ONLINE.md#seamless-co-op-bbsc)).
@@ -115,10 +110,7 @@ python3 launcher/bbport_launcher.py
```
With [Nix](https://nixos.org), `bash build.sh` gets every dependency by itself. DLSS needs
`bash tools/build_dlss.sh` once; the AppImage is `bash packaging/appimage-sharun.sh`, which bundles
the libraries of the PC it runs on. Releases are built with `bash packaging/appimage-in-arch.sh`
(Docker, plain Arch Linux): on distributions built for newer processors (CachyOS's x86-64-v3
repositories, for example) the AppImage would otherwise not start on CPUs without AVX2. Environment
`bash tools/build_dlss.sh` once; the AppImage is `bash packaging/appimage-sharun.sh`. Environment
variables for the launcher's **Advanced → Developer → Extra variables**: [docs/ENVIRONMENT.md](docs/ENVIRONMENT.md).
Problems: [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md).
-1
View File
@@ -1 +0,0 @@
0.7
+3 -5
View File
@@ -11,8 +11,6 @@ Only what the mods drew is here, never the game's own files:
of big prompts from the shared UI atlas (`atlas.dds`, scaled down to the game's atlas size).
- `gesturetop.json`: the edits that turn the game's `menu/gesturetop.gfx` into the mod's.
The port draws its button prompts live (`gpu/shim/bbport_button_prompts.cpp`): `scripts/prompt_art.py`
turns the 20 icons into the raw images it uses for the Xbox and Switch styles, and the keyboard's
are its own. `scripts/gestures_menu.py` applies `gesturetop.json` to the player's own
`menu/gesturetop.gfx` when the launcher's *Gestures menu without motion-control icons* is on.
The Linux fork's `tools/extract_prompt_assets.py` rebuilt this folder from the mods' ZIPs.
`scripts/menu_prompts.py` puts them into the player's own `menu/common.tpf.dcx` and
`menu/gesturetop.gfx` at each start; `tools/extract_prompt_assets.py` rebuilds this folder from
the mods' ZIPs. Keyboard prompts are drawn by `scripts/menu_prompts.py` itself.
+20
View File
@@ -0,0 +1,20 @@
# Your account (token + recovery code): never commit or share it.
bbbridge_state.json
bbbridge_state.json.*
# Per-PC settings (the chosen bbport folder, ...)
bbbridge_config.json
# Logs and trace captures (they can hold session ids)
*.log
captures/
# Build output
build/
bin/
bbbridge.exe
.vs/
out/
__pycache__/
# Release packages
dist/
+40
View File
@@ -0,0 +1,40 @@
# Changelog
## 0.3.2 - 2026-10-12
- **Fix: "Failed to acquire Bloodborne server information" after closing
bbbridge.** The file that sends the game's server traffic to bbbridge
(`host_overrides.custom.json` in bbport's user folder) now exists only while
bbbridge runs with the switch on The Hunter's Dream: it goes when bbbridge
closes, when the bridge stops, or when bbport is set back to shadNet - also
in the bbport launcher itself. One left behind by an older bbbridge
(including the Python 0.2.0) is cleaned up the next time bbbridge starts.
- Troubleshooting notes in the README.
## 0.3.1 - 2026-10-12
- **Online switch** in place of the "Route bbport through bbbridge" box:
shadNet on the left, The Hunter's Dream (bbhost) on the right. A line under
it says which pool and account PLAY uses.
- **shadNet comes back whole**: switching to shadNet restores bbport's own
server *and* shadNet account (name, password). The Hunter's Dream account is
bbport's online id only while switched to it. PLAY follows the switch; on
shadNet no Discord sign-in is needed.
- **Set up by default**: once signed in with Discord, the switch goes to The
Hunter's Dream and the live map patch is added; turning either off sticks.
- **bbport folder**: shows where it was found; bbbridge.exe right next to
Bloodborne.exe works too; asks when it had to guess, when a chosen folder is
gone, or when bbport is not found.
- **Clear errors**: PLAY and "bbport settings..." say what is missing (sign-in,
game folder, shadNet account, launcher open, missing exe, Windows' error).
"bbport settings..." also works before the launcher has any settings.
- **Lighter**: the window draws at full rate only while used (idle CPU about a
quarter of before).
- **Steadier event poll**: it no longer stalls ~8 s about once a minute, so a
summon reaches the game without that delay.
- Verified live: joining a bbhost host's world as a guest (Small Resonant Bell).
## 0.3.0 - 2026-10-12
- First C++ release: one window for the bridge, Discord sign-in, routing bbport,
the live map patch and starting the game.
+81
View File
@@ -0,0 +1,81 @@
cmake_minimum_required(VERSION 3.21)
project(bbbridge VERSION 0.3.2 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# One self-contained exe: the C++ runtime is linked in (no VC++ redistributable).
set(CMAKE_MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
if(MSVC)
add_compile_options(/utf-8 /permissive- /EHsc)
add_compile_definitions(_WIN32_WINNT=0x0A00 WINVER=0x0A00 UNICODE _UNICODE _CRT_SECURE_NO_WARNINGS)
endif()
# The bridge itself: everything but the front ends.
add_library(bbbridge_core STATIC
src/util.cpp
src/proto3.cpp
src/http.cpp
src/bbhost_client.cpp
src/p2p.cpp
src/bridge.cpp
)
target_include_directories(bbbridge_core PUBLIC src third_party)
target_sources(bbbridge_core PRIVATE src/account_link.cpp)
if(WIN32)
target_link_libraries(bbbridge_core PUBLIC ws2_32 winhttp bcrypt)
# Pointing bbport for Windows at the bridge (its launcher's settings, host overrides).
target_sources(bbbridge_core PRIVATE src/setup.cpp)
else()
# Linux: BSD sockets, libcurl for HTTPS, OpenSSL for SHA-256. The bbport launcher points the
# game at the bridge itself, so there is no setup.cpp, and no window (bbbridge-cli only).
find_package(Threads REQUIRED)
find_package(CURL REQUIRED)
find_package(OpenSSL REQUIRED COMPONENTS Crypto)
target_link_libraries(bbbridge_core PUBLIC Threads::Threads CURL::libcurl OpenSSL::Crypto)
target_compile_options(bbbridge_core PRIVATE -Wall -Wextra)
endif()
if(WIN32)
# Dear ImGui (third_party/imgui, v1.91.5) with the Win32 + DirectX 11 backends.
add_library(imgui STATIC
third_party/imgui/imgui.cpp
third_party/imgui/imgui_draw.cpp
third_party/imgui/imgui_tables.cpp
third_party/imgui/imgui_widgets.cpp
third_party/imgui/imgui_impl_win32.cpp
third_party/imgui/imgui_impl_dx11.cpp
)
target_include_directories(imgui PUBLIC third_party/imgui)
target_compile_definitions(imgui PUBLIC IMGUI_DISABLE_DEMO_WINDOWS)
# The app: bbbridge.exe, next to this CMakeLists (inside bbport's folder).
add_executable(bbbridge WIN32 src/app.cpp src/app.rc)
target_link_libraries(bbbridge PRIVATE bbbridge_core imgui d3d11 dxgi)
add_custom_command(TARGET bbbridge POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different $<TARGET_FILE:bbbridge> ${CMAKE_SOURCE_DIR}/bbbridge.exe)
endif()
# Headless bridge: tests, servers, troubleshooting.
add_executable(bbbridge-cli src/main_cli.cpp)
target_link_libraries(bbbridge-cli PRIVATE bbbridge_core)
# Unit tests for the byte-level parts (framing, signaling, STUN, protobuf).
add_executable(bbbridge-unit tests/unit.cpp)
target_link_libraries(bbbridge-unit PRIVATE bbbridge_core)
# Warnings at /W4 for our code (not for third_party).
if(MSVC)
foreach(target bbbridge_core bbbridge bbbridge-cli bbbridge-unit)
target_compile_options(${target} PRIVATE /W4)
endforeach()
endif()
# The CLI and the unit tests land in bin\ (on Windows; elsewhere they stay in the build folder).
if(WIN32)
foreach(target bbbridge-cli bbbridge-unit)
add_custom_command(TARGET ${target} POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different $<TARGET_FILE:${target}> ${CMAKE_SOURCE_DIR}/bin/$<TARGET_FILE_NAME:${target}>)
endforeach()
endif()
+23
View File
@@ -0,0 +1,23 @@
{
"version": 3,
"configurePresets": [
{
"name": "x64-release",
"displayName": "x64 Release",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/${presetName}",
"architecture": { "value": "x64", "strategy": "external" },
"cacheVariables": { "CMAKE_BUILD_TYPE": "Release", "CMAKE_CXX_COMPILER": "cl" }
},
{
"name": "x64-debug",
"displayName": "x64 Debug",
"inherits": "x64-release",
"cacheVariables": { "CMAKE_BUILD_TYPE": "Debug" }
}
],
"buildPresets": [
{ "name": "x64-release", "configurePreset": "x64-release" },
{ "name": "x64-debug", "configurePreset": "x64-debug" }
]
}
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<http://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
+53
View File
@@ -0,0 +1,53 @@
# bbbridge
Co-op on **The Hunter's Dream** pool, with players of [bbhost](https://github.com/droogie/bbhost).
The game speaks shadNet, the online service shadPS4 uses; The Hunter's Dream speaks bbhost's
protocol. bbbridge sits between them on your own computer: to the game it is a shadNet server, and
to The Hunter's Dream it is one more bbhost client. It carries the matchmaking (rooms, bells,
summons), the traffic between the two games (with The Hunter's Dream's STUN and relay, so no router
port has to be opened) and the account. The game itself is not changed.
bbbridge is by [MaskZeus](https://github.com/MaskZeus/bbbridge). This folder holds its source, built
for Linux.
## How the port uses it
You never start it yourself:
1. In the launcher's **Multiplayer → Online** tab, choose **The Hunter's Dream (with bbhost players)**.
2. Sign in with Discord (the launcher shows a code and opens The Hunter's Dream's page), or make an
account without Discord with **Create**, and keep the recovery code it shows.
3. Press **Play**: `run.sh` starts `bbbridge-cli` for that game and stops it when the game closes.
The account and the bridge's log are in `user/bbbridge/`. More in
[docs/ONLINE.md](../docs/ONLINE.md#the-hunters-dream-with-bbhosts-players).
> This connection is unofficial: The Hunter's Dream's team has not approved bbbridge and does not
> support this port. Report problems in this repository's
> [issues](https://github.com/ayozetr/bloodborne_linux/issues).
## Building and testing
`build.sh` builds it with the rest of the port, into `out/bbbridge/`. On its own:
```sh
cmake -S bbbridge -B out/bbbridge -G Ninja -DCMAKE_BUILD_TYPE=Release
ninja -C out/bbbridge bbbridge-cli bbbridge-unit
out/bbbridge/bbbridge-unit # framing, signaling, STUN, protobuf
python3 bbbridge/tests/e2e/test_e2e.py out/bbbridge/bbbridge-cli # against fake servers, offline
```
| Folder | Contents |
|---|---|
| `src/` | The bridge (`bridge.cpp`, `p2p.cpp`, `bbhost_client.cpp`…) and `main_cli.cpp`, the program the port runs |
| `tests/` | Unit tests (C++) and end-to-end tests (Python) |
| `third_party/` | nlohmann/json and Dear ImGui (MIT) |
`src/app.cpp` is bbbridge's own window, which only builds on Windows; the port does not use it.
## License
GPL-3.0-or-later (`LICENSE`); nlohmann/json and Dear ImGui are MIT (`THIRD_PARTY_NOTICES.txt`).
The protocol comes from bbhost and The Hunter's Dream by droogie. Not affiliated with Sony,
FromSoftware or The Hunter's Dream.
+51
View File
@@ -0,0 +1,51 @@
bbbridge includes the following third-party software.
------------------------------------------------------------------------------
Dear ImGui 1.91.5 - https://github.com/ocornut/imgui
------------------------------------------------------------------------------
The MIT License (MIT)
Copyright (c) 2014-2024 Omar Cornut
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
------------------------------------------------------------------------------
JSON for Modern C++ 3.11.3 - https://github.com/nlohmann/json
------------------------------------------------------------------------------
MIT License
Copyright (c) 2013-2023 Niels Lohmann <https://nlohmann.me>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+248
View File
@@ -0,0 +1,248 @@
#include "account_link.hpp"
#include <algorithm>
#include <cstdlib>
#include <cstring>
#include <fstream>
#include "http.hpp"
namespace bb {
namespace {
// POST to the account server; the reply is read whatever the status (a limit
// is a 429 whose Message the player should see), as bbhost's auth_call does.
bool auth_call(const std::string& base, const std::string& path, const json& body, json& reply, std::string& error) {
http::ClientRequest req;
req.url = base + path;
req.body = body.dump();
req.headers = {{"Content-Type", "application/json"}, {"Accept", "application/json"},
{"User-Agent", std::string("bbbridge/") + kVersion}};
const http::ClientResponse res = http::send(req);
if (!res.ok()) {
error = res.error;
return false;
}
reply = json::parse(res.body, nullptr, false);
if (reply.is_discarded() || !reply.is_object() || !reply.contains("ResKind")) {
error = fmt("%s has no account service (HTTP %d)", base.c_str(), res.status);
return false;
}
if (jint(reply, "ResKind", 1) != 0) {
error = jstr(reply, "Message", "refused");
return false;
}
return true;
}
} // namespace
namespace {
json read_state(const std::wstring& path) {
std::ifstream in(fspath(path));
if (!in) return json::object();
json j = json::parse(in, nullptr, false);
return j.is_object() ? j : json::object();
}
} // namespace
std::string account_kind(const std::wstring& state_file, const std::string& npid) {
const json state = read_state(state_file);
if (!state.contains("accounts") || !state["accounts"].contains(npid)) return "";
const json& e = state["accounts"][npid];
if (jstr(e, "token").empty() && jstr(e, "recovery_code").empty()) return "";
return jstr(e, "linked") == "discord" ? "discord" : "bbbridge";
}
std::string website_code(const std::string& auth_server, const std::wstring& state_file, const std::string& npid,
std::string& error) {
const json state = read_state(state_file);
const std::string token =
state.contains("accounts") && state["accounts"].contains(npid) ? jstr(state["accounts"][npid], "token") : "";
if (token.empty()) {
error = "no account for '" + npid + "' yet - play once (or sign in with Discord) first";
return "";
}
json reply;
if (!auth_call(auth_server, "/auth/web_code", {{"Token", token}}, reply, error)) return "";
const std::string code = jstr(reply, "Code");
if (code.empty()) error = "no code in the reply";
return code;
}
namespace {
bool write_state(const std::wstring& path, const json& state) {
const std::wstring tmp = path + L".tmp";
{
std::ofstream out(fspath(tmp), std::ios::trunc);
if (!out) return false;
out << state.dump(2);
}
return replace_file(tmp, path);
}
} // namespace
std::string recovery_code_of(const std::wstring& state_file, const std::string& name) {
const json state = read_state(state_file);
return state.contains("accounts") && state["accounts"].contains(name)
? jstr(state["accounts"][name], "recovery_code")
: "";
}
std::string account_create_now(const std::string& auth_server, const std::wstring& state_file, const std::string& name) {
json reply;
std::string error;
if (!auth_call(auth_server, "/auth/account/create", {{"Name", name}, {"DeviceName", device_name()}}, reply, error))
return error;
json state = read_state(state_file);
if (!state["accounts"].is_object()) state["accounts"] = json::object();
state["accounts"][name] = {{"bbhost_name", name}, {"recovery_code", jstr(reply, "RecoveryCode")},
{"token", jstr(reply, "Token")}, {"online_id", jstr(reply, "OnlineId", name)}};
if (!write_state(state_file, state)) return "could not write the account file";
log("account: created '" + name + "' on this PC (recovery code stored in bbbridge_state.json)");
return "";
}
std::string account_recover_now(const std::string& auth_server, const std::wstring& state_file, const std::string& name,
const std::string& recovery_code) {
json reply;
std::string error;
if (!auth_call(auth_server, "/auth/recover", {{"Name", name}, {"RecoveryCode", recovery_code}}, reply, error))
return error;
json state = read_state(state_file);
if (!state["accounts"].is_object()) state["accounts"] = json::object();
const std::string fresh = jstr(reply, "RecoveryCode");
state["accounts"][name] = {{"bbhost_name", name}, {"recovery_code", fresh.empty() ? recovery_code : fresh},
{"token", jstr(reply, "Token")}, {"online_id", jstr(reply, "OnlineId", name)}};
if (!write_state(state_file, state)) return "could not write the account file";
log("account: recovered '" + name + "' (its new recovery code is in bbbridge_state.json)");
return "";
}
std::string account_sign_out(const std::string& auth_server, const std::wstring& state_file, const std::string& name) {
json state = read_state(state_file);
if (!state.contains("accounts") || !state["accounts"].contains(name)) return "no account for '" + name + "' here";
json& e = state["accounts"][name];
const std::string token = jstr(e, "token");
if (!token.empty()) {
json reply;
std::string error;
auth_call(auth_server, "/auth/logout", {{"Token", token}}, reply, error); // best effort, as bbhost
}
if (jstr(e, "recovery_code").empty()) state["accounts"].erase(name); // Discord: sign in again to come back
else e.erase("token");
if (!write_state(state_file, state)) return "could not write the account file";
log("account: signed out of '" + name + "' on this PC");
return "";
}
std::string device_name() {
char host[256] = {};
#ifdef _WIN32
size_t n = 0;
getenv_s(&n, host, sizeof(host), "COMPUTERNAME");
#else
const size_t n = gethostname(host, sizeof(host) - 1) == 0 ? std::strlen(host) + 1 : 0;
#endif
return n > 1 ? "bbbridge on " + std::string(host).substr(0, 48) : "bbbridge";
}
double DiscordLink::seconds_left() const {
std::lock_guard<std::mutex> lk(mu_);
return expires_at_ > 0 ? std::max(0.0, expires_at_ - now()) : 0.0;
}
void DiscordLink::start() {
cancel();
cancel_ = false;
{
std::lock_guard<std::mutex> lk(mu_);
user_code_.clear();
uri_.clear();
online_id_.clear();
message_.clear();
expires_at_ = 0;
}
state_ = State::Starting;
worker_ = std::thread([this] { run(); });
}
void DiscordLink::cancel() {
cancel_ = true;
if (worker_.joinable()) worker_.join();
if (state_ == State::Starting || state_ == State::Waiting) state_ = State::Idle;
}
void DiscordLink::fail(const std::string& why) {
{
std::lock_guard<std::mutex> lk(mu_);
message_ = why;
}
log("discord sign-in: " + why);
state_ = State::Failed;
}
void DiscordLink::run() {
json reply;
std::string error;
if (!auth_call(auth_, "/auth/device/start", {{"DeviceName", device_name()}}, reply, error)) return fail(error);
const std::string code = jstr(reply, "DeviceCode");
int interval = static_cast<int>(jint(reply, "Interval", 5));
if (interval < 1) interval = 5;
const int expires = static_cast<int>(jint(reply, "ExpiresIn", 600));
{
std::lock_guard<std::mutex> lk(mu_);
user_code_ = jstr(reply, "UserCode");
uri_ = jstr(reply, "VerificationUri");
expires_at_ = now() + expires;
if (code.empty() || user_code_.empty()) return fail("no code in the reply");
}
log("discord sign-in: approve code " + user_code() + " at " + uri());
state_ = State::Waiting;
while (!cancel_) {
for (int i = 0; i < interval * 10 && !cancel_; ++i) Sleep(100);
if (cancel_) return;
if (seconds_left() <= 0) return fail("the code expired - start again");
if (!auth_call(auth_, "/auth/device/poll", {{"DeviceCode", code}}, reply, error)) return fail(error);
const std::string status = jstr(reply, "Status");
if (status == "approved") {
const std::string token = jstr(reply, "Token"), id = jstr(reply, "OnlineId");
if (token.empty() || id.empty()) return fail("no token in the reply");
if (!save(id, token)) return fail("could not write the account file");
{
std::lock_guard<std::mutex> lk(mu_);
online_id_ = id;
}
log("discord sign-in: linked, signed in as '" + id + "'");
state_ = State::Approved;
return;
}
if (status != "pending" && !status.empty()) return fail("the server says: " + status);
}
}
bool DiscordLink::save(const std::string& online_id, const std::string& token) {
json state = json::object();
{
std::ifstream in(fspath(state_file_));
if (in) {
state = json::parse(in, nullptr, false);
if (!state.is_object()) state = json::object();
}
}
if (!state["accounts"].is_object()) state["accounts"] = json::object();
// Keyed by the account's name, which becomes the NPID: the game signs in to
// the game server under it, and the token's account matches.
state["accounts"][online_id] = {{"bbhost_name", online_id}, {"token", token}, {"online_id", online_id},
{"linked", "discord"}};
const std::wstring tmp = state_file_ + L".tmp";
{
std::ofstream out(fspath(tmp), std::ios::trunc);
if (!out) return false;
out << state.dump(2);
}
return replace_file(tmp, state_file_);
}
} // namespace bb
+73
View File
@@ -0,0 +1,73 @@
// Sign in with Discord: the OAuth device flow bbhost uses (src/net/account.cpp).
// POST /auth/device/start {DeviceName} -> DeviceCode, UserCode, VerificationUri, Interval, ExpiresIn
// POST /auth/device/poll {DeviceCode} -> Status pending | approved (+ Token, OnlineId) | ...
// The player approves the code on The Hunter's Dream with their Discord account;
// the account and its token then go into bbbridge_state.json under its name, and
// the bridge signs in with that token when the game connects with that NPID.
#pragma once
#include <atomic>
#include <functional>
#include <mutex>
#include <string>
#include <thread>
#include "util.hpp"
namespace bb {
class DiscordLink {
public:
enum class State { Idle, Starting, Waiting, Approved, Failed };
DiscordLink(std::string auth_server, std::wstring state_file) : auth_(std::move(auth_server)), state_file_(std::move(state_file)) {}
~DiscordLink() { cancel(); }
void start(); // runs in its own thread; read progress with the getters
void cancel();
State state() const { return state_; }
std::string user_code() const { std::lock_guard<std::mutex> lk(mu_); return user_code_; }
std::string uri() const { std::lock_guard<std::mutex> lk(mu_); return uri_; }
std::string online_id() const { std::lock_guard<std::mutex> lk(mu_); return online_id_; }
std::string message() const { std::lock_guard<std::mutex> lk(mu_); return message_; }
double seconds_left() const;
private:
void run();
void fail(const std::string& why);
bool save(const std::string& online_id, const std::string& token);
std::string auth_;
std::wstring state_file_;
std::atomic<State> state_{State::Idle};
std::atomic<bool> cancel_{false};
mutable std::mutex mu_;
std::string user_code_, uri_, online_id_, message_;
double expires_at_ = 0;
std::thread worker_;
};
std::string device_name(); // "bbbridge on <COMPUTERNAME>"
// The website sign-in code for an account the bridge holds a token for
// (thehuntersdream.com/account, "Sign in with a code from the game"):
// POST /auth/web_code {Token} -> Code. Returns "" and sets error on failure.
std::string website_code(const std::string& auth_server, const std::wstring& state_file, const std::string& npid,
std::string& error);
// What the state file holds for this NPID: "discord", "bbbridge", or "" (none yet).
std::string account_kind(const std::wstring& state_file, const std::string& npid);
// bbhost's other account buttons (src/net/account.cpp), on the same server:
// create an account named `name` on this PC; recover one with its recovery code
// (the server answers with a new code, which replaces the old one); sign out
// (the token is ended on the server and removed here; a recovery code stays).
// Each returns "" on success, else the reason.
std::string account_create_now(const std::string& auth_server, const std::wstring& state_file, const std::string& name);
std::string account_recover_now(const std::string& auth_server, const std::wstring& state_file, const std::string& name,
const std::string& recovery_code);
std::string account_sign_out(const std::string& auth_server, const std::wstring& state_file, const std::string& name);
std::string recovery_code_of(const std::wstring& state_file, const std::string& name);
} // namespace bb
+976
View File
@@ -0,0 +1,976 @@
// bbbridge.exe: the bridge with a window. It sits in a folder inside bbport's
// (E:\bloodborne\bbbridge\), keeps its config, account and log there, points
// bbport at itself, and starts the game.
#include "bridge.hpp" // first: winsock2.h before anything pulls in windows.h's winsock.h
#include <d3d11.h>
#include <shellapi.h>
#include <shlobj.h>
#include <shobjidl.h>
#include <deque>
#include <fstream>
#include <memory>
#include <mutex>
#include <optional>
#include "account_link.hpp"
#include "imgui.h"
#include "imgui_impl_dx11.h"
#include "imgui_impl_win32.h"
#include "setup.hpp"
#pragma comment(lib, "d3d11.lib")
extern IMGUI_IMPL_API LRESULT ImGui_ImplWin32_WndProcHandler(HWND, UINT, WPARAM, LPARAM);
namespace {
using namespace bb;
// ---- D3D11 boilerplate (from Dear ImGui's example_win32_directx11) ----
ID3D11Device* g_device = nullptr;
ID3D11DeviceContext* g_context = nullptr;
IDXGISwapChain* g_swap = nullptr;
ID3D11RenderTargetView* g_rtv = nullptr;
UINT g_resize_w = 0, g_resize_h = 0;
bool g_close_requested = false;
void create_rtv() {
ID3D11Texture2D* back = nullptr;
g_swap->GetBuffer(0, IID_PPV_ARGS(&back));
g_device->CreateRenderTargetView(back, nullptr, &g_rtv);
back->Release();
}
void release_rtv() {
if (g_rtv) g_rtv->Release();
g_rtv = nullptr;
}
bool create_device(HWND hwnd) {
DXGI_SWAP_CHAIN_DESC sd{};
sd.BufferCount = 2;
sd.BufferDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
sd.BufferDesc.RefreshRate.Numerator = 60;
sd.BufferDesc.RefreshRate.Denominator = 1;
sd.Flags = DXGI_SWAP_CHAIN_FLAG_ALLOW_MODE_SWITCH;
sd.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;
sd.OutputWindow = hwnd;
sd.SampleDesc.Count = 1;
sd.Windowed = TRUE;
sd.SwapEffect = DXGI_SWAP_EFFECT_DISCARD;
const D3D_FEATURE_LEVEL levels[] = {D3D_FEATURE_LEVEL_11_0, D3D_FEATURE_LEVEL_10_0};
D3D_FEATURE_LEVEL got;
HRESULT hr = D3D11CreateDeviceAndSwapChain(nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, 0, levels, 2,
D3D11_SDK_VERSION, &sd, &g_swap, &g_device, &got, &g_context);
if (hr == DXGI_ERROR_UNSUPPORTED) // no GPU driver: the software rasterizer
hr = D3D11CreateDeviceAndSwapChain(nullptr, D3D_DRIVER_TYPE_WARP, nullptr, 0, levels, 2, D3D11_SDK_VERSION,
&sd, &g_swap, &g_device, &got, &g_context);
if (FAILED(hr)) return false;
create_rtv();
return true;
}
void destroy_device() {
release_rtv();
if (g_swap) g_swap->Release();
if (g_context) g_context->Release();
if (g_device) g_device->Release();
}
LRESULT WINAPI wnd_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
if (ImGui_ImplWin32_WndProcHandler(hwnd, msg, wp, lp)) return TRUE;
switch (msg) {
case WM_SIZE:
if (wp != SIZE_MINIMIZED) {
g_resize_w = LOWORD(lp);
g_resize_h = HIWORD(lp);
}
return 0;
case WM_SYSCOMMAND:
if ((wp & 0xfff0) == SC_KEYMENU) return 0;
break;
case WM_CLOSE:
g_close_requested = true; // the app decides (the game may still be running)
return 0;
case WM_DESTROY:
PostQuitMessage(0);
return 0;
}
return DefWindowProcW(hwnd, msg, wp, lp);
}
// ---- the app ----
fs::path exe_dir() {
wchar_t buf[MAX_PATH];
GetModuleFileNameW(nullptr, buf, MAX_PATH);
return fs::path(buf).parent_path();
}
// "" when started, else why not
std::string launch(const fs::path& exe, const fs::path& cwd) {
std::error_code ec;
if (!fs::is_regular_file(exe, ec)) return narrow(exe.wstring()) + " is missing.";
STARTUPINFOW si{};
si.cb = sizeof(si);
PROCESS_INFORMATION pi{};
std::wstring cmd = L"\"" + exe.wstring() + L"\"";
if (!CreateProcessW(exe.c_str(), cmd.data(), nullptr, nullptr, FALSE, 0, nullptr, cwd.c_str(), &si, &pi)) {
const DWORD err = GetLastError();
wchar_t* text = nullptr;
FormatMessageW(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
nullptr, err, 0, reinterpret_cast<wchar_t*>(&text), 0, nullptr);
std::string why = text ? narrow(text) : fmt("error %lu", err);
if (text) LocalFree(text);
while (!why.empty() && (why.back() == '\n' || why.back() == '\r' || why.back() == '.')) why.pop_back();
return "Could not start " + narrow(exe.wstring()) + ": " + why + ".";
}
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
return "";
}
// A two-way switch: "left" label, the switch, "right" label; *right is the
// state. The switch and either label change it. True when changed.
bool network_switch(const char* id, const char* left, const char* right, bool* on) {
const ImVec4 lit(0.95f, 0.95f, 0.95f, 1), dim(0.5f, 0.5f, 0.5f, 1);
const ImVec4 left_track(0.22f, 0.42f, 0.62f, 1), right_track(0.62f, 0.16f, 0.16f, 1);
bool changed = false;
ImGui::PushID(id);
ImGui::AlignTextToFramePadding();
ImGui::TextColored(*on ? dim : lit, "%s", left);
if (ImGui::IsItemClicked() && *on) *on = false, changed = true;
ImGui::SameLine();
const float h = ImGui::GetFrameHeight(), w = h * 2.1f;
const ImVec2 p = ImGui::GetCursorScreenPos();
if (ImGui::InvisibleButton("switch", ImVec2(w, h))) *on = !*on, changed = true;
const bool hovered = ImGui::IsItemHovered();
// slide the knob over ~0.12 s
ImGuiStorage* store = ImGui::GetStateStorage();
const ImGuiID key = ImGui::GetID("t");
float t = store->GetFloat(key, *on ? 1.0f : 0.0f);
const float step = ImGui::GetIO().DeltaTime / 0.12f;
t = *on ? (t + step > 1 ? 1 : t + step) : (t - step < 0 ? 0 : t - step);
store->SetFloat(key, t);
ImVec4 track(left_track.x + (right_track.x - left_track.x) * t, left_track.y + (right_track.y - left_track.y) * t,
left_track.z + (right_track.z - left_track.z) * t, 1);
if (hovered) track = ImVec4(track.x * 1.15f, track.y * 1.15f, track.z * 1.15f, 1);
ImDrawList* dl = ImGui::GetWindowDrawList();
dl->AddRectFilled(p, ImVec2(p.x + w, p.y + h), ImGui::GetColorU32(track), h * 0.5f);
const float r = h * 0.5f - 3;
dl->AddCircleFilled(ImVec2(p.x + r + 3 + t * (w - 2 * r - 6), p.y + h * 0.5f), r, ImGui::GetColorU32(lit));
ImGui::SameLine();
ImGui::TextColored(*on ? lit : dim, "%s", right);
if (ImGui::IsItemClicked() && !*on) *on = true, changed = true;
ImGui::PopID();
return changed;
}
std::optional<fs::path> pick_folder(HWND owner) {
std::optional<fs::path> out;
IFileOpenDialog* dlg = nullptr;
if (FAILED(CoCreateInstance(CLSID_FileOpenDialog, nullptr, CLSCTX_ALL, IID_PPV_ARGS(&dlg)))) return out;
DWORD opts = 0;
dlg->GetOptions(&opts);
dlg->SetOptions(opts | FOS_PICKFOLDERS);
dlg->SetTitle(L"bbport folder (the one with Bloodborne.exe)");
if (SUCCEEDED(dlg->Show(owner))) {
IShellItem* item = nullptr;
if (SUCCEEDED(dlg->GetResult(&item))) {
PWSTR path = nullptr;
if (SUCCEEDED(item->GetDisplayName(SIGDN_FILESYSPATH, &path))) {
out = fs::path(path);
CoTaskMemFree(path);
}
item->Release();
}
}
dlg->Release();
return out;
}
class App {
public:
explicit App(HWND hwnd) : hwnd_(hwnd), dir_(exe_dir()) {
set_log_file((dir_ / L"bbbridge.log").wstring());
set_log_sink([this](const std::string& line) {
std::lock_guard<std::mutex> lk(log_mu_);
lines_.push_back(line);
while (lines_.size() > 3000) lines_.pop_front();
scroll_ = true;
});
load_config();
reload_setup();
log(fmt("bbbridge %s - bbport: %s (%s)", kVersion, bbport_dir_.empty() ? "-" : narrow(bbport_dir_.wstring()).c_str(),
describe(bbport_source_).c_str()));
if (!setup_error_.empty()) log(setup_error_);
check_folder_on_start();
if (setup_ && setup_->routed()) start_bridge(); // routed already: be ready when the game starts
}
~App() {
stop_bridge(); // and the game's server traffic goes back to bbport's own addresses
}
void frame();
bool wants_close() {
if (!g_close_requested) return false;
if (game_running_ && bridge_) {
confirm_close_ = true;
g_close_requested = false;
return false;
}
return true;
}
private:
void load_config();
void save_config();
void try_setup();
void reload_setup();
void refresh();
void check_folder_on_start();
void choose_folder();
void start_bridge();
void stop_bridge();
void play();
void open_launcher();
void fail(const std::string& message);
void status_panel();
void setup_panel();
void account_row();
void account_popups();
void message_popups();
void log_panel();
void use_npid(const std::string& npid);
void apply_defaults();
void sync_overrides();
bool live_map_wanted() const { return jint(config_, "live_map", 1) != 0; }
bool route_wanted() const { return jint(config_, "route", 1) != 0; } // the box, as last ticked; bbhost by default
bool signed_in() const { return !account_.empty() && !account_kind(state_path(), account_).empty(); }
std::wstring state_path() const { return (dir_ / L"bbbridge_state.json").wstring(); }
std::string auth_server() const { return jstr(config_, "auth_server", "https://thehuntersdream.com"); }
HWND hwnd_;
fs::path dir_;
json config_ = json::object();
std::unique_ptr<Bridge> bridge_;
std::optional<BbportSetup> setup_;
std::string setup_error_;
fs::path chosen_bbport_;
fs::path bbport_dir_; // known even when setup_ is not (no launcher settings yet)
BbportSource bbport_source_ = BbportSource::None;
// The Hunter's Dream account (bbbridge_config.json "account"). It is bbport's
// online id only while routed: unrouted, bbport keeps its own shadNet account.
std::string account_;
bool launcher_running_ = false, game_running_ = false, routed_ = false, patched_ = false;
double last_refresh_ = 0;
std::string error_, folder_question_;
bool open_error_ = false, open_folder_ = false;
bool confirm_close_ = false;
std::unique_ptr<DiscordLink> link_;
bool open_link_ = false, open_web_ = false;
std::string web_code_, web_error_;
std::mutex log_mu_;
std::deque<std::string> lines_;
bool scroll_ = false;
};
void App::load_config() {
std::ifstream in(dir_ / L"bbbridge_config.json");
if (in) {
json j = json::parse(in, nullptr, false);
if (j.is_object()) config_ = j;
}
if (jhas(config_, "bbport_dir")) chosen_bbport_ = fs::path(widen(jstr(config_, "bbport_dir")));
account_ = jstr(config_, "account");
}
void App::save_config() {
std::ofstream(dir_ / L"bbbridge_config.json", std::ios::trunc) << config_.dump(2) << "\n";
}
void App::try_setup() {
try {
setup_.emplace(dir_, chosen_bbport_);
setup_error_.clear();
bbport_dir_ = setup_->bbport;
bbport_source_ = setup_->source;
// older bbbridge kept the account only as bbport's online id
if (account_.empty() && !account_kind(state_path(), setup_->npid()).empty()) {
account_ = setup_->npid();
config_["account"] = account_;
save_config();
}
} catch (const std::exception& e) {
setup_.reset();
setup_error_ = e.what();
bbport_dir_ = find_bbport(dir_, chosen_bbport_, "", &bbport_source_); // still lets "bbport settings..." work
}
}
void App::reload_setup() {
try_setup();
refresh();
}
// The first look: say where bbport is unless bbbridge sits inside it (the
// normal install), and ask when it had to guess or a chosen folder is gone.
void App::check_folder_on_start() {
const std::string here = narrow(bbport_dir_.wstring());
if (bbport_source_ == BbportSource::None) {
folder_question_ = "bbport was not found.\n\nPut the bbbridge folder inside bbport's folder (the one with "
"Bloodborne.exe), e.g. bloodborne\\bbbridge\\bbbridge.exe, or choose the bbport folder.";
} else if (!chosen_bbport_.empty() && bbport_source_ != BbportSource::Chosen) {
folder_question_ = "The bbport folder you chose before has no Bloodborne.exe any more:\n " +
narrow(chosen_bbport_.wstring()) + "\n\nbbbridge found bbport here instead (" +
describe(bbport_source_) + "):\n " + here + "\n\nUse this folder?";
} else if (bbport_source_ == BbportSource::Launcher) {
folder_question_ = "bbbridge is not inside a bbport folder. The bbport launcher points to this one:\n " + here +
"\n\nUse this folder?";
} else {
return;
}
open_folder_ = true;
}
void App::choose_folder() {
auto p = pick_folder(hwnd_);
if (!p) return;
if (!is_bbport_dir(*p)) {
fail(narrow(p->wstring()) + " is not a bbport folder: it has no Bloodborne.exe or Play Bloodborne.exe.\n\n"
"Choose the folder that has Bloodborne.exe in it.");
return;
}
chosen_bbport_ = *p;
config_["bbport_dir"] = narrow(p->wstring());
save_config();
reload_setup();
log("bbport folder: " + narrow(p->wstring()));
if (!setup_error_.empty()) fail(setup_error_);
}
void App::fail(const std::string& message) {
log("error: " + message);
error_ = message;
open_error_ = true;
}
void App::refresh() {
launcher_running_ = launcher_running();
game_running_ = game_running();
if (!setup_ && !launcher_running_ && bbport_source_ != BbportSource::None) try_setup(); // e.g. the launcher just made its settings
if (setup_) {
try {
if (!launcher_running_) setup_->reload(); // the launcher may have saved new settings
} catch (...) {
}
routed_ = setup_->routed();
patched_ = setup_->patched();
if (!jint(config_, "defaults_applied", 0) && !launcher_running_ && !game_running_ && signed_in())
apply_defaults();
sync_overrides();
}
last_refresh_ = now();
}
// The game's server traffic goes to bbbridge (host_overrides.custom.json) exactly
// while bbport is switched to The Hunter's Dream and the bridge runs: with
// bbbridge closed, or bbport set back to shadNet in its own launcher, the file
// would leave the game unable to reach its server.
void App::sync_overrides() {
if (!setup_) return;
const bool want = setup_->routed() && bridge_; // live: PLAY routes and starts the bridge in one go
if (want == setup_->overrides_written()) return;
try {
setup_->set_overrides(want);
log(want ? "the game's server traffic goes through bbbridge"
: "the game's server traffic goes to bbport's own addresses again");
} catch (const std::exception& e) {
log(std::string("host_overrides.custom.json: ") + e.what());
}
}
// Once, when there is an account to play with: route bbport through bbbridge
// and add the live map patch, so starting from the bbport launcher works too
// and nobody has to remember them. Switching them off later sticks.
void App::apply_defaults() {
try {
if (!routed_) setup_->enable();
if (setup_->npid() != account_) setup_->set_npid(account_);
if (live_map_wanted() && !patched_) setup_->set_patch(true);
} catch (const std::exception& e) {
fail(std::string("Could not set bbport up: ") + e.what());
return;
}
config_["defaults_applied"] = true;
config_["route"] = true;
save_config();
routed_ = setup_->routed();
patched_ = setup_->patched();
log("bbport now uses bbbridge" + std::string(patched_ ? ", with the live map patch" : ""));
if (routed_) start_bridge();
}
void App::start_bridge() {
if (bridge_) return;
Config cfg = Config::from_json(config_);
cfg.state_file = narrow((dir_ / L"bbbridge_state.json").wstring()); // the account, next to the exe
auto b = std::make_unique<Bridge>(cfg);
std::string error;
if (!b->start(&error)) {
fail("The bridge could not start: " + error);
return;
}
bridge_ = std::move(b);
sync_overrides();
}
void App::stop_bridge() {
if (!bridge_) return;
bridge_->stop();
bridge_.reset();
log("bridge stopped");
sync_overrides();
}
void App::play() {
refresh();
if (!setup_) {
fail(setup_error_);
return;
}
const bool via_bridge = route_wanted();
if (via_bridge && !signed_in()) {
fail("Not signed in. Press 'Sign in with Discord...' first - The Hunter's Dream needs your own account.\n\n"
"(To play in bbport's own shadNet pool instead, move the Online switch to shadNet.)");
return;
}
if (launcher_running_) {
fail("Close the bbport launcher first - it would undo bbbridge's settings when it saves.");
return;
}
if (!setup_->game_ready()) {
fail("bbport has no game folder yet. Press 'bbport settings...', choose your Bloodborne folder "
"(CUSA03173, the one with eboot.bin), close the launcher, then press PLAY.");
return;
}
try {
if (via_bridge) {
if (!routed_) setup_->enable();
if (setup_->npid() != account_) setup_->set_npid(account_);
if (live_map_wanted() && !patched_) setup_->set_patch(true);
} else if (routed_) {
setup_->disable(); // e.g. the launcher was open when the switch moved
}
} catch (const std::exception& e) {
fail(std::string("Could not set bbport up: ") + e.what());
return;
}
if (!via_bridge && setup_->online() && !setup_->shadnet_account_set()) {
fail("bbport's online play is on but it has no shadNet account.\n\nRegister one at "
"https://shadnet.shadps4.net, then press 'bbport settings...' > Online co-op and enter the account "
"name (not the email) and password. Or move the Online switch to The Hunter's Dream "
"to play with bbhost players using your Discord sign-in.");
return;
}
if (via_bridge) {
start_bridge();
if (!bridge_) return;
}
if (const std::string err = launch(setup_->play_exe(), setup_->bbport); !err.empty()) {
fail(err);
return;
}
log(fmt("starting the game (%s): %s", via_bridge ? "bbhost pool through bbbridge" : "bbport's own shadNet",
narrow(setup_->play_exe().wstring()).c_str()));
refresh();
}
void App::open_launcher() {
refresh();
if (bbport_dir_.empty()) {
fail("bbport was not found. Press 'Choose bbport folder...' and pick the folder with Bloodborne.exe.");
return;
}
if (launcher_running_) {
fail("The bbport launcher is already open.");
return;
}
if (game_running_) {
fail("The game is running - close it before changing bbport's settings.");
return;
}
if (const std::string err = launch(bbport_dir_ / L"Bloodborne.exe", bbport_dir_); !err.empty()) fail(err);
}
void App::status_panel() {
const BridgeStatus st = bridge_ ? bridge_->status() : BridgeStatus{};
const ImVec4 green(0.35f, 0.85f, 0.45f, 1), grey(0.6f, 0.6f, 0.6f, 1), amber(0.95f, 0.75f, 0.3f, 1);
ImGui::TextColored(bridge_ ? green : grey, bridge_ ? " Bridge running" : " Bridge stopped");
if (bridge_) {
ImGui::SameLine();
if (st.relay_port)
ImGui::TextDisabled(" relay port %d%s", st.relay_port,
st.mapped ? (" - seen as " + st.mapped->str()).c_str() : "");
else
ImGui::TextDisabled(" waiting for The Hunter's Dream's STUN answer");
}
if (bridge_) {
if (st.logged_in) ImGui::Text("Signed in as %s", st.npid.c_str());
else if (!st.npid.empty()) ImGui::TextColored(amber, "Signing in as %s ...", st.npid.c_str());
else ImGui::TextDisabled("Waiting for the game to connect");
if (st.room_id) ImGui::Text("Room %llu - %d member(s)", static_cast<unsigned long long>(st.room_id), st.room_members);
if (!st.peers.empty() && ImGui::BeginTable("peers", 4, ImGuiTableFlags_BordersInnerH | ImGuiTableFlags_SizingStretchProp)) {
ImGui::TableSetupColumn("Player");
ImGui::TableSetupColumn("Member");
ImGui::TableSetupColumn("Packets out");
ImGui::TableSetupColumn("Packets in");
ImGui::TableHeadersRow();
for (const auto& p : st.peers) {
ImGui::TableNextRow();
ImGui::TableNextColumn();
ImGui::TextUnformatted(p.online_id.c_str());
ImGui::TableNextColumn();
ImGui::Text("%d", p.member_id);
ImGui::TableNextColumn();
ImGui::Text("%llu", static_cast<unsigned long long>(p.to_peer));
ImGui::TableNextColumn();
ImGui::Text("%llu", static_cast<unsigned long long>(p.to_port));
}
ImGui::EndTable();
}
}
}
void App::setup_panel() {
ImGui::SeparatorText("Setup");
if (!bbport_dir_.empty()) {
ImGui::Text("bbport:");
ImGui::SameLine();
ImGui::TextUnformatted(narrow(bbport_dir_.wstring()).c_str());
ImGui::SameLine();
ImGui::TextDisabled("(%s)", describe(bbport_source_).c_str());
ImGui::SameLine();
if (ImGui::SmallButton("Change...")) choose_folder();
}
if (!setup_) {
ImGui::PushTextWrapPos(0);
ImGui::TextColored(ImVec4(0.95f, 0.5f, 0.4f, 1), "%s", setup_error_.c_str());
ImGui::PopTextWrapPos();
if (bbport_dir_.empty() && ImGui::Button("Choose bbport folder...")) choose_folder();
return;
}
if (!setup_->game_ready())
ImGui::TextColored(ImVec4(0.95f, 0.75f, 0.3f, 1),
"Choose your game folder (CUSA03173, with eboot.bin) in the bbport launcher first.");
account_row();
ImGui::BeginDisabled(launcher_running_);
bool routed = routed_;
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted("Online:");
ImGui::SameLine();
if (network_switch("network", "shadNet", "The Hunter's Dream (bbhost)", &routed)) {
try {
if (routed) {
setup_->enable();
if (signed_in() && setup_->npid() != account_) setup_->set_npid(account_);
start_bridge(); // the game's server traffic needs it running
} else {
setup_->disable(); // bbport's own shadNet server and account come back
}
config_["route"] = routed;
save_config();
log(routed ? "bbport now uses bbbridge" : "bbport back on its own shadNet server and account");
} catch (const std::exception& e) {
fail(e.what());
}
refresh();
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Right: bbbridge connects bbport to The Hunter's Dream (bbhost players, Discord sign-in).\n"
"Left: bbport's own shadNet server and account, without bbbridge.");
ImGui::Indent();
if (routed_)
ImGui::TextDisabled(signed_in() ? "Co-op with bbhost players through bbbridge, as '%s'."
: "Co-op with bbhost players through bbbridge - sign in with Discord first.",
account_.c_str());
else if (!setup_->online())
ImGui::TextDisabled("bbport's own settings - online play is off there.");
else if (setup_->shadnet_account_set())
ImGui::TextDisabled("bbport's own shadNet pool (%s), as '%s'.", setup_->server().c_str(),
setup_->npid().c_str());
else
ImGui::TextColored(ImVec4(0.95f, 0.75f, 0.3f, 1),
"shadNet needs a shadNet account (shadnet.shadps4.net), entered in\n"
"bbport settings > Online co-op.");
ImGui::Unindent();
bool patched = patched_;
if (ImGui::Checkbox("Live map patch (show me on thehuntersdream.com's map)", &patched)) {
setup_->set_patch(patched);
config_["live_map"] = patched; // PLAY keeps to it
save_config();
refresh();
}
ImGui::EndDisabled();
if (launcher_running_)
ImGui::TextColored(ImVec4(0.95f, 0.75f, 0.3f, 1), "The bbport launcher is open - close it to change these.");
bool trace = jint(config_, "trace", 0) != 0;
if (ImGui::Checkbox("Detailed log (for bug reports; applies when the bridge restarts)", &trace)) {
config_["trace"] = trace;
save_config();
}
}
void App::use_npid(const std::string& npid) {
account_ = npid;
config_["account"] = npid;
save_config();
// bbport's online id changes only while it plays through bbbridge (PLAY sets it too)
if (!setup_ || !routed_ || launcher_running_) return;
try {
setup_->set_npid(npid);
log(fmt("online id set to '%s'", npid.c_str()));
} catch (const std::exception& e) {
fail(e.what());
}
}
void App::account_row() {
// Everyone signs in with their own Discord account on The Hunter's Dream;
// the account's name is bbport's online id (NPID) while routed.
const std::string kind = account_.empty() ? "" : account_kind(state_path(), account_);
if (!kind.empty()) {
ImGui::TextColored(ImVec4(0.55f, 0.65f, 1.0f, 1), "Signed in as '%s'%s", account_.c_str(),
kind == "discord" ? " (Discord)" : " (bbbridge account)");
ImGui::BeginDisabled(launcher_running_);
if (ImGui::Button("Website sign-in code")) {
web_code_ = website_code(auth_server(), state_path(), account_, web_error_);
open_web_ = true;
}
if (ImGui::IsItemHovered())
ImGui::SetTooltip("A code for thehuntersdream.com/account, 'Sign in with a code from the game'.");
ImGui::SameLine();
if (ImGui::Button("Switch account...")) {
link_ = std::make_unique<DiscordLink>(auth_server(), state_path());
link_->start();
open_link_ = true;
}
ImGui::SameLine();
if (ImGui::Button("Sign out")) {
const std::string err = account_sign_out(auth_server(), state_path(), account_);
if (!err.empty()) {
fail("Sign out failed: " + err);
} else {
account_.clear();
config_.erase("account");
save_config();
}
}
ImGui::EndDisabled();
return;
}
ImGui::TextColored(ImVec4(0.95f, 0.75f, 0.3f, 1), "Not signed in. Sign in with your Discord account to play online.");
ImGui::BeginDisabled(launcher_running_);
if (ImGui::Button("Sign in with Discord...", ImVec2(220, 0))) {
link_ = std::make_unique<DiscordLink>(auth_server(), state_path());
link_->start();
open_link_ = true;
}
ImGui::EndDisabled();
ImGui::SameLine();
ImGui::TextDisabled("opens The Hunter's Dream: sign in there with Discord and approve the code");
}
void App::account_popups() {
if (open_link_) {
ImGui::OpenPopup("Sign in with Discord");
open_link_ = false;
}
if (ImGui::BeginPopupModal("Sign in with Discord", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
const DiscordLink::State st = link_ ? link_->state() : DiscordLink::State::Idle;
if (st == DiscordLink::State::Starting) {
ImGui::TextUnformatted("Asking The Hunter's Dream for a code ...");
} else if (st == DiscordLink::State::Waiting) {
ImGui::TextUnformatted("1. Open the sign-in page and sign in with Discord.");
ImGui::TextUnformatted("2. Enter this code there:");
ImGui::SetWindowFontScale(2.2f);
ImGui::TextColored(ImVec4(0.95f, 0.85f, 0.5f, 1), " %s", link_->user_code().c_str());
ImGui::SetWindowFontScale(1.0f);
ImGui::TextDisabled("%s", link_->uri().c_str());
if (ImGui::Button("Open sign-in page"))
ShellExecuteW(nullptr, L"open", widen(link_->uri()).c_str(), nullptr, nullptr, SW_SHOWNORMAL);
ImGui::SameLine();
if (ImGui::Button("Copy code")) ImGui::SetClipboardText(link_->user_code().c_str());
ImGui::TextDisabled("Waiting for the approval ... (%d s left)", static_cast<int>(link_->seconds_left()));
} else if (st == DiscordLink::State::Approved) {
ImGui::Text("Signed in as '%s'.", link_->online_id().c_str());
ImGui::TextUnformatted("bbport will use this account as its online id.");
} else if (st == DiscordLink::State::Failed) {
ImGui::TextColored(ImVec4(0.95f, 0.5f, 0.4f, 1), "Sign-in failed: %s", link_->message().c_str());
}
ImGui::Separator();
if (st == DiscordLink::State::Approved) {
if (ImGui::Button("Done")) {
use_npid(link_->online_id());
link_.reset();
ImGui::CloseCurrentPopup();
}
} else {
if (st == DiscordLink::State::Failed && ImGui::Button("Try again")) link_->start();
if (st == DiscordLink::State::Failed) ImGui::SameLine();
if (ImGui::Button("Cancel")) {
link_.reset();
ImGui::CloseCurrentPopup();
}
}
ImGui::EndPopup();
}
if (open_web_) {
ImGui::OpenPopup("Website sign-in code");
open_web_ = false;
}
if (ImGui::BeginPopupModal("Website sign-in code", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
if (web_code_.empty()) {
ImGui::TextColored(ImVec4(0.95f, 0.5f, 0.4f, 1), "No code: %s", web_error_.c_str());
} else {
ImGui::Text("For thehuntersdream.com/account, 'Sign in with a code from the game' (account '%s'):",
account_.c_str());
ImGui::SetWindowFontScale(2.2f);
ImGui::TextColored(ImVec4(0.95f, 0.85f, 0.5f, 1), " %s", web_code_.c_str());
ImGui::SetWindowFontScale(1.0f);
ImGui::TextDisabled("Valid for a few minutes.");
if (ImGui::Button("Copy code")) ImGui::SetClipboardText(web_code_.c_str());
ImGui::SameLine();
if (ImGui::Button("Open the account page"))
ShellExecuteW(nullptr, L"open", widen(auth_server() + "/account").c_str(), nullptr, nullptr, SW_SHOWNORMAL);
}
ImGui::Separator();
if (ImGui::Button("Close")) ImGui::CloseCurrentPopup();
ImGui::EndPopup();
}
}
void App::message_popups() {
const float wrap = ImGui::GetFontSize() * 34;
if (open_folder_) {
ImGui::OpenPopup("bbport folder");
open_folder_ = false;
}
if (ImGui::BeginPopupModal("bbport folder", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
ImGui::PushTextWrapPos(wrap);
ImGui::TextUnformatted(folder_question_.c_str());
ImGui::PopTextWrapPos();
ImGui::Separator();
if (!bbport_dir_.empty() && ImGui::Button("Use this folder")) {
if (bbport_source_ == BbportSource::ExeFolder) { // the normal place: nothing to remember
chosen_bbport_.clear();
config_.erase("bbport_dir");
} else {
chosen_bbport_ = bbport_dir_;
config_["bbport_dir"] = narrow(bbport_dir_.wstring());
}
save_config();
reload_setup();
ImGui::CloseCurrentPopup();
}
if (!bbport_dir_.empty()) ImGui::SameLine();
if (ImGui::Button("Choose another folder...")) {
ImGui::CloseCurrentPopup();
choose_folder();
}
ImGui::SameLine();
if (ImGui::Button("Close")) ImGui::CloseCurrentPopup();
ImGui::EndPopup();
}
if (open_error_) {
ImGui::OpenPopup("bbbridge###error");
open_error_ = false;
}
if (ImGui::BeginPopupModal("bbbridge###error", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
ImGui::PushTextWrapPos(wrap);
ImGui::TextColored(ImVec4(0.95f, 0.6f, 0.5f, 1), "%s", error_.c_str());
ImGui::PopTextWrapPos();
ImGui::Separator();
if (ImGui::Button("OK", ImVec2(100, 0)) || ImGui::IsKeyPressed(ImGuiKey_Enter)) ImGui::CloseCurrentPopup();
ImGui::EndPopup();
}
}
void App::log_panel() {
ImGui::SeparatorText("Log");
if (ImGui::SmallButton("Copy log")) {
std::string all;
{
std::lock_guard<std::mutex> lk(log_mu_);
for (const auto& l : lines_) all += l + "\n";
}
ImGui::SetClipboardText(all.c_str());
}
ImGui::SameLine();
if (ImGui::SmallButton("Open folder")) ShellExecuteW(nullptr, L"open", dir_.c_str(), nullptr, nullptr, SW_SHOWNORMAL);
ImGui::BeginChild("log", ImVec2(0, 0), ImGuiChildFlags_Borders, ImGuiWindowFlags_HorizontalScrollbar);
{
std::lock_guard<std::mutex> lk(log_mu_);
ImGuiListClipper clip;
clip.Begin(static_cast<int>(lines_.size()));
while (clip.Step())
for (int i = clip.DisplayStart; i < clip.DisplayEnd; ++i) ImGui::TextUnformatted(lines_[i].c_str());
if (scroll_ && ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 40) ImGui::SetScrollHereY(1.0f);
scroll_ = false;
}
ImGui::EndChild();
}
void App::frame() {
if (now() - last_refresh_ > 1.5) refresh();
const ImGuiViewport* vp = ImGui::GetMainViewport();
ImGui::SetNextWindowPos(vp->WorkPos);
ImGui::SetNextWindowSize(vp->WorkSize);
ImGui::Begin("bbbridge", nullptr,
ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings |
ImGuiWindowFlags_NoBringToFrontOnFocus);
ImGui::Text("bbbridge %s", kVersion);
ImGui::SameLine();
ImGui::TextDisabled("- co-op with bbhost players on The Hunter's Dream");
ImGui::Spacing();
status_panel();
ImGui::Spacing();
setup_panel();
ImGui::Spacing();
// PLAY and bbport settings stay clickable: when something is missing they say what.
ImGui::BeginDisabled(game_running_);
ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.55f, 0.12f, 0.12f, 1));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.16f, 0.16f, 1));
if (ImGui::Button(game_running_ ? " PLAYING " : " PLAY ", ImVec2(160, 40))) play();
ImGui::PopStyleColor(2);
ImGui::EndDisabled();
ImGui::SameLine();
if (ImGui::Button(bridge_ ? "Stop bridge" : "Start bridge", ImVec2(130, 40))) {
if (bridge_) stop_bridge();
else start_bridge();
}
ImGui::SameLine();
if (ImGui::Button("bbport settings...", ImVec2(160, 40))) open_launcher();
if (ImGui::IsItemHovered())
ImGui::SetTooltip("Opens the bbport launcher for graphics, mods and cheats. Close it before pressing PLAY here.");
ImGui::Spacing();
log_panel();
account_popups();
message_popups();
if (confirm_close_) ImGui::OpenPopup("Close bbbridge?");
if (ImGui::BeginPopupModal("Close bbbridge?", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) {
ImGui::TextUnformatted("The game is still running. Closing bbbridge takes it offline.");
if (ImGui::Button("Close anyway")) {
confirm_close_ = false;
g_close_requested = true;
game_running_ = false;
ImGui::CloseCurrentPopup();
}
ImGui::SameLine();
if (ImGui::Button("Cancel")) {
confirm_close_ = false;
ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
}
ImGui::End();
}
} // namespace
int WINAPI wWinMain(HINSTANCE inst, HINSTANCE, PWSTR, int) {
// One bbbridge at a time: a second would fight the first over the ports.
HANDLE single = CreateMutexW(nullptr, TRUE, L"Local\\bbbridge-app");
if (GetLastError() == ERROR_ALREADY_EXISTS) {
if (HWND other = FindWindowW(L"bbbridge", nullptr)) SetForegroundWindow(other);
return 0;
}
CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
net_init();
ImGui_ImplWin32_EnableDpiAwareness();
WNDCLASSEXW wc{sizeof(wc), CS_CLASSDC, wnd_proc, 0, 0, inst, LoadIconW(inst, MAKEINTRESOURCEW(1)), nullptr,
nullptr, nullptr, L"bbbridge", nullptr};
RegisterClassExW(&wc);
const float scale = ImGui_ImplWin32_GetDpiScaleForMonitor(MonitorFromPoint(POINT{0, 0}, MONITOR_DEFAULTTOPRIMARY));
HWND hwnd = CreateWindowW(wc.lpszClassName, L"bbbridge", WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT,
static_cast<int>(820 * scale), static_cast<int>(760 * scale), nullptr, nullptr, inst, nullptr);
if (!create_device(hwnd)) {
MessageBoxW(nullptr, L"Direct3D 11 is not available.", L"bbbridge", MB_ICONERROR);
return 1;
}
ShowWindow(hwnd, SW_SHOWDEFAULT);
UpdateWindow(hwnd);
IMGUI_CHECKVERSION();
ImGui::CreateContext();
ImGuiIO& io = ImGui::GetIO();
io.IniFilename = nullptr;
ImGui::StyleColorsDark();
ImGuiStyle& style = ImGui::GetStyle();
style.WindowPadding = ImVec2(14, 12);
style.FrameRounding = 4;
style.ScaleAllSizes(scale);
static const ImWchar ranges[] = {0x0020, 0x00FF, 0x0100, 0x017F, 0x2013, 0x2026, 0}; // Latin-1 + Ext-A
if (GetFileAttributesW(L"C:\\Windows\\Fonts\\segoeui.ttf") != INVALID_FILE_ATTRIBUTES)
io.Fonts->AddFontFromFileTTF("C:\\Windows\\Fonts\\segoeui.ttf", 17.0f * scale, nullptr, ranges);
ImGui_ImplWin32_Init(hwnd);
ImGui_ImplDX11_Init(g_device, g_context);
{
App app(hwnd);
bool done = false;
double last_input = now();
while (!done) {
// Full rate (vsync) only while the player is using the window; idle
// ~10 frames a second (the log and status still move), minimized 4 -
// the game is the one that should have the CPU and GPU.
const bool minimized = IsIconic(hwnd) != 0;
if (now() - last_input > 0.5)
MsgWaitForMultipleObjects(0, nullptr, FALSE, minimized ? 250 : 100, QS_ALLINPUT);
MSG msg;
while (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE)) {
TranslateMessage(&msg);
DispatchMessageW(&msg);
if (msg.message == WM_QUIT) done = true;
if (!minimized && msg.message != WM_TIMER && msg.message != WM_PAINT) last_input = now();
}
if (done) break;
if (app.wants_close()) {
DestroyWindow(hwnd);
continue;
}
if (g_resize_w) {
release_rtv();
g_swap->ResizeBuffers(0, g_resize_w, g_resize_h, DXGI_FORMAT_UNKNOWN, 0);
g_resize_w = g_resize_h = 0;
create_rtv();
}
ImGui_ImplDX11_NewFrame();
ImGui_ImplWin32_NewFrame();
ImGui::NewFrame();
app.frame();
ImGui::Render();
if (minimized) continue; // the app still runs (status, close); nothing to show
const float clear[4] = {0.07f, 0.07f, 0.08f, 1};
g_context->OMSetRenderTargets(1, &g_rtv, nullptr);
g_context->ClearRenderTargetView(g_rtv, clear);
ImGui_ImplDX11_RenderDrawData(ImGui::GetDrawData());
g_swap->Present(1, 0);
}
} // the App stops the bridge here
ImGui_ImplDX11_Shutdown();
ImGui_ImplWin32_Shutdown();
ImGui::DestroyContext();
destroy_device();
UnregisterClassW(wc.lpszClassName, wc.hInstance);
CoUninitialize();
if (single) CloseHandle(single);
return 0;
}
+26
View File
@@ -0,0 +1,26 @@
#include <winver.h>
VS_VERSION_INFO VERSIONINFO
FILEVERSION 0,3,2,0
PRODUCTVERSION 0,3,2,0
FILEFLAGSMASK 0x3fL
FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP
BEGIN
BLOCK "StringFileInfo"
BEGIN
BLOCK "040904b0"
BEGIN
VALUE "FileDescription", "bbbridge - co-op with bbhost players for bbport"
VALUE "FileVersion", "0.3.2"
VALUE "ProductName", "bbbridge"
VALUE "ProductVersion", "0.3.2"
VALUE "LegalCopyright", "GPL-3.0-or-later"
VALUE "OriginalFilename", "bbbridge.exe"
END
END
BLOCK "VarFileInfo"
BEGIN
VALUE "Translation", 0x409, 1200
END
END
+141
View File
@@ -0,0 +1,141 @@
#include "bbhost_client.hpp"
#include "http.hpp"
namespace bb {
BBHostClient::Reply BBHostClient::post(const std::string& path, const json& body, int timeout_ms, http::Pool* pool) {
http::ClientRequest req;
req.method = "POST";
req.url = base_ + path;
req.body = body.dump();
req.timeout_ms = timeout_ms;
req.headers = {{"Content-Type", "application/json"}, {"Accept", "application/json"}, {"User-Agent", user_agent}};
const std::string tok = token();
if (!tok.empty()) req.headers.emplace_back("X-BB-Token", tok);
if (!ruleset.empty()) req.headers.emplace_back("X-BBHost-Ruleset", ruleset);
http::ClientResponse res = pool ? pool->send(req) : http::send(req);
if (!res.ok()) throw BridgeError(path + ": " + res.error);
Reply r;
r.status = res.status;
r.body = json::parse(res.body, nullptr, false);
if (r.body.is_discarded()) r.body = json::object();
return r;
}
json BBHostClient::call(const std::string& path, const json& body, int timeout_ms) {
Reply r = post(path, body, timeout_ms);
if (r.status < 200 || r.status >= 300)
throw BridgeError(fmt("%s: HTTP %d %s", path.c_str(), r.status, jstr(r.body, "Message").c_str()));
if (!r.body.is_object() || !r.body.contains("ResKind"))
throw BridgeError(fmt("%s: no account/matching service (HTTP %d)", path.c_str(), r.status));
if (jint(r.body, "ResKind", -1) != 0)
throw BridgeError(path + ": refused: " + jstr(r.body, "Message", "unknown"));
return r.body;
}
std::string BBHostClient::account_create(const std::string& name) {
json r = call("/auth/account/create", {{"Name", name}, {"DeviceName", "bbbridge"}});
set_identity(jstr(r, "Token"), jstr(r, "OnlineId", name));
return jstr(r, "RecoveryCode");
}
std::string BBHostClient::account_recover(const std::string& name, const std::string& code) {
json r = call("/auth/recover", {{"Name", name}, {"RecoveryCode", code}});
set_identity(jstr(r, "Token"), jstr(r, "OnlineId", name));
return jstr(r, "RecoveryCode");
}
std::optional<bool> BBHostClient::token_valid(const std::string& online_id) {
try {
Reply r = post("/np/signaling/resolve", {{"OnlineId", online_id}}, 6000);
if (r.status == 401) return false;
if (r.status >= 200 && r.status < 500) return true;
} catch (const BridgeError&) {
}
return std::nullopt;
}
json BBHostClient::context_start(const std::string& online_id, const Endpoint& signaling, const Endpoint& mapped) {
return call("/mp/matching2/context_start", {{"OnlineId", online_id},
{"SignalingAddr", signaling.ip},
{"SignalingPort", signaling.port},
{"MappedAddr", mapped.ip},
{"MappedPort", mapped.port}});
}
json BBHostClient::create_room(const std::string& online_id, const Endpoint& local, const Endpoint& mapped,
int max_members, const json& extra) {
json body = {{"OnlineId", online_id}, {"LocalAddr", local.ip}, {"LocalPort", local.port},
{"PublicAddr", local.ip}, {"PublicPort", local.port}, {"MappedAddr", mapped.ip},
{"MappedPort", mapped.port}, {"MaxMembers", max_members}};
if (extra.is_object())
for (auto it = extra.begin(); it != extra.end(); ++it) body[it.key()] = it.value();
return call("/mp/matching2/create_room", body);
}
json BBHostClient::join_room(const std::string& online_id, uint64_t room_id, const Endpoint& local,
const Endpoint& mapped) {
return call("/mp/matching2/join_room", {{"OnlineId", online_id}, {"RoomId", room_id},
{"LocalAddr", local.ip}, {"LocalPort", local.port},
{"PublicAddr", local.ip}, {"PublicPort", local.port},
{"MappedAddr", mapped.ip}, {"MappedPort", mapped.port}});
}
void BBHostClient::leave_room(const std::string& session_id, int member_id) {
call("/mp/matching2/leave_room", {{"SessionId", session_id}, {"MemberId", member_id}});
}
int BBHostClient::heartbeat(const std::string& session_id, int member_id) {
try {
json r = call("/mp/matching2/heartbeat", {{"SessionId", session_id}, {"MemberId", member_id}}, 4000);
return static_cast<int>(jint(r, "InRoom", 1));
} catch (const BridgeError&) {
return -1;
}
}
void BBHostClient::kick_member(const std::string& session_id, int member_id, int kicker_id) {
call("/mp/matching2/kick_member",
{{"SessionId", session_id}, {"MemberId", member_id}, {"KickerMemberId", kicker_id}, {"OptData", ""}});
}
void BBHostClient::signaling_update(const std::string& online_id, const Endpoint& mapped,
const std::string& session_id, int member_id) {
try {
call("/mp/matching2/signaling_update", {{"OnlineId", online_id}, {"MappedAddr", mapped.ip},
{"MappedPort", mapped.port}, {"SessionId", session_id},
{"MemberId", member_id}});
} catch (const BridgeError& e) {
log(std::string("signaling_update failed: ") + e.what());
}
}
std::optional<Endpoint> BBHostClient::resolve_peer(const std::string& online_id) {
try {
json r = call("/np/signaling/resolve", {{"OnlineId", online_id}});
Endpoint e{jstr(r, "Addr"), static_cast<int>(jint(r, "Port"))};
if (e.valid()) return e;
} catch (const BridgeError&) {
}
return std::nullopt;
}
uint64_t BBHostClient::poll_events(const std::string& online_id, uint64_t cursor, json& event) {
Reply r = post("/np/events/poll", {{"OnlineId", online_id}, {"Cursor", cursor}, {"Categories", {"matching2"}}},
5000, &poll_pool_);
if (r.status == 401) throw BridgeError("server refused our token (HTTP 401)");
if (r.status < 200 || r.status >= 300) throw BridgeError(fmt("/np/events/poll: HTTP %d", r.status));
event = jint(r.body, "HasEvent", 0) ? r.body : json();
return static_cast<uint64_t>(jint(r.body, "NextCursor", static_cast<int64_t>(cursor)));
}
void BBHostClient::ack_event(const std::string& online_id, uint64_t cursor) {
try {
post("/np/events/ack", {{"OnlineId", online_id}, {"Cursor", cursor}}, 4000);
} catch (const BridgeError& e) {
log(std::string("event ack failed: ") + e.what());
}
}
} // namespace bb
+75
View File
@@ -0,0 +1,75 @@
// The Hunter's Dream (bbhost server) REST client. The contract is bbhost's own
// client code (GPL-3.0): src/net/http.cpp (headers, ResKind), src/net/account.cpp,
// src/net/session.cpp, src/hle/np_matching2.cpp.
#pragma once
#include <mutex>
#include <optional>
#include <stdexcept>
#include <string>
#include "http.hpp"
#include "util.hpp"
namespace bb {
struct BridgeError : std::runtime_error {
using std::runtime_error::runtime_error;
};
class BBHostClient {
public:
explicit BBHostClient(std::string base) : base_(std::move(base)) {
while (!base_.empty() && base_.back() == '/') base_.pop_back();
}
std::string user_agent = "bbbridge";
std::string ruleset = "vanilla";
std::string token() const { std::lock_guard<std::mutex> lk(mu_); return token_; }
std::string online_id() const { std::lock_guard<std::mutex> lk(mu_); return online_id_; }
void set_identity(const std::string& token, const std::string& online_id) {
std::lock_guard<std::mutex> lk(mu_);
token_ = token;
online_id_ = online_id;
}
// account; each returns the recovery code the server sent ("" if none)
std::string account_create(const std::string& name);
std::string account_recover(const std::string& name, const std::string& code);
// true accepted, false refused (401), nullopt when it could not be asked
std::optional<bool> token_valid(const std::string& online_id);
// matching
json context_start(const std::string& online_id, const Endpoint& signaling, const Endpoint& mapped);
json create_room(const std::string& online_id, const Endpoint& local, const Endpoint& mapped, int max_members,
const json& extra);
json join_room(const std::string& online_id, uint64_t room_id, const Endpoint& local, const Endpoint& mapped);
void leave_room(const std::string& session_id, int member_id);
int heartbeat(const std::string& session_id, int member_id); // InRoom, -1 when not asked
void kick_member(const std::string& session_id, int member_id, int kicker_id);
void signaling_update(const std::string& online_id, const Endpoint& mapped, const std::string& session_id,
int member_id);
std::optional<Endpoint> resolve_peer(const std::string& online_id);
// events: returns the next cursor; `event` is null when there was none
uint64_t poll_events(const std::string& online_id, uint64_t cursor, json& event);
void ack_event(const std::string& online_id, uint64_t cursor);
private:
struct Reply {
int status = 0;
json body;
};
// throws on transport failure; pool: a connection of its own (else the shared one)
Reply post(const std::string& path, const json& body, int timeout_ms = 8000, http::Pool* pool = nullptr);
json call(const std::string& path, const json& body, int timeout_ms = 8000); // + ResKind == 0
http::Pool poll_pool_{45.0}; // the event poll, every 0.4 s: redialled before it is a minute old
std::string base_;
mutable std::mutex mu_;
std::string token_;
std::string online_id_;
};
} // namespace bb
File diff suppressed because it is too large. Load diff
+195
View File
@@ -0,0 +1,195 @@
// bbbridge core: a local shadNet server for bbport that speaks The Hunter's
// Dream's REST API (bbhost's protocol) behind it, plus the P2P gateway, the
// WebAPI the game's summon calls go to, and the game-server proxy.
#pragma once
#include <atomic>
#include <condition_variable>
#include <map>
#include <memory>
#include <mutex>
#include <optional>
#include <set>
#include <string>
#include <thread>
#include <vector>
#include "bbhost_client.hpp"
#include "http.hpp"
#include "p2p.hpp"
#include "util.hpp"
namespace bb {
uint64_t account_id_for(const std::string& npid); // the PSN-style id bbport hands the game
struct Config {
std::string bbhost_server = "https://thehuntersdream.com:18671";
std::string auth_server = "https://thehuntersdream.com"; // Discord sign-in, website codes (bbhost's auth_server)
std::string community_server = "https://thehuntersdream.com:18671";
std::string bind_host = "127.0.0.1";
int tcp_port = 31313, udp_port = 31314, webapi_port = 31315;
bool p2p_gateway = true;
int p2p_port = 0; // outer UDP port, 0 = any
int port_p2p_port = 3658; // bbport's P2P UDP port (SHADPS4_P2P_PORT)
std::string stun_server; // "host:port", "" = the bbhost host on 3478, "off"
std::string local_addr; // LAN address to register, "" = detected
double poll_interval = 0.4;
std::string state_file = "bbbridge_state.json";
bool trace = false;
bool debug_endpoints = false; // /debug/* on the WebAPI (tests only)
bool create_accounts = false; // make an account for an unknown NPID (tests); players sign in with Discord
double stun_refresh = 15.0; // seconds between STUN refreshes (tests shorten it)
std::map<int, std::string> game_proxy_routes = {{31316, "https://thehuntersdream.com:20443"},
{31317, "https://thehuntersdream.com:18671"}};
std::string user_agent = std::string("bbbridge/") + kVersion;
static Config from_json(const json& j);
std::optional<std::pair<std::string, int>> stun_endpoint() const;
};
class Bridge;
class TcpConn {
public:
explicit TcpConn(SOCKET s) : sock(s) {}
bool send(const std::string& data);
void close();
SOCKET sock;
std::atomic<bool> alive{true};
private:
std::mutex send_mu_;
};
struct RoomState {
uint64_t room_id = 0;
std::string session_id;
int member_id = 0, owner_id = 0, max_slot = 5;
std::map<int, json> members; // member_id -> {OnlineId, Addr, Port, LocalAddr, LocalPort, MappedAddr, MappedPort}
void clear() { *this = RoomState{}; }
};
class Session {
public:
Session(Bridge& bridge, std::shared_ptr<TcpConn> conn);
~Session();
std::pair<uint8_t, std::string> handle(uint16_t command, uint64_t packet_id, const std::string& payload);
void shutdown();
bool logged_in() const { return logged_in_; }
std::string npid() const;
BBHostClient bb;
std::atomic<bool> terminate_requested{false};
std::shared_ptr<TcpConn> conn;
// for the bridge (mapped-address change, summon advertisement, app status)
void on_mapped_change(const Endpoint& mapped);
void set_advertisement(const json& adv);
uint64_t room_id() const;
int room_members() const;
private:
using Reply = std::pair<uint8_t, std::string>;
Reply on_login(const std::string& payload);
Reply on_get_token();
Reply on_context_start(const std::string& payload);
Reply on_context_stop();
Reply on_get_world_info_list();
Reply on_create_room(const std::string& payload);
Reply on_join_room(const std::string& payload);
Reply on_leave_room(const std::string& payload);
Reply on_request_signaling_infos(const std::string& payload);
Reply on_room_id_echo(const std::string& payload); // SetRoomData{Internal,External}
Reply on_kickout(const std::string& payload);
bool bbhost_account();
std::pair<Endpoint, Endpoint> p2p_addresses();
json host_extra_from_advertisement() const;
static Endpoint member_endpoint(const json& member);
Endpoint port_endpoint_for(const json& member);
std::optional<Endpoint> port_p2p_endpoint() const;
void forget_peers(std::optional<int> member_id = std::nullopt);
void ensure_self_member();
void absorb_members(const json& members);
std::string build_details(int me_id, int owner_id);
std::string member_data(const json& member, int owner_id);
void leave_room_quietly();
void send_room_event(uint32_t event, uint32_t cause, const json& member, int64_t error_code = 0);
void start_pump();
void pump_main();
void handle_event(const json& event);
Bridge& bridge_;
mutable std::recursive_mutex mu_; // the session's state; the TCP and pump threads share it
std::string npid_, title_id_;
uint64_t ctx_id_ = 0;
std::atomic<bool> logged_in_{false};
uint64_t account_id_ = 0;
RoomState room_;
json advertisement_;
std::vector<json> pending_guest_invites_;
std::thread pump_;
std::atomic<bool> pump_running_{false};
std::mutex join_mu_; // shutdown() can come from the TCP thread and Bridge::stop at once
std::mutex pump_mu_;
std::condition_variable pump_cv_;
bool pump_stop_ = false;
};
struct BridgeStatus {
bool running = false;
std::string npid;
bool logged_in = false;
uint64_t room_id = 0;
int room_members = 0;
int relay_port = 0;
std::optional<Endpoint> mapped;
std::vector<p2p::PeerInfo> peers;
};
class Bridge {
public:
explicit Bridge(Config cfg) : cfg(std::move(cfg)) {}
~Bridge() { stop(); }
bool start(std::string* error);
void stop();
BridgeStatus status() const;
const Config cfg;
std::unique_ptr<p2p::Gateway> gateway;
// shared with sessions
json load_state();
void save_state(const json& state);
std::optional<Endpoint> registry_get(const std::string& npid) const;
std::pair<std::string, std::string> identity() const; // (token, ruleset) of the logged-in player
json last_advertisement() const;
private:
void tcp_accept_loop();
void tcp_serve(std::shared_ptr<TcpConn> conn);
void stun_loop();
http::ServerResponse webapi(const http::ServerRequest& req);
http::ServerResponse summon(const http::ServerRequest& req);
http::ServerResponse game_proxy(const std::string& upstream, const http::ServerRequest& req);
void capture(const std::string& name, const std::string& request, int status, const std::string& response,
const std::string& note);
void on_mapped_change(const Endpoint& mapped);
SOCKET tcp_ = INVALID_SOCKET, stun_ = INVALID_SOCKET;
std::thread tcp_thread_, stun_thread_;
std::atomic<bool> running_{false};
std::atomic<int> tcp_active_{0};
http::Server webapi_server_;
std::vector<std::unique_ptr<http::Server>> proxies_;
mutable std::mutex sessions_mu_;
std::vector<std::shared_ptr<Session>> sessions_;
mutable std::mutex registry_mu_;
std::map<std::string, Endpoint> registry_; // npid -> the port's endpoint, from its STUN pings
json last_advertisement_;
std::mutex state_mu_;
};
} // namespace bb
+437
View File
@@ -0,0 +1,437 @@
#include "http.hpp"
#ifdef _WIN32
#include <winhttp.h>
#else
#include <curl/curl.h>
#endif
#include <algorithm>
#include <cctype>
#include <sstream>
#ifdef _WIN32
#pragma comment(lib, "winhttp.lib")
#pragma comment(lib, "ws2_32.lib")
#endif
namespace http {
namespace {
bool iequals(const std::string& a, const std::string& b) {
return a.size() == b.size() &&
std::equal(a.begin(), a.end(), b.begin(), [](char x, char y) {
return std::tolower(static_cast<unsigned char>(x)) == std::tolower(static_cast<unsigned char>(y));
});
}
std::string trim(const std::string& s) {
size_t a = 0, b = s.size();
while (a < b && (s[a] == ' ' || s[a] == '\t')) ++a;
while (b > a && (s[b - 1] == ' ' || s[b - 1] == '\t' || s[b - 1] == '\r')) --b;
return s.substr(a, b - a);
}
#ifdef _WIN32
HINTERNET open_session() {
return WinHttpOpen(L"bbbridge", WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, WINHTTP_NO_PROXY_NAME,
WINHTTP_NO_PROXY_BYPASS, 0);
}
HINTERNET session() {
static HINTERNET h = open_session();
return h;
}
ClientResponse send_on(HINTERNET session, const ClientRequest& req);
struct Handle {
HINTERNET h = nullptr;
~Handle() { if (h) WinHttpCloseHandle(h); }
};
#endif
const char* reason(int status) {
switch (status) {
case 200: return "OK";
case 201: return "Created";
case 204: return "No Content";
case 400: return "Bad Request";
case 401: return "Unauthorized";
case 404: return "Not Found";
case 500: return "Internal Server Error";
case 502: return "Bad Gateway";
default: return "Status";
}
}
} // namespace
std::string header_value(const Headers& headers, const std::string& name) {
for (const auto& [k, v] : headers)
if (iequals(k, name)) return v;
return "";
}
#ifdef _WIN32
ClientResponse send(const ClientRequest& req) { return send_on(session(), req); }
Pool::~Pool() {
if (session_) WinHttpCloseHandle(session_);
}
ClientResponse Pool::send(const ClientRequest& req) {
std::lock_guard<std::mutex> lk(mu_);
if (session_ && bb::now() - opened_ >= max_age_) { // closing the session closes its connection
WinHttpCloseHandle(session_);
session_ = nullptr;
}
if (!session_) {
session_ = open_session();
opened_ = bb::now();
}
return send_on(session_, req);
}
namespace {
ClientResponse send_on(HINTERNET session, const ClientRequest& req) {
ClientResponse res;
if (!session) {
res.error = "WinHttpOpen failed";
return res;
}
const std::wstring wurl = bb::widen(req.url);
URL_COMPONENTS uc{};
uc.dwStructSize = sizeof(uc);
wchar_t host[256] = {}, path[4096] = {};
uc.lpszHostName = host;
uc.dwHostNameLength = 256;
uc.lpszUrlPath = path;
uc.dwUrlPathLength = 4096;
wchar_t extra[4096] = {};
uc.lpszExtraInfo = extra;
uc.dwExtraInfoLength = 4096;
if (!WinHttpCrackUrl(wurl.c_str(), 0, 0, &uc)) {
res.error = "bad url " + req.url;
return res;
}
const bool secure = uc.nScheme == INTERNET_SCHEME_HTTPS;
Handle conn{WinHttpConnect(session, host, uc.nPort, 0)};
if (!conn.h) {
res.error = bb::fmt("connect failed (%lu)", GetLastError());
return res;
}
const std::wstring object = std::wstring(path) + extra;
Handle request{WinHttpOpenRequest(conn.h, bb::widen(req.method).c_str(), object.c_str(), nullptr,
WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES,
secure ? WINHTTP_FLAG_SECURE : 0)};
if (!request.h) {
res.error = bb::fmt("open request failed (%lu)", GetLastError());
return res;
}
DWORD policy = WINHTTP_OPTION_REDIRECT_POLICY_NEVER;
WinHttpSetOption(request.h, WINHTTP_OPTION_REDIRECT_POLICY, &policy, sizeof(policy));
WinHttpSetTimeouts(request.h, req.timeout_ms, req.timeout_ms, req.timeout_ms, req.timeout_ms);
std::wstring headers;
for (const auto& [k, v] : req.headers) headers += bb::widen(k + ": " + v + "\r\n");
if (!headers.empty())
WinHttpAddRequestHeaders(request.h, headers.c_str(), static_cast<DWORD>(-1L),
WINHTTP_ADDREQ_FLAG_ADD | WINHTTP_ADDREQ_FLAG_REPLACE);
const DWORD len = static_cast<DWORD>(req.body.size());
if (!WinHttpSendRequest(request.h, WINHTTP_NO_ADDITIONAL_HEADERS, 0,
len ? const_cast<char*>(req.body.data()) : WINHTTP_NO_REQUEST_DATA, len, len, 0) ||
!WinHttpReceiveResponse(request.h, nullptr)) {
const DWORD e = GetLastError();
res.error = e == ERROR_WINHTTP_TIMEOUT ? "The read operation timed out" : bb::fmt("request failed (%lu)", e);
return res;
}
DWORD status = 0, size = sizeof(status);
WinHttpQueryHeaders(request.h, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
WINHTTP_HEADER_NAME_BY_INDEX, &status, &size, WINHTTP_NO_HEADER_INDEX);
res.status = static_cast<int>(status);
size = 0;
WinHttpQueryHeaders(request.h, WINHTTP_QUERY_RAW_HEADERS_CRLF, WINHTTP_HEADER_NAME_BY_INDEX, nullptr,
&size, WINHTTP_NO_HEADER_INDEX);
if (size) {
std::wstring raw(size / sizeof(wchar_t), L'\0');
if (WinHttpQueryHeaders(request.h, WINHTTP_QUERY_RAW_HEADERS_CRLF, WINHTTP_HEADER_NAME_BY_INDEX,
raw.data(), &size, WINHTTP_NO_HEADER_INDEX)) {
std::istringstream lines(bb::narrow(raw));
std::string line;
bool first = true;
while (std::getline(lines, line)) {
if (first) { first = false; continue; } // status line
const size_t c = line.find(':');
if (c != std::string::npos) res.headers.emplace_back(trim(line.substr(0, c)), trim(line.substr(c + 1)));
}
}
}
while (true) {
DWORD avail = 0;
if (!WinHttpQueryDataAvailable(request.h, &avail)) {
res.error = bb::fmt("read failed (%lu)", GetLastError());
return res;
}
if (!avail) break;
std::string chunk(avail, '\0');
DWORD read = 0;
if (!WinHttpReadData(request.h, chunk.data(), avail, &read)) {
res.error = bb::fmt("read failed (%lu)", GetLastError());
return res;
}
res.body.append(chunk.data(), read);
}
return res;
}
} // namespace
#else
// Linux: libcurl, as WinHTTP above: certificates checked, no redirects followed, the request's
// timeout for connecting and again for the answer, and a Pool keeping one connection alive.
namespace {
// The system's CA bundle: libcurl's built-in path is the one of the distribution it was built
// on, and the AppImage carries it to others.
const char* ca_bundle() {
static const char* found = [] {
for (const char* p : {"/etc/ssl/certs/ca-certificates.crt", "/etc/pki/tls/certs/ca-bundle.crt",
"/etc/ssl/ca-bundle.pem", "/etc/pki/tls/cacert.pem",
"/etc/ssl/cert.pem"})
if (access(p, R_OK) == 0) return p;
return static_cast<const char*>(nullptr);
}();
return found;
}
size_t on_body(char* data, size_t size, size_t count, void* user) {
static_cast<std::string*>(user)->append(data, size * count);
return size * count;
}
size_t on_header(char* data, size_t size, size_t count, void* user) {
auto* headers = static_cast<Headers*>(user);
std::string line(data, size * count);
while (!line.empty() && (line.back() == '\n' || line.back() == '\r')) line.pop_back(); // trim() keeps '\n'
line = trim(line);
if (line.rfind("HTTP/", 0) == 0) headers->clear(); // a new response (after a 100 Continue)
else if (const size_t c = line.find(':'); c != std::string::npos)
headers->emplace_back(trim(line.substr(0, c)), trim(line.substr(c + 1)));
return size * count;
}
ClientResponse send_on(CURL* curl, const ClientRequest& req) {
ClientResponse res;
if (!curl) {
res.error = "curl_easy_init failed";
return res;
}
curl_easy_reset(curl);
curl_easy_setopt(curl, CURLOPT_URL, req.url.c_str());
curl_easy_setopt(curl, CURLOPT_USERAGENT, "bbbridge");
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L);
if (const char* ca = ca_bundle()) curl_easy_setopt(curl, CURLOPT_CAINFO, ca);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, static_cast<long>(req.timeout_ms));
curl_easy_setopt(curl, CURLOPT_TIMEOUT_MS, static_cast<long>(req.timeout_ms) * 2);
if (req.method == "GET") {
curl_easy_setopt(curl, CURLOPT_HTTPGET, 1L);
} else if (req.method == "HEAD") {
curl_easy_setopt(curl, CURLOPT_NOBODY, 1L);
} else {
curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, req.method.c_str());
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, req.body.data());
curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, static_cast<long>(req.body.size()));
}
curl_slist* headers = curl_slist_append(nullptr, "Expect:"); // no 100-continue round trip
for (const auto& [k, v] : req.headers) headers = curl_slist_append(headers, (k + ": " + v).c_str());
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, on_body);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &res.body);
curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, on_header);
curl_easy_setopt(curl, CURLOPT_HEADERDATA, &res.headers);
const CURLcode code = curl_easy_perform(curl);
curl_slist_free_all(headers);
if (code != CURLE_OK) {
res.error = code == CURLE_OPERATION_TIMEDOUT ? "The read operation timed out"
: bb::fmt("request failed (%s)", curl_easy_strerror(code));
res.body.clear();
res.headers.clear();
return res;
}
long status = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
res.status = static_cast<int>(status);
return res;
}
CURL* new_handle() {
static const bool initialised = curl_global_init(CURL_GLOBAL_DEFAULT) == CURLE_OK;
return initialised ? curl_easy_init() : nullptr;
}
} // namespace
ClientResponse send(const ClientRequest& req) {
// One connection per request, as WinHTTP's shared session gives no keep-alive guarantee.
CURL* curl = new_handle();
ClientResponse res = send_on(curl, req);
if (curl) curl_easy_cleanup(curl);
return res;
}
Pool::~Pool() {
if (session_) curl_easy_cleanup(static_cast<CURL*>(session_));
}
ClientResponse Pool::send(const ClientRequest& req) {
std::lock_guard<std::mutex> lk(mu_);
if (session_ && bb::now() - opened_ >= max_age_) { // a new handle dials a new connection
curl_easy_cleanup(static_cast<CURL*>(session_));
session_ = nullptr;
}
if (!session_) {
session_ = new_handle();
opened_ = bb::now();
}
return send_on(static_cast<CURL*>(session_), req);
}
#endif
ServerResponse ServerResponse::json(const bb::json& j, int status) {
ServerResponse r;
r.status = status;
r.body = j.dump();
r.headers.emplace_back("Content-Type", "application/json");
return r;
}
// ---- server ----
bool Server::start(const std::string& host, int port, Handler handler, std::string* error) {
bb::net_init();
handler_ = std::move(handler);
listen_ = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
#ifdef _WIN32
BOOL exclusive = TRUE;
setsockopt(listen_, SOL_SOCKET, SO_EXCLUSIVEADDRUSE, reinterpret_cast<const char*>(&exclusive), sizeof(exclusive));
#else
// Linux keeps a port that just closed (TIME_WAIT) from being bound again without SO_REUSEADDR,
// which Windows allows; it still never lets a second program listen on it.
int reuse = 1;
setsockopt(listen_, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
#endif
sockaddr_in addr = bb::to_sockaddr(bb::Endpoint{host, port});
if (bind(listen_, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0 || ::listen(listen_, 16) != 0) {
if (error) *error = bb::fmt("cannot listen on %s:%d (error %d)", host.c_str(), port, WSAGetLastError());
closesocket(listen_);
listen_ = INVALID_SOCKET;
return false;
}
socklen_t len = sizeof(addr);
getsockname(listen_, reinterpret_cast<sockaddr*>(&addr), &len);
port_ = ntohs(addr.sin_port);
running_ = true;
acceptor_ = std::thread([this] { accept_loop(); });
return true;
}
void Server::stop() {
if (!running_.exchange(false)) return;
closesocket(listen_);
listen_ = INVALID_SOCKET;
if (acceptor_.joinable()) acceptor_.join();
{
std::lock_guard<std::mutex> lk(mu_);
for (SOCKET s : clients_) shutdown(s, SD_BOTH);
}
// Connection threads are detached (the game opens one per request); wait
// for the ones still running, which a shutdown socket ends quickly.
for (int i = 0; i < 300 && active_.load() > 0; ++i) Sleep(10);
}
void Server::accept_loop() {
while (running_) {
SOCKET s = accept(listen_, nullptr, nullptr);
if (s == INVALID_SOCKET) break;
{
std::lock_guard<std::mutex> lk(mu_);
clients_.insert(s);
}
++active_;
std::thread([this, s] {
serve(s);
--active_;
}).detach();
}
}
void Server::serve(SOCKET s) {
std::string buf;
char chunk[8192];
bool keep = true;
while (keep && running_) {
size_t end;
while ((end = buf.find("\r\n\r\n")) == std::string::npos) {
const int n = recv(s, chunk, sizeof(chunk), 0);
if (n <= 0) { keep = false; break; }
buf.append(chunk, n);
if (buf.size() > (1 << 20)) { keep = false; break; }
}
if (!keep) break;
ServerRequest req;
std::istringstream head(buf.substr(0, end));
std::string line, version;
std::getline(head, line);
{
std::istringstream rl(line);
rl >> req.method >> req.target >> version;
}
while (std::getline(head, line)) {
const size_t c = line.find(':');
if (c != std::string::npos) req.headers.emplace_back(trim(line.substr(0, c)), trim(line.substr(c + 1)));
}
buf.erase(0, end + 4);
size_t want = 0;
const std::string cl = header_value(req.headers, "Content-Length");
if (!cl.empty()) {
try { want = static_cast<size_t>(std::stoull(cl)); } catch (...) { want = 0; }
}
while (buf.size() < want) {
const int n = recv(s, chunk, sizeof(chunk), 0);
if (n <= 0) { keep = false; break; }
buf.append(chunk, n);
}
if (!keep) break;
req.body = buf.substr(0, want);
buf.erase(0, want);
if (iequals(header_value(req.headers, "Connection"), "close") || version == "HTTP/1.0") keep = false;
ServerResponse res;
try {
res = handler_(req);
} catch (const std::exception& e) {
res = ServerResponse::json({{"ResKind", 1}, {"Message", std::string("bridge error: ") + e.what()}}, 500);
}
std::string out = bb::fmt("HTTP/1.1 %d %s\r\n", res.status, reason(res.status));
for (const auto& [k, v] : res.headers)
if (!iequals(k, "Content-Length") && !iequals(k, "Connection")) out += k + ": " + v + "\r\n";
out += "Content-Length: " + std::to_string(res.body.size()) + "\r\n";
out += keep ? "Connection: keep-alive\r\n\r\n" : "Connection: close\r\n\r\n";
if (req.method != "HEAD") out += res.body;
size_t sent = 0;
while (sent < out.size()) {
const int n = ::send(s, out.data() + sent, static_cast<int>(out.size() - sent), 0);
if (n <= 0) { keep = false; break; }
sent += static_cast<size_t>(n);
}
}
{
std::lock_guard<std::mutex> lk(mu_);
clients_.erase(s);
}
closesocket(s);
}
} // namespace http
+101
View File
@@ -0,0 +1,101 @@
// HTTP: a WinHTTP client (certificates checked by Windows, no redirects
// followed) and a small threaded HTTP/1.1 server for the bridge's local
// listeners (WebAPI, game-server proxy).
#pragma once
#include <atomic>
#include <functional>
#include <mutex>
#include <set>
#include <string>
#include <thread>
#include <utility>
#include <vector>
#include "util.hpp"
namespace http {
using Headers = std::vector<std::pair<std::string, std::string>>;
std::string header_value(const Headers& headers, const std::string& name); // case-insensitive, "" if absent
struct ClientRequest {
std::string method = "POST";
std::string url;
Headers headers;
std::string body;
int timeout_ms = 8000;
};
struct ClientResponse {
int status = 0;
Headers headers;
std::string body;
std::string error; // transport failure (no HTTP answer)
bool ok() const { return error.empty(); }
};
ClientResponse send(const ClientRequest& req);
// A WinHTTP session of its own for one caller's requests in a row: its kept-
// alive connection is closed and dialled again once it is max_age_s old, before
// something on the way drops it silently. (The Hunter's Dream's event poll, on
// the shared pool, hung for its whole timeout about once a minute.)
class Pool {
public:
explicit Pool(double max_age_s) : max_age_(max_age_s) {}
~Pool();
Pool(const Pool&) = delete;
Pool& operator=(const Pool&) = delete;
ClientResponse send(const ClientRequest& req);
private:
std::mutex mu_;
void* session_ = nullptr; // HINTERNET
double opened_ = 0, max_age_;
};
struct ServerRequest {
std::string method;
std::string target; // path with the query
Headers headers;
std::string body;
std::string path() const { return target.substr(0, target.find('?')); }
std::string query() const {
const size_t q = target.find('?');
return q == std::string::npos ? "" : target.substr(q + 1);
}
};
struct ServerResponse {
int status = 200;
Headers headers; // Content-Length is added
std::string body;
static ServerResponse json(const bb::json& j, int status = 200);
};
using Handler = std::function<ServerResponse(const ServerRequest&)>;
class Server {
public:
~Server() { stop(); }
bool start(const std::string& host, int port, Handler handler, std::string* error);
void stop();
int port() const { return port_; }
private:
void accept_loop();
void serve(SOCKET s);
SOCKET listen_ = INVALID_SOCKET;
int port_ = 0;
Handler handler_;
std::thread acceptor_;
std::atomic<bool> running_{false};
std::mutex mu_;
std::set<SOCKET> clients_;
std::atomic<int> active_{0}; // detached connection threads still running
};
} // namespace http
+152
View File
@@ -0,0 +1,152 @@
// bbbridge-cli: the bridge without a window (tests, servers, troubleshooting).
// bbbridge-cli[.exe] [--config bbbridge_config.json] [--log bbbridge.log]
#include <atomic>
#include <cstdio>
#include <fstream>
#include <string>
#include <vector>
#include "account_link.hpp"
#include "bridge.hpp"
#ifdef _WIN32
#include "setup.hpp"
#else
#include <csignal>
#endif
namespace {
std::atomic<bool> g_quit{false};
#ifdef _WIN32
BOOL WINAPI on_ctrl(DWORD) {
g_quit = true;
return TRUE;
}
#else
void on_signal(int) { g_quit = true; }
#endif
int run(const std::vector<std::wstring>& argv) {
const int argc = static_cast<int>(argv.size());
std::wstring config_path = L"bbbridge_config.json", log_path, web_code_npid;
bool link = false;
std::wstring route, bbport_dir;
struct { std::wstring op, name, code; } account_op;
for (int i = 1; i < argc; ++i) {
const std::wstring a = argv[i];
if (a == L"--config" && i + 1 < argc) config_path = argv[++i];
else if (a == L"--log" && i + 1 < argc) log_path = argv[++i];
else if (a == L"--link-discord") link = true;
else if (a == L"--route" && i + 1 < argc) route = argv[++i];
else if (a == L"--bbport" && i + 1 < argc) bbport_dir = argv[++i];
else if (a == L"--web-code" && i + 1 < argc) web_code_npid = argv[++i];
else if (a == L"--create" && i + 1 < argc) account_op = {L"create", argv[++i], L""};
else if (a == L"--recover" && i + 2 < argc) {
account_op = {L"recover", argv[i + 1], argv[i + 2]};
i += 2;
} else if (a == L"--sign-out" && i + 1 < argc) account_op = {L"sign-out", argv[++i], L""};
else if (a == L"--version") {
std::printf("bbbridge %s\n", bb::kVersion);
return 0;
} else {
std::printf("usage: bbbridge-cli [--config file.json] [--log file.log] [--version]\n"
" [--link-discord] [--web-code NPID] [--create NAME]\n"
" [--recover NAME CODE] [--sign-out NAME]\n"
" [--route on|off [--bbport DIR]]\n");
return 2;
}
}
if (!log_path.empty()) bb::set_log_file(log_path);
bb::json j = bb::json::object();
std::ifstream in(bb::fspath(config_path));
if (in) {
j = bb::json::parse(in, nullptr, false);
if (j.is_discarded()) {
std::printf("config %s is not valid JSON\n", bb::narrow(config_path).c_str());
return 1;
}
}
const bb::Config cfg = bb::Config::from_json(j);
if (link) { // sign in with Discord, store the account, exit
bb::DiscordLink dl(cfg.auth_server, bb::widen(cfg.state_file));
dl.start();
bool shown = false;
while (dl.state() == bb::DiscordLink::State::Starting || dl.state() == bb::DiscordLink::State::Waiting) {
if (!shown && dl.state() == bb::DiscordLink::State::Waiting) {
std::printf("Open %s and enter the code %s\n", dl.uri().c_str(), dl.user_code().c_str());
std::fflush(stdout);
shown = true;
}
Sleep(200);
}
if (dl.state() != bb::DiscordLink::State::Approved) {
std::printf("failed: %s\n", dl.message().c_str());
return 1;
}
std::printf("signed in as %s\n", dl.online_id().c_str());
return 0;
}
if (!route.empty()) { // point bbport at the bridge (on) or back (off), as the app's checkbox
#ifndef _WIN32
std::printf("failed: --route is for bbport for Windows; on Linux the bbport launcher routes the game\n");
return 1;
#else
try {
wchar_t self[MAX_PATH];
GetModuleFileNameW(nullptr, self, MAX_PATH);
bb::BbportSetup setup(bb::fs::path(self).parent_path(), bbport_dir);
if (route == L"on") setup.enable();
else setup.disable();
std::printf("ok: bbport %s, routed=%d\n", bb::narrow(setup.bbport.wstring()).c_str(), setup.routed() ? 1 : 0);
return 0;
} catch (const std::exception& e) {
std::printf("failed: %s\n", e.what());
return 1;
}
#endif
}
if (!account_op.op.empty()) {
const std::wstring state = bb::widen(cfg.state_file);
const std::string name = bb::narrow(account_op.name);
const std::string err =
account_op.op == L"create" ? bb::account_create_now(cfg.auth_server, state, name)
: account_op.op == L"recover" ? bb::account_recover_now(cfg.auth_server, state, name, bb::narrow(account_op.code))
: bb::account_sign_out(cfg.auth_server, state, name);
std::printf("%s\n", err.empty() ? "ok" : ("failed: " + err).c_str());
return err.empty() ? 0 : 1;
}
if (!web_code_npid.empty()) {
std::string error;
const std::string code = bb::website_code(cfg.auth_server, bb::widen(cfg.state_file), bb::narrow(web_code_npid), error);
if (code.empty()) {
std::printf("failed: %s\n", error.c_str());
return 1;
}
std::printf("%s\n", code.c_str());
return 0;
}
bb::log(bb::fmt("bbbridge %s (cli)", bb::kVersion));
bb::Bridge bridge(cfg);
std::string error;
if (!bridge.start(&error)) return 1;
#ifdef _WIN32
SetConsoleCtrlHandler(on_ctrl, TRUE);
#else
std::signal(SIGINT, on_signal);
std::signal(SIGTERM, on_signal);
#endif
while (!g_quit) Sleep(200);
bb::log("stopping");
bridge.stop();
return 0;
}
} // namespace
#ifdef _WIN32
int wmain(int argc, wchar_t** argv) { return run(std::vector<std::wstring>(argv, argv + argc)); }
#else
int main(int argc, char** argv) {
std::vector<std::wstring> args;
for (int i = 0; i < argc; ++i) args.push_back(bb::widen(argv[i]));
return run(args);
}
#endif
+735
View File
@@ -0,0 +1,735 @@
#include "p2p.hpp"
#ifdef _WIN32
#include <mstcpip.h>
#ifndef SIO_UDP_CONNRESET
#define SIO_UDP_CONNRESET _WSAIOW(IOC_VENDOR, 12)
#endif
#endif
#include <algorithm>
#include <chrono>
#include <cstring>
#include <random>
namespace bb::p2p {
namespace {
constexpr uint16_t kPortSignalingVport = 0xFFFF;
constexpr uint8_t kPs4Magic = 0xFF, kFlagP2p = 0x80, kFlagByteVports = 0x40, kFlagComid = 0x20, kFlagType = 0x03;
constexpr uint8_t kRelayMagic = 0xFB;
constexpr uint32_t kStunCookie = 0x2112A442;
constexpr uint16_t kStunBindingRequest = 0x0001, kStunBindingResponse = 0x0101, kStunMapped = 0x0001,
kStunXorMapped = 0x0020, kStunXorMappedOld = 0x8020, kBbhostHello = 0x8100,
kBbhostRelay = 0x8101;
const std::string kRelayVersion = "bbr1";
const std::string kShad = "SHAD";
uint16_t be16(const std::string& s, size_t at) {
return static_cast<uint16_t>((static_cast<uint8_t>(s[at]) << 8) | static_cast<uint8_t>(s[at + 1]));
}
uint32_t be32(const std::string& s, size_t at) {
return (static_cast<uint32_t>(be16(s, at)) << 16) | be16(s, at + 2);
}
void put_be16(std::string& s, uint16_t v) {
s.push_back(static_cast<char>(v >> 8));
s.push_back(static_cast<char>(v & 0xFF));
}
template <typename T>
void put_le(std::string& s, T v) {
for (size_t i = 0; i < sizeof(T); ++i) s.push_back(static_cast<char>((static_cast<uint64_t>(v) >> (8 * i)) & 0xFF));
}
template <typename T>
T get_le(const std::string& s, size_t at) {
uint64_t v = 0;
for (size_t i = 0; i < sizeof(T); ++i) v |= static_cast<uint64_t>(static_cast<uint8_t>(s[at + i])) << (8 * i);
return static_cast<T>(v);
}
std::string id16(const std::string& name) {
std::string out = name.substr(0, 16);
out.resize(16, '\0');
return out;
}
std::string ip_from_be32(uint32_t v) {
return fmt("%u.%u.%u.%u", (v >> 24) & 0xFF, (v >> 16) & 0xFF, (v >> 8) & 0xFF, v & 0xFF);
}
uint64_t wall_micros() {
using namespace std::chrono;
return static_cast<uint64_t>(duration_cast<microseconds>(system_clock::now().time_since_epoch()).count());
}
void no_udp_connreset(SOCKET s) {
#ifdef _WIN32
// Without this, an ICMP "port unreachable" for an earlier send makes the
// next recvfrom fail with WSAECONNRESET (10054).
BOOL off = FALSE;
DWORD ret = 0;
WSAIoctl(s, SIO_UDP_CONNRESET, &off, sizeof(off), nullptr, 0, &ret, nullptr, nullptr);
#else
(void)s; // Linux reports ICMP errors only on connected UDP sockets; these are not
#endif
}
SOCKET udp_bind(const std::string& ip, int port, Endpoint* bound) {
SOCKET s = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (s == INVALID_SOCKET) return s;
sockaddr_in a = to_sockaddr(Endpoint{ip, port});
if (bind(s, reinterpret_cast<sockaddr*>(&a), sizeof(a)) != 0) {
closesocket(s);
return INVALID_SOCKET;
}
no_udp_connreset(s);
socklen_t len = sizeof(a);
getsockname(s, reinterpret_cast<sockaddr*>(&a), &len);
if (bound) *bound = from_sockaddr(a);
return s;
}
void udp_send(SOCKET s, const std::string& data, const Endpoint& to) {
sockaddr_in a = to_sockaddr(to);
sendto(s, data.data(), static_cast<int>(data.size()), 0, reinterpret_cast<sockaddr*>(&a), sizeof(a));
}
} // namespace
const std::string kProbe("\xfe" "bbhp\0\0\0", 8);
// ---- framing ----
Frame port_to_ps4(const std::string& frame, std::string& out) {
if (frame.size() < 4) return Frame::None;
const uint16_t src = be16(frame, 0), dst = be16(frame, 2);
const std::string payload = frame.substr(4);
if (dst == kPortSignalingVport) {
out = payload;
return Frame::Signaling;
}
out.clear();
out.push_back(static_cast<char>(kPs4Magic));
if (src < 256 && dst < 256) {
out.push_back(static_cast<char>(kFlagP2p | kFlagByteVports | kFlagType));
out.push_back(static_cast<char>(src));
out.push_back(static_cast<char>(dst));
} else {
out.push_back(static_cast<char>(kFlagP2p | kFlagType));
put_be16(out, src);
put_be16(out, dst);
}
out += payload;
return Frame::Game;
}
bool ps4_to_port(const std::string& d, std::string& out) {
if (d.size() < 4 || static_cast<uint8_t>(d[0]) != kPs4Magic) return false;
const uint8_t flags = static_cast<uint8_t>(d[1]);
size_t at = 2 + ((flags & kFlagComid) ? 4 : 0);
uint16_t src, dst;
if (flags & kFlagByteVports) {
if (at + 2 > d.size()) return false;
src = static_cast<uint8_t>(d[at]);
dst = static_cast<uint8_t>(d[at + 1]);
at += 2;
} else {
if (at + 4 > d.size()) return false;
src = be16(d, at);
dst = be16(d, at + 2);
at += 4;
}
out.clear();
put_be16(out, src);
put_be16(out, dst);
out += d.substr(at);
return true;
}
std::string port_signaling_frame(const std::string& payload) {
std::string out;
put_be16(out, kPortSignalingVport);
put_be16(out, kPortSignalingVport);
return out + payload;
}
std::string relay_wrap(const std::string& token, int port, const std::string& datagram) {
std::string out;
out.push_back(static_cast<char>(kRelayMagic));
out.push_back('R');
out += token;
put_be16(out, static_cast<uint16_t>(port));
return out + datagram;
}
bool relay_unwrap(const std::string& data, int& port, std::string& datagram) {
if (data.size() < 4 || static_cast<uint8_t>(data[0]) != kRelayMagic || data[1] != 'r') return false;
port = be16(data, 2);
datagram = data.substr(4);
return true;
}
std::string stun_request(const std::string& token, std::string& txid) {
static std::mt19937_64 rng{std::random_device{}()};
txid.clear();
txid.push_back(static_cast<char>(kStunCookie >> 24));
txid.push_back(static_cast<char>((kStunCookie >> 16) & 0xFF));
txid.push_back(static_cast<char>((kStunCookie >> 8) & 0xFF));
txid.push_back(static_cast<char>(kStunCookie & 0xFF));
for (int i = 0; i < 12; ++i) txid.push_back(static_cast<char>(rng() & 0xFF));
std::string attr;
put_be16(attr, kBbhostHello);
put_be16(attr, 12);
attr += kRelayVersion;
attr += token.size() == 8 ? token : std::string(8, '\0');
std::string out;
put_be16(out, kStunBindingRequest);
put_be16(out, static_cast<uint16_t>(attr.size()));
return out + txid + attr;
}
bool stun_parse(const std::string& d, const std::string& txid, StunAnswer& out) {
if (d.size() < 20 || be16(d, 0) != kStunBindingResponse || d.compare(4, 16, txid) != 0) return false;
const size_t length = be16(d, 2);
if (20 + length > d.size()) return false;
const uint32_t xor_addr = be32(d, 4);
const uint16_t xor_port = be16(d, 4);
std::optional<std::pair<uint32_t, uint16_t>> mapped, xored;
out = StunAnswer{};
size_t at = 20;
const size_t end = 20 + length;
while (at + 4 <= end) {
const uint16_t type = be16(d, at), alen = be16(d, at + 2);
if (at + 4 + alen > end) break;
const std::string v = d.substr(at + 4, alen);
if (type == kBbhostRelay && alen >= 20 && v.compare(0, 4, kRelayVersion) == 0) {
out.relay = true;
out.relay_token = v.substr(4, 8);
out.relay_port = be16(v, 12);
} else if (alen >= 8 && static_cast<uint8_t>(v[1]) == 0x01) {
const uint16_t port = be16(v, 2);
const uint32_t addr = be32(v, 4);
if (type == kStunMapped) mapped = std::make_pair(addr, port);
else if (type == kStunXorMapped || type == kStunXorMappedOld)
xored = std::make_pair(addr ^ xor_addr, static_cast<uint16_t>(port ^ xor_port));
}
at += 4 + ((alen + 3u) & ~3u);
}
const auto chosen = mapped ? mapped : xored;
if (!chosen || !chosen->first || !chosen->second) return false;
out.mapped = Endpoint{ip_from_be32(chosen->first), chosen->second};
return true;
}
// ---- signaling ----
PeerSignaling::PeerSignaling(const std::string& peer_online_id, int peer_member, const std::string& port_online_id,
int port_member, uint64_t room)
: peer_id(id16(peer_online_id)),
port_id(id16(port_online_id)),
peer_member_id(peer_member > 0 ? peer_member : 0),
port_member_id(port_member),
room_id(room),
conn_id(0x4242u + static_cast<uint32_t>(peer_member > 0 ? peer_member : 0)) {}
std::string PeerSignaling::handshake(uint8_t kind, uint32_t to_conn, uint64_t nonce) const {
std::string p = kShad;
p.push_back(static_cast<char>(T_HANDSHAKE));
p.push_back(static_cast<char>(kind));
p += peer_id + port_id;
put_le<uint32_t>(p, conn_id);
put_le<uint32_t>(p, to_conn);
put_le<uint32_t>(p, 0); // mapped addr: 0 makes the port keep the inner socket's address
put_le<uint16_t>(p, 0);
put_le<uint16_t>(p, 0);
put_le<uint64_t>(p, nonce);
return p; // 62 bytes
}
std::string PeerSignaling::control(uint8_t kind) const {
std::string p = kShad;
p.push_back(static_cast<char>(T_CONTROL));
p.push_back(static_cast<char>(kind));
p += peer_id + port_id;
put_le<uint32_t>(p, conn_id);
put_le<uint32_t>(p, 0);
put_le<uint16_t>(p, 0);
put_le<uint16_t>(p, 0);
return p; // 50 bytes
}
std::string PeerSignaling::m2(uint8_t kind, uint64_t nonce) const {
std::string p = kShad;
p.push_back(static_cast<char>(T_M2));
p.push_back(static_cast<char>(kind));
put_le<uint64_t>(p, room_id);
put_le<uint16_t>(p, static_cast<uint16_t>(peer_member_id));
put_le<uint16_t>(p, static_cast<uint16_t>(port_member_id));
p += peer_id;
put_le<uint32_t>(p, 0);
put_le<uint16_t>(p, 0);
put_le<uint16_t>(p, 0);
put_le<uint64_t>(p, nonce);
return p; // 50 bytes
}
std::vector<std::string> PeerSignaling::handle(const std::string& p) {
if (p.size() < 5 || p.compare(0, 4, kShad) != 0) return {};
const uint8_t type = static_cast<uint8_t>(p[4]);
if (type == T_ECHO_PING && p.size() == 17) {
heard = true;
std::string pong = kShad;
pong.push_back(static_cast<char>(T_ECHO_PONG));
pong += p.substr(5, 12); // conn u32 + timestamp u64, echoed
return {pong};
}
if (type == T_HANDSHAKE && p.size() == 0x3E) {
heard = true;
const uint8_t kind = static_cast<uint8_t>(p[5]);
const uint32_t from_conn = get_le<uint32_t>(p, 38);
const uint64_t nonce = get_le<uint64_t>(p, 54);
if (kind == HS_OFFER) return {handshake(HS_ACCEPT, from_conn, 0)};
if (kind == HS_ACCEPT) return {handshake(HS_CHECK, from_conn, wall_micros())};
if (kind == HS_CHECK) return {handshake(HS_CHECK_ACK, from_conn, nonce)};
return {};
}
if (type == T_CONTROL && p.size() == 0x32) {
heard = true;
const uint8_t kind = static_cast<uint8_t>(p[5]);
if (kind == CTL_ACTIVATION_REQUEST) return {control(CTL_ACTIVATION_ACK)};
if (kind == CTL_ESTABLISHED && !sent_established) {
sent_established = true;
return {control(CTL_ESTABLISHED)};
}
return {};
}
if (type == T_M2 && p.size() == 0x32) {
heard = true;
const uint8_t kind = static_cast<uint8_t>(p[5]);
const uint64_t room = get_le<uint64_t>(p, 6);
const uint16_t from_member = get_le<uint16_t>(p, 14), to_member = get_le<uint16_t>(p, 16);
const uint64_t nonce = get_le<uint64_t>(p, 42);
if (room) room_id = room;
// the port addresses the member it means: learn the ids from it
if (to_member && !peer_member_id) peer_member_id = to_member;
if (from_member && !port_member_id) port_member_id = from_member;
if (kind == HS_OFFER) return {m2(HS_ACCEPT, 0)};
if (kind == HS_ACCEPT) return {m2(HS_CHECK, 0)};
if (kind == HS_CHECK) return {m2(HS_CHECK_ACK, nonce)};
return {};
}
return {};
}
std::vector<std::string> PeerSignaling::opening() const {
std::vector<std::string> out{control(CTL_ACTIVATION_REQUEST)};
if (room_id && port_member_id && peer_member_id) out.push_back(m2(HS_OFFER, 0));
return out;
}
// ---- gateway ----
Gateway::Gateway(int outer_port, std::optional<std::pair<std::string, int>> stun_server, std::string lan_addr,
std::function<void(const Endpoint&)> on_mapped_change)
: outer_port_(outer_port),
stun_server_(std::move(stun_server)),
lan_addr_(std::move(lan_addr)),
on_mapped_change_(std::move(on_mapped_change)) {}
Gateway::~Gateway() { stop(); }
bool Gateway::start(std::string* error) {
net_init();
Endpoint bound;
outer_ = udp_bind("0.0.0.0", outer_port_, &bound);
if (outer_ == INVALID_SOCKET) {
if (error) *error = fmt("p2p: cannot bind udp :%d (error %d)", outer_port_, WSAGetLastError());
return false;
}
outer_port_ = bound.port;
stop_ = false;
threads_.emplace_back([this] { outer_main(); });
threads_.emplace_back([this] { inner_main(); });
threads_.emplace_back([this] { tick_main(); });
log(fmt("p2p: gateway on udp :%d (bbhost framing)", outer_port_));
return true;
}
void Gateway::stop() {
if (stop_.exchange(true)) return;
if (outer_ != INVALID_SOCKET) closesocket(outer_);
outer_ = INVALID_SOCKET;
for (auto& t : threads_)
if (t.joinable()) t.join();
threads_.clear();
std::lock_guard<std::mutex> lk(mu_);
for (auto& [id, link] : peers_) close_link(*link);
peers_.clear();
}
void Gateway::close_link(Link& link) {
if (link.inner != INVALID_SOCKET) closesocket(link.inner);
link.inner = INVALID_SOCKET;
}
std::pair<Endpoint, Endpoint> Gateway::endpoints() const {
std::lock_guard<std::mutex> lk(mu_);
const Endpoint local{lan_addr_, outer_port_};
return {local, mapped_ ? *mapped_ : local};
}
std::optional<Endpoint> Gateway::mapped() const {
std::lock_guard<std::mutex> lk(mu_);
return mapped_;
}
int Gateway::relay_port() const {
std::lock_guard<std::mutex> lk(mu_);
return relay_ ? relay_->port : 0;
}
Endpoint Gateway::add_peer(int member_id, const std::string& online_id, const Endpoint& endpoint,
const Endpoint& local_endpoint, const std::string& port_online_id, int port_member_id,
uint64_t room_id, const std::optional<Endpoint>& port_endpoint) {
LinkPtr link;
{
std::lock_guard<std::mutex> lk(mu_);
// A peer the port asked for before it joined (a host signaling the
// guest it summons) sits under a provisional key: keep its inner socket
// - the port knows it there - and file it under the real id.
for (auto it = peers_.begin(); it != peers_.end(); ++it) {
if (!online_id.empty() && it->second->online_id == online_id && it->first != member_id) {
LinkPtr other = it->second;
peers_.erase(it);
auto old = peers_.find(member_id);
if (old != peers_.end()) {
if (old->second != other) close_link(*old->second);
peers_.erase(old);
}
other->member_id = member_id;
other->signaling.peer_member_id = member_id > 0 ? member_id : 0;
peers_[member_id] = other;
break;
}
}
auto it = peers_.find(member_id);
if (it != peers_.end() && it->second->online_id == online_id) {
Link& l = *it->second;
if (endpoint.valid()) l.endpoint = endpoint;
if (local_endpoint.valid()) l.local_endpoint = local_endpoint;
if (port_endpoint && !l.port_endpoint) l.port_endpoint = port_endpoint;
// a guest invite seeds the host before the port has joined: the room
// and the port's member id arrive with the join
if (port_member_id) l.signaling.port_member_id = port_member_id;
if (room_id) l.signaling.room_id = room_id;
return l.inner_endpoint;
}
if (it != peers_.end()) {
close_link(*it->second);
peers_.erase(it);
}
link = std::make_shared<Link>(PeerSignaling(online_id, member_id, port_online_id, port_member_id, room_id));
link->member_id = member_id;
link->online_id = online_id;
link->endpoint = endpoint;
link->local_endpoint = local_endpoint;
link->port_endpoint = port_endpoint;
link->created = now();
link->inner = udp_bind("127.0.0.1", 0, &link->inner_endpoint);
peers_[member_id] = link;
}
log(fmt("p2p: member %d (%s) at %s -> port sees 127.0.0.1:%d", member_id, online_id.c_str(),
endpoint.valid() ? endpoint.str().c_str() : "?:0", link->inner_endpoint.port));
return link->inner_endpoint;
}
void Gateway::remove_peer(int member_id) {
LinkPtr link;
{
std::lock_guard<std::mutex> lk(mu_);
auto it = peers_.find(member_id);
if (it == peers_.end()) return;
link = it->second;
peers_.erase(it);
close_link(*link);
}
log(fmt("p2p: member %d removed (to peer %llu, to port %llu, signaling %llu)", member_id,
static_cast<unsigned long long>(link->to_peer), static_cast<unsigned long long>(link->to_port),
static_cast<unsigned long long>(link->signaling_count)));
}
void Gateway::clear_peers() {
std::vector<int> ids;
{
std::lock_guard<std::mutex> lk(mu_);
for (const auto& [id, link] : peers_) ids.push_back(id);
}
for (int id : ids) remove_peer(id);
}
void Gateway::remove_peers_except(const std::set<std::string>& online_ids) {
std::vector<int> ids;
{
std::lock_guard<std::mutex> lk(mu_);
for (const auto& [id, link] : peers_)
if (!online_ids.count(link->online_id)) ids.push_back(id);
}
for (int id : ids) remove_peer(id);
}
std::optional<Endpoint> Gateway::inner_endpoint_for(const std::string& online_id) const {
std::lock_guard<std::mutex> lk(mu_);
for (const auto& [id, link] : peers_)
if (link->online_id == online_id) return link->inner_endpoint;
return std::nullopt;
}
std::vector<PeerInfo> Gateway::peers() const {
std::lock_guard<std::mutex> lk(mu_);
std::vector<PeerInfo> out;
for (const auto& [id, l] : peers_) {
PeerInfo p;
p.member_id = id;
p.online_id = l->online_id;
p.endpoint = l->endpoint;
p.inner = l->inner_endpoint;
p.peer_member_id = l->signaling.peer_member_id;
p.port_member_id = l->signaling.port_member_id;
p.room_id = l->signaling.room_id;
p.to_peer = l->to_peer;
p.to_port = l->to_port;
p.signaling = l->signaling_count;
out.push_back(p);
}
return out;
}
bool Gateway::relay_target(const Endpoint& e) const {
return relay_ && e.valid() && e.ip == relay_->server.ip && e.port != relay_->server.port;
}
void Gateway::send_outer(const std::string& datagram, const Endpoint& to) {
if (!to.valid()) return;
std::optional<Relay> relay;
{
std::lock_guard<std::mutex> lk(mu_);
relay = relay_;
}
if (relay && to.ip == relay->server.ip && to.port != relay->server.port)
udp_send(outer_, relay_wrap(relay->token, to.port, datagram), relay->server);
else
udp_send(outer_, datagram, to);
}
void Gateway::to_port(const LinkPtr& link, const std::string& frame) {
SOCKET s;
std::optional<Endpoint> dst;
{
std::lock_guard<std::mutex> lk(mu_);
s = link->inner;
dst = link->port_endpoint;
}
if (s != INVALID_SOCKET && dst) udp_send(s, frame, *dst);
}
Gateway::LinkPtr Gateway::link_for(const Endpoint& src) {
for (auto& [id, link] : peers_) {
if (src == link->endpoint || src == link->local_endpoint || link->aliases.count(src)) {
if (src != link->endpoint && link->aliases.count(src)) link->endpoint = src; // answer where it last spoke
return link;
}
}
// A NAT may hand the peer's datagrams a new port, and a relayed bbhost peer
// can reach us from two relay ports (the one the room lists and the one
// signaling resolved). With one peer from that address, take the new port
// as another of its endpoints.
LinkPtr only;
int same = 0;
for (auto& [id, link] : peers_)
if (link->endpoint.valid() && link->endpoint.ip == src.ip) {
only = link;
++same;
}
if (same != 1) return nullptr;
only->aliases.insert(only->endpoint);
only->aliases.insert(src);
log(fmt("p2p: member %d (%s) also at %s", only->member_id, only->online_id.c_str(), src.str().c_str()));
only->endpoint = src;
return only;
}
void Gateway::outer_main() {
std::string buf(65536, '\0');
while (!stop_) {
sockaddr_in from{};
socklen_t flen = sizeof(from);
const int n = recvfrom(outer_, buf.data(), static_cast<int>(buf.size()), 0,
reinterpret_cast<sockaddr*>(&from), &flen);
if (n < 0) {
if (stop_) return;
continue;
}
Endpoint src = from_sockaddr(from);
std::string data(buf.data(), static_cast<size_t>(n));
{
std::lock_guard<std::mutex> lk(mu_);
int port;
std::string inner;
if (relay_ && src == relay_->server && relay_unwrap(data, port, inner)) {
src = Endpoint{relay_->server.ip, port};
data = std::move(inner);
}
}
if (data.size() >= 20 && data[0] == 0x01 && data[1] == 0x01) {
on_stun(data);
continue;
}
LinkPtr link;
{
std::lock_guard<std::mutex> lk(mu_);
link = link_for(src);
if (data.compare(0, 5, kProbe, 0, 5) == 0) {
if (link) link->heard_peer = true;
continue;
}
}
std::string frame;
if (!link || !ps4_to_port(data, frame)) continue;
{
std::lock_guard<std::mutex> lk(mu_);
link->heard_peer = true;
++link->to_port;
}
to_port(link, frame);
}
}
void Gateway::inner_main() {
std::string buf(65536, '\0');
while (!stop_) {
std::map<SOCKET, LinkPtr> links;
{
std::lock_guard<std::mutex> lk(mu_);
for (auto& [id, link] : peers_)
if (link->inner != INVALID_SOCKET) links[link->inner] = link;
}
if (links.empty()) {
Sleep(50);
continue;
}
fd_set rd;
FD_ZERO(&rd);
SOCKET highest = 0; // select()'s first argument: ignored on Windows, the highest fd + 1 elsewhere
for (auto& [s, l] : links) {
FD_SET(s, &rd);
highest = std::max(highest, s);
}
timeval tv{0, 100000};
if (select(static_cast<int>(highest) + 1, &rd, nullptr, nullptr, &tv) <= 0) continue; // a socket closed under us: rebuild
for (auto& [s, link] : links) {
if (!FD_ISSET(s, &rd)) continue;
sockaddr_in from{};
socklen_t flen = sizeof(from);
const int n = recvfrom(s, buf.data(), static_cast<int>(buf.size()), 0,
reinterpret_cast<sockaddr*>(&from), &flen);
if (n < 0) continue;
const std::string data(buf.data(), static_cast<size_t>(n));
std::string out;
const Frame kind = port_to_ps4(data, out);
Endpoint peer;
std::vector<std::string> replies;
{
std::lock_guard<std::mutex> lk(mu_);
link->port_endpoint = from_sockaddr(from);
if (kind == Frame::Game) {
++link->to_peer;
peer = link->endpoint;
} else if (kind == Frame::Signaling) {
++link->signaling_count;
replies = link->signaling.handle(out);
}
}
if (kind == Frame::Game) send_outer(out, peer);
for (const auto& r : replies) to_port(link, port_signaling_frame(r));
}
}
}
void Gateway::tick_main() {
double last_stun = -1e9, last_punch = 0, last_opening = 0;
while (!stop_) {
Sleep(100);
const double t = now();
if (stun_server_ && t - last_stun >= stun_refresh) {
last_stun = t;
send_stun();
}
if (t - last_punch >= punch_interval) {
last_punch = t;
std::vector<Endpoint> targets;
{
std::lock_guard<std::mutex> lk(mu_);
for (auto& [id, l] : peers_) {
if (l->heard_peer || t - l->created > punch_for || relay_target(l->endpoint)) continue;
if (l->endpoint.valid()) targets.push_back(l->endpoint);
if (l->local_endpoint.valid()) targets.push_back(l->local_endpoint);
}
}
for (const auto& e : targets) udp_send(outer_, kProbe, e);
}
if (t - last_opening >= opening_interval) {
last_opening = t;
std::vector<std::pair<LinkPtr, std::vector<std::string>>> openings;
{
std::lock_guard<std::mutex> lk(mu_);
for (auto& [id, l] : peers_)
if (!l->signaling.heard && t - l->created <= opening_for)
openings.emplace_back(l, l->signaling.opening());
}
for (auto& [l, payloads] : openings)
for (const auto& p : payloads) to_port(l, port_signaling_frame(p));
}
}
}
void Gateway::send_stun() {
const std::string ip = resolve_ipv4(stun_server_->first);
if (ip.empty()) {
log(fmt("p2p: cannot resolve STUN server %s", stun_server_->first.c_str()));
return;
}
std::string request;
Endpoint server{ip, stun_server_->second};
{
std::lock_guard<std::mutex> lk(mu_);
const std::string token = relay_ && relay_->server.ip == ip ? relay_->token : "";
request = stun_request(token, stun_txid_);
stun_server_ep_ = server;
}
udp_send(outer_, request, server);
}
void Gateway::on_stun(const std::string& data) {
std::optional<Endpoint> changed;
{
std::lock_guard<std::mutex> lk(mu_);
StunAnswer a;
if (stun_txid_.empty() || !stun_parse(data, stun_txid_, a)) return;
if (a.relay && stun_server_ep_.valid()) {
if (!relay_ || relay_->port != a.relay_port) log(fmt("p2p: relay on, our port on it is %d", a.relay_port));
relay_ = Relay{stun_server_ep_, a.relay_token, a.relay_port};
} else if (relay_) {
log("p2p: relay off");
relay_.reset();
}
if (!mapped_ || *mapped_ != a.mapped) {
log("p2p: STUN sees us as " + a.mapped.str());
mapped_ = a.mapped;
changed = a.mapped;
}
}
if (changed && on_mapped_change_) {
try {
on_mapped_change_(*changed);
} catch (const std::exception& e) {
log(std::string("p2p: mapped-change callback failed: ") + e.what());
}
}
}
} // namespace bb::p2p
+163
View File
@@ -0,0 +1,163 @@
// P2P gateway: carries the game's UDP traffic between bbport and bbhost players.
//
// bbport (gpu/shadps4/core/libraries/network/p2p_sockets.cpp)
// game datagram: [src vport u16 be][dst vport u16 be][payload]
// dst vport 0xffff: internal "SHAD" packets - 0x05/0x06 echo (17 B),
// 0x07 handshake (62 B), 0x10 control (50 B), 0x21 matching2 handshake (50 B)
// bbhost (src/hle/net.cpp, src/net/stun.cpp)
// game datagram: [0xff][flags][src][dst][payload] - flags 0xC3 with one-byte
// vports (Bloodborne's 40 and 30), 0x83 with two-byte, +4 bytes with 0x20
// hole-punch probe "\xfebbhp\0\0\0"; STUN Binding + BBHOST-HELLO (0x8100);
// relay: to the STUN port [0xfb]['R'][token 8][port u16][datagram],
// from it [0xfb]['r'][source port u16][datagram]
//
// One OUTER socket faces the internet and speaks bbhost's format; each remote
// member gets an INNER socket on 127.0.0.1, which is where the port is told the
// member lives. bbport's signaling handshakes are answered on the bbhost
// peer's behalf (bbhost has none: its connection state comes from the server).
#pragma once
#include <atomic>
#include <functional>
#include <map>
#include <memory>
#include <mutex>
#include <optional>
#include <set>
#include <string>
#include <thread>
#include <utility>
#include <vector>
#include "util.hpp"
namespace bb::p2p {
// ---- framing (pure, unit-tested) ----
enum class Frame { None, Game, Signaling };
Frame port_to_ps4(const std::string& frame, std::string& out); // Game: bbhost datagram; Signaling: payload
bool ps4_to_port(const std::string& datagram, std::string& out);
std::string port_signaling_frame(const std::string& payload);
std::string relay_wrap(const std::string& token, int port, const std::string& datagram);
bool relay_unwrap(const std::string& data, int& port, std::string& datagram);
extern const std::string kProbe;
struct StunAnswer {
Endpoint mapped;
bool relay = false;
std::string relay_token; // 8 bytes
int relay_port = 0;
};
std::string stun_request(const std::string& token, std::string& txid); // token "" = zeros
bool stun_parse(const std::string& data, const std::string& txid, StunAnswer& out);
// ---- the port's signaling, answered for one bbhost peer ----
enum : uint8_t { T_ECHO_PING = 0x05, T_ECHO_PONG = 0x06, T_HANDSHAKE = 0x07, T_CONTROL = 0x10, T_M2 = 0x21 };
enum : uint8_t { HS_OFFER = 1, HS_ACCEPT = 2, HS_CHECK = 3, HS_CHECK_ACK = 4 };
enum : uint8_t { CTL_ACTIVATION_REQUEST = 1, CTL_ACTIVATION_ACK = 2, CTL_CLOSE = 3, CTL_ESTABLISHED = 4 };
class PeerSignaling {
public:
PeerSignaling(const std::string& peer_online_id, int peer_member_id, const std::string& port_online_id,
int port_member_id, uint64_t room_id);
std::vector<std::string> handle(const std::string& payload); // replies (payloads, no frame header)
std::vector<std::string> opening() const; // what a real peer sends first
std::string peer_id, port_id; // 16 bytes each, NUL padded
int peer_member_id = 0; // 0 until the peer is a room member
int port_member_id = 0;
uint64_t room_id = 0;
uint32_t conn_id = 0;
bool sent_established = false;
bool heard = false; // any signaling from the port for this peer
private:
std::string handshake(uint8_t kind, uint32_t to_conn, uint64_t nonce) const;
std::string control(uint8_t kind) const;
std::string m2(uint8_t kind, uint64_t nonce) const;
};
// ---- the gateway ----
struct PeerInfo { // a snapshot, for the app and the tests
int member_id = 0;
std::string online_id;
Endpoint endpoint, inner;
int peer_member_id = 0, port_member_id = 0;
uint64_t room_id = 0;
uint64_t to_peer = 0, to_port = 0, signaling = 0;
};
class Gateway {
public:
Gateway(int outer_port, std::optional<std::pair<std::string, int>> stun_server, std::string lan_addr,
std::function<void(const Endpoint&)> on_mapped_change);
~Gateway();
bool start(std::string* error);
void stop();
std::pair<Endpoint, Endpoint> endpoints() const; // (local, mapped) as bbhost registers them
std::optional<Endpoint> mapped() const;
int relay_port() const;
// Returns the 127.0.0.1 endpoint the port should use for this member.
Endpoint add_peer(int member_id, const std::string& online_id, const Endpoint& endpoint,
const Endpoint& local_endpoint, const std::string& port_online_id, int port_member_id,
uint64_t room_id, const std::optional<Endpoint>& port_endpoint);
void remove_peer(int member_id);
void clear_peers();
void remove_peers_except(const std::set<std::string>& online_ids);
std::optional<Endpoint> inner_endpoint_for(const std::string& online_id) const;
std::vector<PeerInfo> peers() const;
double punch_interval = 0.5, punch_for = 20.0, opening_interval = 1.0, opening_for = 30.0,
stun_refresh = 15.0;
private:
struct Link {
int member_id = 0;
std::string online_id;
Endpoint endpoint, local_endpoint;
std::set<Endpoint> aliases;
std::optional<Endpoint> port_endpoint;
PeerSignaling signaling;
SOCKET inner = INVALID_SOCKET;
Endpoint inner_endpoint;
bool heard_peer = false;
double created = 0;
uint64_t to_peer = 0, to_port = 0, signaling_count = 0;
Link(PeerSignaling s) : signaling(std::move(s)) {}
};
using LinkPtr = std::shared_ptr<Link>;
void outer_main();
void inner_main();
void tick_main();
void send_outer(const std::string& datagram, const Endpoint& to);
void to_port(const LinkPtr& link, const std::string& frame);
LinkPtr link_for(const Endpoint& src); // under mu_
bool relay_target(const Endpoint& e) const; // under mu_
void send_stun();
void on_stun(const std::string& data);
static void close_link(Link& link);
int outer_port_ = 0;
std::optional<std::pair<std::string, int>> stun_server_;
std::string lan_addr_;
std::function<void(const Endpoint&)> on_mapped_change_;
SOCKET outer_ = INVALID_SOCKET;
mutable std::mutex mu_;
std::map<int, LinkPtr> peers_;
std::optional<Endpoint> mapped_;
struct Relay {
Endpoint server; // its STUN endpoint
std::string token;
int port = 0; // ours on it
};
std::optional<Relay> relay_;
std::string stun_txid_;
Endpoint stun_server_ep_;
std::atomic<bool> stop_{false};
std::vector<std::thread> threads_;
};
} // namespace bb::p2p
+85
View File
@@ -0,0 +1,85 @@
#include "proto3.hpp"
#include <cstring>
namespace pb {
namespace {
bool read_varint(const std::string& b, size_t& pos, uint64_t& out) {
out = 0;
int shift = 0;
while (true) {
if (pos >= b.size() || shift > 63) return false;
const uint8_t c = static_cast<uint8_t>(b[pos++]);
out |= static_cast<uint64_t>(c & 0x7F) << shift;
if (!(c & 0x80)) return true;
shift += 7;
}
}
} // namespace
bool parse(const std::string& b, std::vector<Field>& out) {
size_t pos = 0;
while (pos < b.size()) {
uint64_t tag = 0;
if (!read_varint(b, pos, tag)) return false;
Field f;
f.number = static_cast<int>(tag >> 3);
f.wire = static_cast<int>(tag & 7);
switch (f.wire) {
case 0:
if (!read_varint(b, pos, f.value)) return false;
break;
case 2: {
uint64_t len = 0;
if (!read_varint(b, pos, len) || len > b.size() - pos) return false;
f.data.assign(b, pos, static_cast<size_t>(len));
pos += static_cast<size_t>(len);
break;
}
case 5:
if (b.size() - pos < 4) return false;
{ uint32_t v; std::memcpy(&v, b.data() + pos, 4); f.value = v; }
pos += 4;
break;
case 1:
if (b.size() - pos < 8) return false;
std::memcpy(&f.value, b.data() + pos, 8);
pos += 8;
break;
default:
return false;
}
out.push_back(std::move(f));
}
return true;
}
uint64_t Message::u(int field, uint64_t def) const {
uint64_t v = def;
for (const auto& f : fields_)
if (f.number == field && f.wire != 2) v = f.value;
return v;
}
std::string Message::s(int field) const {
std::string v;
for (const auto& f : fields_)
if (f.number == field && f.wire == 2) v = f.data;
return v;
}
bool Message::has(int field) const {
for (const auto& f : fields_)
if (f.number == field) return true;
return false;
}
std::vector<std::string> Message::all(int field) const {
std::vector<std::string> v;
for (const auto& f : fields_)
if (f.number == field && f.wire == 2) v.push_back(f.data);
return v;
}
} // namespace pb
+73
View File
@@ -0,0 +1,73 @@
// Minimal protobuf3 wire format: varint and length-delimited fields, which is
// all the shadNet messages use. proto3 semantics: fields at their default value
// are omitted on encode; decode keeps what is on the wire.
#pragma once
#include <cstdint>
#include <string>
#include <vector>
namespace pb {
class Writer {
public:
void varint(uint64_t v) {
while (v >= 0x80) {
buf_.push_back(static_cast<char>((v & 0x7F) | 0x80));
v >>= 7;
}
buf_.push_back(static_cast<char>(v));
}
void tag(int field, int wire) { varint((static_cast<uint64_t>(field) << 3) | static_cast<uint64_t>(wire)); }
Writer& u(int field, uint64_t v) { // uint / int (0 omitted)
if (v) { tag(field, 0); varint(v); }
return *this;
}
Writer& i(int field, int64_t v) { return u(field, static_cast<uint64_t>(v)); }
Writer& flag(int field, bool v) { // bool (false omitted)
if (v) { tag(field, 0); varint(1); }
return *this;
}
Writer& s(int field, const std::string& v) { // string / bytes ("" omitted)
if (!v.empty()) { tag(field, 2); varint(v.size()); buf_ += v; }
return *this;
}
Writer& msg(int field, const std::string& encoded) { // embedded message, always written
tag(field, 2);
varint(encoded.size());
buf_ += encoded;
return *this;
}
const std::string& str() const { return buf_; }
private:
std::string buf_;
};
struct Field {
int number = 0;
int wire = 0;
uint64_t value = 0; // varint / fixed
std::string data; // length-delimited
};
bool parse(const std::string& buf, std::vector<Field>& out);
// Read access by field number; scalars keep the last occurrence.
class Message {
public:
Message() = default;
explicit Message(const std::string& buf) { ok_ = parse(buf, fields_); }
bool ok() const { return ok_; }
uint64_t u(int field, uint64_t def = 0) const;
std::string s(int field) const;
bool has(int field) const;
std::vector<std::string> all(int field) const;
private:
std::vector<Field> fields_;
bool ok_ = false;
};
} // namespace pb
+262
View File
@@ -0,0 +1,262 @@
#include "setup.hpp"
#include <tlhelp32.h>
#include <fstream>
#include <sstream>
namespace bb {
namespace {
constexpr const char* kBackup = ".before-bbbridge";
constexpr const char* kMarker = "_bbbridge";
constexpr const char* kP2pVar = "SHADPS4_P2P_PORT";
constexpr int kP2pPort = 3658;
constexpr const char* kBridgeServer = "127.0.0.1:31313";
constexpr const char* kBridgeWebapi = "http://127.0.0.1:31315";
constexpr const char* kPublicServer = "srv.shadps4.net:31313";
constexpr const char* kPlaceholderPassword = "bbbridge"; // see enable()
// bbhost's play-log patch (bbhost src/engine/playlog.cpp) in bbport's patch
// format: the float immediates of `mov dword [rip+disp], imm32` in
// SprjPlaylogSystem's static initializer - 0x23F9B86 stores 300.0f (upload
// period), 0x23F9B68 stores 1.5f (position period); the float is at +6.
// Verified against eboot.bin 1.09.
const char* kPatchXml = R"xml(<?xml version="1.0"?>
<Patch>
<TitleID>
<ID>CUSA03173</ID>
</TitleID>
<Metadata Title="Bloodborne" Name="Live map: play log every 5 s, position every 0.5 s" Note="Uploads the play log every 5 s instead of 300 s and samples the position every 0.5 s instead of 1.5 s, as bbhost does, so the player shows on thehuntersdream.com's live map." Author="bbbridge (from bbhost)" PatchVer="1.0" AppVer="01.09" AppElf="eboot.bin" isEnabled="true">
<PatchList>
<Line Type="float32" Address="0x023F9B8C" Value="5.0"/>
<Line Type="float32" Address="0x023F9B6E" Value="0.5"/>
</PatchList>
</Metadata>
</Patch>
)xml";
nlohmann::ordered_json overrides() {
return {{kMarker, "Written by bbbridge: the game's server traffic goes through bbbridge. Move bbbridge's "
"Online switch to shadNet to remove."},
{"https://ss4.scej-network.jp:20443", "http://127.0.0.1:31316"},
{"https://thehuntersdream.com:20443", "http://127.0.0.1:31316"},
{"https://thehuntersdream.com:18671", "http://127.0.0.1:31317"},
{"http://thehuntersdream.com:18671", "http://127.0.0.1:31317"},
{"https://bb-playlog-test.s3.amazonaws.com:443", "http://127.0.0.1:31317"},
{"https://bb-playlog-prod.s3.amazonaws.com:443", "http://127.0.0.1:31317"},
{"https://thehuntersdream.com:18671/summon_messenger", "http://127.0.0.1:31315"},
{"http://thehuntersdream.com:18671/summon_messenger", "http://127.0.0.1:31315"}};
}
using ojson = nlohmann::ordered_json;
ojson read_json(const fs::path& p) {
std::ifstream in(p);
if (!in) return ojson();
ojson j = ojson::parse(in, nullptr, false);
return j.is_discarded() ? ojson() : j;
}
void write_json(const fs::path& p, const ojson& j) {
const fs::path tmp = p.wstring() + L".tmp";
{
std::ofstream out(tmp, std::ios::trunc);
out << j.dump(2, ' ', false) << "\n";
}
MoveFileExW(tmp.c_str(), p.c_str(), MOVEFILE_REPLACE_EXISTING);
}
void backup(const fs::path& p) {
const fs::path target = p.wstring() + widen(kBackup);
std::error_code ec;
if (fs::exists(p, ec) && !fs::exists(target, ec)) fs::copy_file(p, target, ec);
}
std::string merge_env(const std::string& extra, const std::string& key, const std::string* value) {
std::istringstream in(extra);
std::string item, out;
while (in >> item)
if (item.rfind(key + "=", 0) != 0) out += (out.empty() ? "" : " ") + item;
if (value) out += (out.empty() ? "" : " ") + key + "=" + *value;
return out;
}
// Ours, or the Python bbbridge's (0.2.0: "_note": "bbbridge: ...").
bool ours(const fs::path& overrides_file) {
const ojson j = read_json(overrides_file);
return j.is_object() && (j.contains(kMarker) || jstr(j, "_note").rfind("bbbridge:", 0) == 0);
}
} // namespace
bool process_running(const wchar_t* image) {
HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (snap == INVALID_HANDLE_VALUE) return false;
PROCESSENTRY32W e{};
e.dwSize = sizeof(e);
bool found = false;
for (BOOL ok = Process32FirstW(snap, &e); ok && !found; ok = Process32NextW(snap, &e))
found = _wcsicmp(e.szExeFile, image) == 0;
CloseHandle(snap);
return found;
}
bool launcher_running() { return process_running(L"Bloodborne.exe"); }
bool game_running() { return process_running(L"Play Bloodborne.exe") || process_running(L"bb-probe.exe"); }
bool is_bbport_dir(const fs::path& dir) {
std::error_code ec;
return fs::is_regular_file(dir / L"Bloodborne.exe", ec) || fs::is_regular_file(dir / L"Play Bloodborne.exe", ec);
}
fs::path find_bbport(const fs::path& exe_dir, const fs::path& chosen, const std::string& game_dir,
BbportSource* source) {
const std::pair<fs::path, BbportSource> candidates[] = {
{chosen, BbportSource::Chosen},
{exe_dir.parent_path(), BbportSource::ExeFolder}, // bbbridge lives in a folder inside bbport's
{exe_dir, BbportSource::ExeFolder}, // or right next to Bloodborne.exe
{game_dir.empty() ? fs::path() : fs::path(widen(game_dir)).parent_path(), BbportSource::Launcher}};
for (const auto& [dir, src] : candidates)
if (!dir.empty() && is_bbport_dir(dir)) {
if (source) *source = src;
return dir;
}
if (source) *source = BbportSource::None;
return {};
}
std::string describe(BbportSource source) {
switch (source) {
case BbportSource::Chosen: return "the folder you chose";
case BbportSource::ExeFolder: return "the folder bbbridge is in";
case BbportSource::Launcher: return "found from the bbport launcher's game folder";
default: return "not found";
}
}
BbportSetup::BbportSetup(const fs::path& exe_dir, const fs::path& bbport_dir) : exe_dir_(exe_dir), chosen_(bbport_dir) {
reload();
}
void BbportSetup::reload() {
wchar_t appdata[MAX_PATH] = {};
if (!GetEnvironmentVariableW(L"APPDATA", appdata, MAX_PATH)) throw SetupError("APPDATA is not set");
settings_file.clear();
for (const wchar_t* name : {L"bbport-launcher", L"bbport-launcher-coop"}) {
const fs::path p = fs::path(appdata) / name / L"settings.json";
std::error_code ec;
if (fs::is_regular_file(p, ec)) {
settings_file = p;
break;
}
}
if (settings_file.empty())
throw SetupError("bbport launcher settings not found. Start the bbport launcher once and close it.");
settings = read_json(settings_file);
if (!settings.is_object()) throw SetupError("the bbport launcher's settings.json cannot be read");
bbport = find_bbport(exe_dir_, chosen_, jstr(settings, "game_dir"), &source);
if (bbport.empty()) throw SetupError("bbport folder not found - choose the folder with Bloodborne.exe.");
const std::string user = jstr(settings, "user_dir"), patches = jstr(settings, "patches_dir");
user_dir = user.empty() ? bbport / L"user" : fs::path(widen(user));
patches_dir = patches.empty() ? bbport / L"patches" : fs::path(widen(patches));
}
bool BbportSetup::routed() const {
return jint(settings, "online", 0) && jstr(settings, "online_server") == kBridgeServer &&
jstr(settings, "online_webapi") == kBridgeWebapi && !jstr(settings, "online_password").empty();
}
bool BbportSetup::overrides_written() const { return ours(overrides_file()); }
// bbport's run.py uses host_overrides.custom.json, when there is one, instead of
// its own addresses: ours sends the game's server traffic to bbbridge, so it may
// only be there while bbbridge runs - else the game cannot reach its server
// ("Failed to acquire Bloodborne server information").
void BbportSetup::set_overrides(bool on) {
std::error_code ec;
if (on) {
fs::create_directories(user_dir, ec);
if (fs::exists(overrides_file(), ec) && !ours(overrides_file())) backup(overrides_file());
write_json(overrides_file(), overrides());
} else if (ours(overrides_file())) {
fs::remove(overrides_file(), ec);
const fs::path prev = overrides_file().wstring() + widen(kBackup);
if (fs::exists(prev, ec)) fs::rename(prev, overrides_file(), ec);
}
}
bool BbportSetup::game_ready() const {
const std::string dir = jstr(settings, "game_dir");
std::error_code ec;
return !dir.empty() && fs::is_regular_file(fs::path(widen(dir)) / L"eboot.bin", ec);
}
bool BbportSetup::patched() const {
std::error_code ec;
return fs::is_regular_file(patch_file(), ec);
}
void BbportSetup::save_settings() {
backup(settings_file);
write_json(settings_file, settings);
}
void BbportSetup::set_npid(const std::string& npid) {
settings["online_npid"] = npid;
save_settings();
}
bool BbportSetup::shadnet_account_set() const {
const std::string pw = jstr(settings, "online_password");
return !npid().empty() && !pw.empty() && pw != kPlaceholderPassword;
}
void BbportSetup::enable() {
// On bbport's own server now: keep that setup (server, shadNet account) for disable(),
// also when the player changed it since the last time.
if (jstr(settings, "online_server") != kBridgeServer)
write_json(settings_file.wstring() + widen(kBackup), settings);
settings["online"] = true;
settings["online_server"] = kBridgeServer;
settings["online_webapi"] = kBridgeWebapi;
// bbport does not go online without a password (np_handler.cpp: "shadNet
// enabled but credentials missing, skipping"). The bridge never reads it; a
// player without a shadNet account just needs something there.
if (jstr(settings, "online_password").empty()) settings["online_password"] = kPlaceholderPassword;
const std::string port = std::to_string(kP2pPort);
settings["extra_env"] = merge_env(jstr(settings, "extra_env"), kP2pVar, &port);
save_settings();
set_overrides(true);
}
void BbportSetup::disable() {
const ojson old = read_json(settings_file.wstring() + widen(kBackup));
std::string server = jstr(old, "online_server", kPublicServer);
if (server.empty() || server == kBridgeServer) server = kPublicServer;
std::string webapi = jstr(old, "online_webapi", "");
if (webapi == kBridgeWebapi) webapi.clear();
settings["online_server"] = server;
settings["online_webapi"] = webapi;
if (jhas(old, "online_npid")) settings["online_npid"] = jstr(old, "online_npid"); // the shadNet account, not The Hunter's Dream's
if (jstr(settings, "online_password") == kPlaceholderPassword) { // ours: back to what it was
const std::string before = jstr(old, "online_password");
settings["online_password"] = before == kPlaceholderPassword ? "" : before;
}
settings["extra_env"] = merge_env(jstr(settings, "extra_env"), kP2pVar, nullptr);
save_settings();
set_overrides(false);
}
void BbportSetup::set_patch(bool on) {
std::error_code ec;
if (on) {
fs::create_directories(patches_dir, ec);
std::ofstream(patch_file(), std::ios::trunc) << kPatchXml;
} else {
fs::remove(patch_file(), ec);
}
}
} // namespace bb
+80
View File
@@ -0,0 +1,80 @@
// Points an installed bbport (bloodborne_pc for Windows) at the bridge, or back.
// Turning it on keeps bbport's own shadNet setup (server, account) in
// settings.json.before-bbbridge; turning it off puts that back.
// "Routed" means (a backup of every file is kept as *.before-bbbridge):
// 1. %APPDATA%\bbport-launcher\settings.json: online on; co-op server
// 127.0.0.1:31313; WebAPI http://127.0.0.1:31315; extra variable
// SHADPS4_P2P_PORT=3658 (the bridge must know the port's P2P UDP port).
// 2. <bbport user dir>\host_overrides.custom.json: the game's own server
// traffic goes through the bridge (the account token, and the play log to
// The Hunter's Dream instead of FromSoftware's closed upload bucket). Only
// while bbbridge runs: the app removes it when it closes.
// 3. <bbport>\patches\bbbridge-live-map.xml (optional): play log every 5 s,
// position every 0.5 s - bbhost's own patch - for the site's live map.
// The bbport launcher must be closed while settings.json changes: it rewrites
// the file when it saves.
#pragma once
#include <filesystem>
#include <string>
#include "util.hpp"
namespace bb {
namespace fs = std::filesystem;
struct SetupError : std::runtime_error {
using std::runtime_error::runtime_error;
};
bool process_running(const wchar_t* image);
bool launcher_running(); // Bloodborne.exe
bool game_running(); // Play Bloodborne.exe / bb-probe.exe
bool is_bbport_dir(const fs::path& dir);
// Where bbport was found, in this order: the folder the player chose, the
// folder bbbridge's own folder is in (the normal install), bbbridge's own folder
// (bbbridge.exe next to Bloodborne.exe), the launcher's game folder.
enum class BbportSource { None, Chosen, ExeFolder, Launcher };
fs::path find_bbport(const fs::path& exe_dir, const fs::path& chosen, const std::string& game_dir,
BbportSource* source);
std::string describe(BbportSource source);
class BbportSetup {
public:
// bbport_dir: "" = the folder above this exe, or from the launcher's settings.
explicit BbportSetup(const fs::path& exe_dir, const fs::path& bbport_dir = {});
void reload();
fs::path bbport, settings_file, user_dir, patches_dir;
BbportSource source = BbportSource::None;
nlohmann::ordered_json settings; // the launcher's file, key order kept
std::string npid() const { return jstr(settings, "online_npid"); }
std::string server() const { return jstr(settings, "online_server"); }
bool online() const { return jint(settings, "online", 0) != 0; }
bool shadnet_account_set() const; // a name and a real (not bbbridge's placeholder) password
bool routed() const; // bbport's settings point at bbbridge
bool overrides_written() const; // our host_overrides.custom.json is there
void set_overrides(bool on); // only while bbbridge runs (see setup.cpp)
bool patched() const;
bool game_ready() const; // the launcher has a game folder with eboot.bin
// the caller makes sure the launcher is closed
void set_npid(const std::string& npid);
void enable();
void disable();
void set_patch(bool on);
fs::path play_exe() const { return bbport / L"Play Bloodborne.exe"; }
fs::path launcher_exe() const { return bbport / L"Bloodborne.exe"; }
private:
void save_settings();
fs::path overrides_file() const { return user_dir / L"host_overrides.custom.json"; }
fs::path patch_file() const { return patches_dir / L"bbbridge-live-map.xml"; }
fs::path exe_dir_, chosen_;
};
} // namespace bb
+103
View File
@@ -0,0 +1,103 @@
// shadNet wire protocol: 15-byte packet framing, command / error / notification
// numbers, and the field numbers of the messages the bridge reads or writes.
// Sources: shadnet-p2p's protocol.h and shadnet.proto, the port's client.cpp.
#pragma once
#include <cstdint>
#include <cstring>
#include <string>
namespace shadnet {
constexpr size_t kHeaderSize = 15;
constexpr uint32_t kMaxPacketSize = 0x800000;
constexpr uint32_t kProtocolVersion = 1;
enum PacketType : uint8_t { Request = 0, Reply = 1, Notification = 2, ServerInfo = 3 };
namespace cmd {
constexpr uint16_t Login = 0, Terminate = 1, AddFriend = 8, RemoveFriend = 9, AddBlock = 10,
RemoveBlock = 11, GetServerFeatures = 12, SetClientVersion = 13, GetToken = 39,
SetAppearOffline = 40, ContextStart = 100, CreateRoom = 101, JoinRoom = 102, LeaveRoom = 103,
SearchRoom = 104, RequestSignalingInfos = 105, ContextStop = 106,
SetRoomDataInternal = 108, SetRoomDataExternal = 109, KickoutRoomMember = 110,
GetWorldInfoList = 111;
}
namespace err {
constexpr uint8_t NoError = 0, Malformed = 1, Invalid = 2, InvalidInput = 3, LoginError = 5,
LoginInvalidPassword = 8, RoomMissing = 14, NotFound = 26, Unsupported = 33;
}
namespace notify {
constexpr uint16_t RoomEvent = 10;
}
namespace room_event {
constexpr uint32_t MemberJoined = 0x1101, MemberLeft = 0x1102, KickedOut = 0x1103;
}
namespace cause {
constexpr uint32_t Leave = 1, Kickout = 2, ServerOperation = 4, MemberDisappeared = 5;
}
constexpr uint32_t kOwnerFlag = 0x80000000; // SCE_NP_MATCHING2_ROOMMEMBER_FLAG_ATTR_OWNER
struct Header {
uint8_t type = 0;
uint16_t command = 0;
uint32_t size = 0;
uint64_t packet_id = 0;
};
inline bool parse_header(const std::string& buf, Header& h) {
if (buf.size() < kHeaderSize) return false;
const char* p = buf.data();
h.type = static_cast<uint8_t>(p[0]);
std::memcpy(&h.command, p + 1, 2);
std::memcpy(&h.size, p + 3, 4);
std::memcpy(&h.packet_id, p + 7, 8);
return true;
}
inline std::string build_packet(uint8_t type, uint16_t command, uint64_t packet_id, const std::string& payload) {
std::string out(kHeaderSize, '\0');
const uint32_t size = static_cast<uint32_t>(kHeaderSize + payload.size());
out[0] = static_cast<char>(type);
std::memcpy(&out[1], &command, 2);
std::memcpy(&out[3], &size, 4);
std::memcpy(&out[7], &packet_id, 8);
return out + payload;
}
inline std::string build_server_info() {
std::string v(4, '\0');
std::memcpy(v.data(), &kProtocolVersion, 4);
return build_packet(ServerInfo, 0, 0, v);
}
inline std::string build_reply(uint16_t command, uint64_t packet_id, uint8_t error, const std::string& payload) {
return build_packet(Reply, command, packet_id, std::string(1, static_cast<char>(error)) + payload);
}
// Every proto payload is framed as a u32-LE length + the message.
inline std::string proto_blob(const std::string& msg) {
std::string out(4, '\0');
const uint32_t n = static_cast<uint32_t>(msg.size());
std::memcpy(out.data(), &n, 4);
return out + msg;
}
inline bool parse_proto_blob(const std::string& payload, std::string& msg) {
if (payload.size() < 4) return false;
uint32_t n;
std::memcpy(&n, payload.data(), 4);
msg = payload.substr(4, n);
return true;
}
inline std::string build_notification(uint16_t type, const std::string& msg) {
return build_packet(Notification, type, 0, proto_blob(msg));
}
} // namespace shadnet
+197
View File
@@ -0,0 +1,197 @@
#include "util.hpp"
#include <chrono>
#include <cstdarg>
#include <cstdio>
#include <ctime>
#include <fstream>
#include <iostream>
#include <memory>
#include <mutex>
#ifndef _WIN32
#include <csignal>
#endif
namespace bb {
namespace {
std::mutex g_log_mu;
std::function<void(const std::string&)> g_sink;
std::unique_ptr<std::ofstream> g_file;
} // namespace
std::string fmt(const char* format, ...) {
va_list args;
va_start(args, format);
va_list copy;
va_copy(copy, args);
const int n = std::vsnprintf(nullptr, 0, format, copy);
va_end(copy);
std::string out(n > 0 ? static_cast<size_t>(n) : 0, '\0');
if (n > 0) std::vsnprintf(out.data(), out.size() + 1, format, args);
va_end(args);
return out;
}
void log(const std::string& msg) {
std::time_t t = std::time(nullptr);
std::tm tm{};
localtime_s(&tm, &t);
char stamp[16];
std::strftime(stamp, sizeof(stamp), "%H:%M:%S", &tm);
const std::string line = std::string(stamp) + " [bbbridge] " + msg;
std::lock_guard<std::mutex> lk(g_log_mu);
std::fwrite(line.data(), 1, line.size(), stdout);
std::fputc('\n', stdout);
std::fflush(stdout);
if (g_file && *g_file) {
*g_file << line << '\n';
g_file->flush();
}
if (g_sink) g_sink(line);
}
void set_log_sink(std::function<void(const std::string&)> sink) {
std::lock_guard<std::mutex> lk(g_log_mu);
g_sink = std::move(sink);
}
void set_log_file(const std::wstring& path) {
std::lock_guard<std::mutex> lk(g_log_mu);
std::wstring prev = path;
const size_t dot = prev.rfind(L".log");
prev = (dot == std::wstring::npos ? prev : prev.substr(0, dot)) + L".prev.log";
replace_file(path, prev);
g_file = std::make_unique<std::ofstream>(fspath(path), std::ios::out | std::ios::trunc);
}
double now() {
using namespace std::chrono;
return duration_cast<duration<double>>(steady_clock::now().time_since_epoch()).count();
}
bool net_init() {
static bool ok = [] {
#ifdef _WIN32
WSADATA wsa;
return WSAStartup(MAKEWORD(2, 2), &wsa) == 0;
#else
// A send to a peer that already closed raises SIGPIPE on Linux, which ends the process;
// Windows returns an error, which the bridge handles.
std::signal(SIGPIPE, SIG_IGN);
return true;
#endif
}();
return ok;
}
sockaddr_in to_sockaddr(const Endpoint& e) {
sockaddr_in a{};
a.sin_family = AF_INET;
a.sin_port = htons(static_cast<u_short>(e.port));
inet_pton(AF_INET, e.ip.c_str(), &a.sin_addr);
return a;
}
Endpoint from_sockaddr(const sockaddr_in& a) {
char ip[INET_ADDRSTRLEN] = {};
inet_ntop(AF_INET, &a.sin_addr, ip, sizeof(ip));
return Endpoint{ip, ntohs(a.sin_port)};
}
std::string detect_lan_addr() {
// The interface the OS would route to the internet through (no packet is sent).
SOCKET s = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
if (s == INVALID_SOCKET) return "127.0.0.1";
sockaddr_in target = to_sockaddr(Endpoint{"8.8.8.8", 80});
std::string out = "127.0.0.1";
if (connect(s, reinterpret_cast<sockaddr*>(&target), sizeof(target)) == 0) {
sockaddr_in local{};
socklen_t len = sizeof(local);
if (getsockname(s, reinterpret_cast<sockaddr*>(&local), &len) == 0) out = from_sockaddr(local).ip;
}
closesocket(s);
return out;
}
std::string resolve_ipv4(const std::string& host) {
addrinfo hints{};
hints.ai_family = AF_INET;
addrinfo* res = nullptr;
if (getaddrinfo(host.c_str(), nullptr, &hints, &res) != 0 || !res) return "";
const std::string ip = from_sockaddr(*reinterpret_cast<sockaddr_in*>(res->ai_addr)).ip;
freeaddrinfo(res);
return ip;
}
#ifdef _WIN32
std::string narrow(const std::wstring& w) {
if (w.empty()) return {};
const int n = WideCharToMultiByte(CP_UTF8, 0, w.data(), static_cast<int>(w.size()), nullptr, 0, nullptr, nullptr);
std::string out(static_cast<size_t>(n), '\0');
WideCharToMultiByte(CP_UTF8, 0, w.data(), static_cast<int>(w.size()), out.data(), n, nullptr, nullptr);
return out;
}
std::wstring widen(const std::string& s) {
if (s.empty()) return {};
const int n = MultiByteToWideChar(CP_UTF8, 0, s.data(), static_cast<int>(s.size()), nullptr, 0);
std::wstring out(static_cast<size_t>(n), L'\0');
MultiByteToWideChar(CP_UTF8, 0, s.data(), static_cast<int>(s.size()), out.data(), n);
return out;
}
std::filesystem::path fspath(const std::wstring& w) { return std::filesystem::path(w); }
bool replace_file(const std::wstring& from, const std::wstring& to) {
return MoveFileExW(from.c_str(), to.c_str(), MOVEFILE_REPLACE_EXISTING) != 0;
}
#else
// wchar_t holds a whole code point (UTF-32) on Linux.
std::string narrow(const std::wstring& w) {
std::string out;
for (const wchar_t wc : w) {
const uint32_t c = static_cast<uint32_t>(wc);
if (c < 0x80) {
out += static_cast<char>(c);
} else if (c < 0x800) {
out += static_cast<char>(0xC0 | (c >> 6));
out += static_cast<char>(0x80 | (c & 0x3F));
} else if (c < 0x10000) {
out += static_cast<char>(0xE0 | (c >> 12));
out += static_cast<char>(0x80 | ((c >> 6) & 0x3F));
out += static_cast<char>(0x80 | (c & 0x3F));
} else {
out += static_cast<char>(0xF0 | (c >> 18));
out += static_cast<char>(0x80 | ((c >> 12) & 0x3F));
out += static_cast<char>(0x80 | ((c >> 6) & 0x3F));
out += static_cast<char>(0x80 | (c & 0x3F));
}
}
return out;
}
std::wstring widen(const std::string& s) {
std::wstring out;
for (size_t i = 0; i < s.size();) {
const unsigned char b = static_cast<unsigned char>(s[i]);
const int extra = b < 0x80 ? 0 : b < 0xE0 ? 1 : b < 0xF0 ? 2 : 3;
uint32_t c = extra == 0 ? b : extra == 1 ? (b & 0x1F) : extra == 2 ? (b & 0x0F) : (b & 0x07);
size_t j = 1;
for (; j <= static_cast<size_t>(extra) && i + j < s.size(); ++j)
c = (c << 6) | (static_cast<unsigned char>(s[i + j]) & 0x3F);
out += static_cast<wchar_t>(c);
i += j;
}
return out;
}
std::filesystem::path fspath(const std::wstring& w) { return std::filesystem::path(narrow(w)); }
bool replace_file(const std::wstring& from, const std::wstring& to) {
return std::rename(narrow(from).c_str(), narrow(to).c_str()) == 0;
}
#endif
} // namespace bb
+126
View File
@@ -0,0 +1,126 @@
// Shared helpers: logging, endpoints, Winsock, tolerant JSON field access.
#pragma once
#ifdef _WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#ifndef NOMINMAX
#define NOMINMAX
#endif
#include <winsock2.h>
#include <ws2tcpip.h>
#include <windows.h>
#else
// Linux: BSD sockets under Winsock's names, so the bridge's code is the same on both.
#include <arpa/inet.h>
#include <netdb.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cerrno>
#include <chrono>
#include <ctime>
#include <thread>
using SOCKET = int;
constexpr SOCKET INVALID_SOCKET = -1;
constexpr int SD_BOTH = SHUT_RDWR;
// Closing a socket does not wake a thread blocked in accept() or recvfrom() on it on Linux
// (Windows does): shutdown() first, which does, also for listening and unconnected UDP sockets.
inline int closesocket(SOCKET s) {
::shutdown(s, SHUT_RDWR);
return ::close(s);
}
inline int WSAGetLastError() { return errno; }
inline void Sleep(unsigned ms) { std::this_thread::sleep_for(std::chrono::milliseconds(ms)); }
inline void localtime_s(std::tm* out, const std::time_t* t) { localtime_r(t, out); }
#endif
#include <cstdint>
#include <filesystem>
#include <functional>
#include <string>
#include "nlohmann/json.hpp"
namespace bb {
using json = nlohmann::json;
constexpr const char* kVersion = "0.3.2";
// ---- logging: "HH:MM:SS [bbbridge] msg" to stdout, the log file and the app ----
void log(const std::string& msg);
std::string fmt(const char* format, ...);
void set_log_sink(std::function<void(const std::string&)> sink); // the app's log panel
void set_log_file(const std::wstring& path); // keeps the previous as *.prev.log
double now(); // seconds, monotonic
// ---- endpoints ----
struct Endpoint {
std::string ip;
int port = 0;
bool valid() const { return !ip.empty() && port > 0 && port <= 65535; }
bool operator==(const Endpoint& o) const { return ip == o.ip && port == o.port; }
bool operator!=(const Endpoint& o) const { return !(*this == o); }
bool operator<(const Endpoint& o) const { return ip != o.ip ? ip < o.ip : port < o.port; }
std::string str() const { return ip + ":" + std::to_string(port); }
};
bool net_init();
sockaddr_in to_sockaddr(const Endpoint& e);
Endpoint from_sockaddr(const sockaddr_in& a);
std::string detect_lan_addr();
std::string resolve_ipv4(const std::string& host); // "" when it fails
// ---- JSON as Python's dict.get(): tolerant of missing keys, null, numbers as strings ----
// Templates, so they serve json and ordered_json (settings files keep their key order).
template <typename J>
bool jhas(const J& j, const char* key) { // present and not null
if (!j.is_object()) return false;
auto it = j.find(key);
return it != j.end() && !it->is_null();
}
template <typename J>
int64_t jint(const J& j, const char* key, int64_t def = 0) {
if (!jhas(j, key)) return def;
const auto& v = j.at(key);
try {
if (v.is_number_integer()) return v.template get<int64_t>();
if (v.is_number_unsigned()) return static_cast<int64_t>(v.template get<uint64_t>());
if (v.is_number_float()) return static_cast<int64_t>(v.template get<double>());
if (v.is_boolean()) return v.template get<bool>() ? 1 : 0;
if (v.is_string()) {
const std::string s = v.template get<std::string>();
return s.empty() ? def : std::stoll(s);
}
} catch (...) {
}
return def;
}
template <typename J>
std::string jstr(const J& j, const char* key, const std::string& def = "") {
if (!jhas(j, key)) return def;
const auto& v = j.at(key);
if (v.is_string()) return v.template get<std::string>();
if (v.is_number_integer()) return std::to_string(v.template get<int64_t>());
if (v.is_number_unsigned()) return std::to_string(v.template get<uint64_t>());
return v.dump();
}
std::string narrow(const std::wstring& w);
std::wstring widen(const std::string& s);
// A file name kept as UTF-16/32 text (std::wstring) as a path to open: on Linux file names are bytes,
// UTF-8 here.
std::filesystem::path fspath(const std::wstring& w);
// Replaces `to` with `from` in one step (MoveFileExW with MOVEFILE_REPLACE_EXISTING, rename()).
bool replace_file(const std::wstring& from, const std::wstring& to);
} // namespace bb
+147
View File
@@ -0,0 +1,147 @@
"""Minimal protobuf3 wire-format codec (no dependencies).
Only what shadnet_proto.py needs: varint and length-delimited fields.
proto3 semantics: fields at their default value are omitted on encode;
decode returns only the fields present on the wire.
Schema entry: field_number -> (name, kind)
kind: "varint" (int), "bool", "string", "bytes", or ("message", schema).
A wire field may repeat; decoded values are then lists (never for our
scalars in practice, but decode keeps the last occurrence for scalars and
extends a list for messages, which is what the shadnet messages use).
"""
import struct
def encode_varint(value):
if value < 0:
# int32/int64 negatives are encoded as 10-byte two's complement
value &= (1 << 64) - 1
out = bytearray()
while True:
b = value & 0x7F
value >>= 7
if value:
out.append(b | 0x80)
else:
out.append(b)
return bytes(out)
def decode_varint(buf, pos):
result = 0
shift = 0
while True:
if pos >= len(buf):
raise ValueError("varint runs past end of buffer")
b = buf[pos]
pos += 1
result |= (b & 0x7F) << shift
if not (b & 0x80):
return result, pos
shift += 7
if shift > 70:
raise ValueError("varint too long")
def encode_tag(field_number, wire_type):
return encode_varint((field_number << 3) | wire_type)
def encode_field(field_number, kind, value):
"""Encode one field occurrence. Returns bytes."""
if kind == "varint":
if value in (0, None):
return b""
return encode_tag(field_number, 0) + encode_varint(int(value))
if kind == "bool":
if not value:
return b""
return encode_tag(field_number, 0) + encode_varint(1)
if kind in ("string", "bytes"):
if not value:
return b""
data = value.encode("utf-8") if kind == "string" else bytes(value)
return encode_tag(field_number, 2) + encode_varint(len(data)) + data
if isinstance(kind, tuple) and kind[0] == "message":
if value is None:
return b""
data = encode_message(kind[1], value)
return encode_tag(field_number, 2) + encode_varint(len(data)) + data
raise ValueError("unknown kind %r" % (kind,))
def encode_message(schema, values):
"""values: {field_number: value}. Repeated messages take a list."""
out = bytearray()
for field_number in sorted(values):
name, kind = schema[field_number]
value = values[field_number]
if isinstance(kind, tuple) and kind[0] == "repeated":
_, inner = kind
for item in (value or []):
out += encode_field(field_number, inner, item)
else:
out += encode_field(field_number, kind, value)
return bytes(out)
def decode_raw_fields(buf):
"""Yield (field_number, wire_type, value) for every field on the wire.
Length-delimited values are bytes; varints are ints."""
pos = 0
n = len(buf)
while pos < n:
tag, pos = decode_varint(buf, pos)
field_number = tag >> 3
wire_type = tag & 7
if wire_type == 0:
value, pos = decode_varint(buf, pos)
elif wire_type == 2:
length, pos = decode_varint(buf, pos)
value = buf[pos:pos + length]
if len(value) != length:
raise ValueError("length-delimited field runs past end")
pos += length
elif wire_type == 5: # fixed32
value = struct.unpack_from("<I", buf, pos)[0]
pos += 4
elif wire_type == 1: # fixed64
value = struct.unpack_from("<Q", buf, pos)[0]
pos += 8
else:
raise ValueError("unsupported wire type %d" % wire_type)
yield field_number, wire_type, value
def decode_message(schema, buf):
"""Decode into {name: value}. Scalars keep the last occurrence; repeated
messages accumulate into lists; nested messages are decoded recursively.
Unknown fields are kept under name "<field:N>" as raw bytes/int."""
out = {}
for field_number, wire_type, value in decode_raw_fields(buf):
entry = schema.get(field_number)
if entry is None:
name = "<field:%d>" % field_number
out[name] = value
continue
name, kind = entry
if isinstance(kind, tuple) and kind[0] == "repeated":
_, inner = kind
if isinstance(inner, tuple):
item = decode_message(inner[1], value)
elif inner == "string":
item = value.decode("utf-8", "replace")
else:
item = value
out.setdefault(name, []).append(item)
elif isinstance(kind, tuple) and kind[0] == "message":
out[name] = decode_message(kind[1], value)
elif kind == "string":
out[name] = value.decode("utf-8", "replace") if isinstance(value, (bytes, bytearray)) else value
elif kind == "bool":
out[name] = bool(value)
else: # varint
out[name] = value
return out
+395
View File
@@ -0,0 +1,395 @@
"""shadNet wire protocol: 15-byte packet framing + message schemas.
Sources (read from the actual code, not the stale docs):
- Packet layout: shadnet-p2p-main/documentation/protocol.md
- Command IDs: shadnet-p2p-main/src/protocol.h (== the port's client.h;
matching commands live at 100-115, NOT 12-23)
- Message fields: the port's gpu/shadps4/shadnet/shadnet.proto (subset used
by the client) cross-checked against shadnet-p2p's fuller proto.
"""
import struct
import proto3
HEADER_SIZE = 15
MAX_PACKET_SIZE = 0x800000
PROTOCOL_VERSION = 1
PACKET_REQUEST = 0
PACKET_REPLY = 1
PACKET_NOTIFICATION = 2
PACKET_SERVER_INFO = 3
class Command:
Login = 0
Terminate = 1
Create = 2
Delete = 3
AddFriend = 8
RemoveFriend = 9
AddBlock = 10
RemoveBlock = 11
GetServerFeatures = 12
SetClientVersion = 13
GetToken = 39
SetAppearOffline = 40
ContextStart = 100
CreateRoom = 101
JoinRoom = 102
LeaveRoom = 103
SearchRoom = 104
RequestSignalingInfos = 105
ContextStop = 106
SetUserInfo = 107
SetRoomDataInternal = 108
SetRoomDataExternal = 109
KickoutRoomMember = 110
GetWorldInfoList = 111
GetRoomDataExternalList = 112
GetUserInfoList = 113
GetRoomMemberDataExternalList = 114
SendRoomMessage = 115
GetBoardInfos = 30
RecordScore = 31
RecordScoreData = 32
GetScoreData = 33
GetScoreRange = 34
GetScoreFriends = 35
GetScoreNpid = 36
class Error:
NoError = 0
Malformed = 1
Invalid = 2
InvalidInput = 3
TooSoon = 4
LoginError = 5
LoginAlreadyLoggedIn = 6
LoginInvalidPassword = 8
LoginInvalidToken = 9
RoomMissing = 14
RoomAlreadyJoined = 15
RoomFull = 16
Unauthorized = 23
NotFound = 26
Unsupported = 33
class Notify:
FriendQuery = 5
FriendNew = 6
FriendLost = 7
FriendStatus = 8
RoomEvent = 10
RoomMessage = 11
# Orbis room event ids (bbhost np_matching2.cpp + shadnet-p2p matching.md)
class RoomEvent:
MemberJoined = 0x1101
MemberLeft = 0x1102
KickedOut = 0x1103
RoomDestroyed = 0x1104
RoomDataUpdatedInternal = 0x1106
class EventCause:
Leave = 1
Kickout = 2
ServerOperation = 4
MemberDisappeared = 5
# ---- protobuf message schemas (field number -> (name, kind)) ----------------
MATCHING_BIN_ATTR = {
1: ("attr_id", "varint"),
2: ("data", "bytes"),
3: ("update_date", "varint"),
4: ("update_member_id", "varint"),
}
MATCHING_INT_ATTR = {
1: ("attr_id", "varint"),
2: ("attr_value", "varint"),
}
MATCHING_ROOM_GROUP = {
1: ("group_id", "varint"),
2: ("has_passwd", "bool"),
3: ("has_label", "bool"),
4: ("label", "bytes"),
5: ("slot_count", "varint"),
6: ("num_members", "varint"),
}
MATCHING_ROOM_MEMBER_DATA = {
1: ("npid", "string"),
2: ("member_id", "varint"),
3: ("team_id", "varint"),
4: ("is_owner", "bool"),
5: ("group", ("message", MATCHING_ROOM_GROUP)),
6: ("bin_attrs_internal", ("repeated", ("message", MATCHING_BIN_ATTR))),
7: ("join_date", "varint"),
8: ("nat_type", "varint"),
9: ("flag_attr", "varint"),
10: ("addr", "string"),
11: ("port", "varint"),
12: ("group_id", "varint"),
13: ("account_id", "varint"),
14: ("platform", "varint"),
}
MATCHING_ROOM_DATA_INTERNAL = {
1: ("public_slots", "varint"),
2: ("private_slots", "varint"),
3: ("open_public_slots", "varint"),
4: ("open_private_slots", "varint"),
5: ("max_slot", "varint"),
6: ("server_id", "varint"),
7: ("world_id", "varint"),
8: ("lobby_id", "varint"),
9: ("room_id", "varint"),
10: ("passwd_slot_mask", "varint"),
11: ("joined_slot_mask", "varint"),
12: ("groups", ("repeated", ("message", MATCHING_ROOM_GROUP))),
13: ("flags", "varint"),
14: ("bin_attrs_internal", ("repeated", ("message", MATCHING_BIN_ATTR))),
}
CREATE_JOIN_ROOM_RESPONSE = {
1: ("room_data", ("message", MATCHING_ROOM_DATA_INTERNAL)),
2: ("members", ("repeated", ("message", MATCHING_ROOM_MEMBER_DATA))),
3: ("me_member_id", "varint"),
4: ("owner_member_id", "varint"),
}
MATCHING_WORLD = {
1: ("world_id", "varint"),
2: ("lobbies_num", "varint"),
3: ("max_lobby_members", "varint"),
4: ("lobby_members_num", "varint"),
5: ("rooms_num", "varint"),
6: ("room_members_num", "varint"),
}
LOGIN_REQUEST = {
1: ("npid", "string"),
2: ("password", "string"),
3: ("token", "string"),
4: ("title_id", "string"),
5: ("title_name", "string"),
6: ("appear_offline", "bool"),
}
LOGIN_REPLY = {
1: ("avatar_url", "string"),
2: ("user_id", "varint"),
3: ("friends", ("repeated", ("message", {
1: ("npid", "string"),
2: ("online", "bool"),
3: ("presence", "bytes"),
}))),
4: ("friend_requests_sent", ("repeated", "string")),
5: ("friend_requests_received", ("repeated", "string")),
6: ("blocked", ("repeated", "string")),
}
SERVER_FEATURES_REPLY = {1: ("matching2_enabled", "bool")}
GET_TOKEN_REPLY = {
1: ("token", "string"),
2: ("user_id", "varint"),
3: ("npid", "string"),
}
CONTEXT_START_REQUEST = {1: ("ctx_id", "varint")}
CONTEXT_STOP_REQUEST = {1: ("ctx_id", "varint")}
CREATE_ROOM_REQUEST = {
1: ("req_id", "varint"),
2: ("max_slots", "varint"),
3: ("team_id", "varint"),
4: ("world_id", "varint"),
5: ("lobby_id", "varint"),
6: ("flags", "varint"),
7: ("group_config_count", "varint"),
8: ("allowed_user_count", "varint"),
9: ("blocked_user_count", "varint"),
10: ("internal_bin_attr_count", "varint"),
11: ("external_search_int_attrs", ("repeated", ("message", MATCHING_INT_ATTR))),
12: ("external_search_bin_attrs", ("repeated", ("message", MATCHING_BIN_ATTR))),
13: ("external_bin_attrs", ("repeated", ("message", MATCHING_BIN_ATTR))),
14: ("member_bin_attrs", ("repeated", ("message", MATCHING_BIN_ATTR))),
15: ("join_group_label_present", "bool"),
16: ("room_password_present", "bool"),
17: ("sig_type", "varint"),
18: ("sig_flag", "varint"),
19: ("sig_main_member", "varint"),
20: ("internal_bin_attrs", ("repeated", ("message", MATCHING_BIN_ATTR))),
21: ("user_id_kind", "varint"),
22: ("allowed_online_ids", ("repeated", "string")),
23: ("blocked_online_ids", ("repeated", "string")),
24: ("allowed_account_ids", ("repeated", "varint")),
25: ("blocked_account_ids", ("repeated", "varint")),
26: ("group_configs", ("repeated", ("message", {
1: ("slot_count", "varint"),
2: ("has_label", "bool"),
3: ("label", "bytes"),
4: ("has_passwd", "bool"),
}))),
27: ("room_password", "bytes"),
28: ("has_passwd_slot_mask", "bool"),
29: ("passwd_slot_mask", "varint"),
30: ("join_group_label", "bytes"),
}
CREATE_ROOM_REPLY = {
1: ("room_id", "varint"),
2: ("server_id", "varint"),
3: ("world_id", "varint"),
4: ("lobby_id", "varint"),
5: ("member_id", "varint"),
6: ("max_slots", "varint"),
7: ("flags", "varint"),
8: ("cur_members", "varint"),
9: ("details", ("message", CREATE_JOIN_ROOM_RESPONSE)),
}
JOIN_ROOM_REQUEST = {
1: ("room_id", "varint"),
2: ("req_id", "varint"),
3: ("team_id", "varint"),
4: ("join_flags", "varint"),
5: ("blocked_user_count", "varint"),
6: ("member_bin_attrs", ("repeated", ("message", MATCHING_BIN_ATTR))),
7: ("room_password_present", "bool"),
8: ("join_group_label_present", "bool"),
9: ("user_id_kind", "varint"),
10: ("blocked_online_ids", ("repeated", "string")),
11: ("blocked_account_ids", ("repeated", "varint")),
}
JOIN_ROOM_REPLY = {
1: ("room_id", "varint"),
2: ("member_id", "varint"),
3: ("max_slots", "varint"),
4: ("flags", "varint"),
5: ("cur_members", "varint"),
6: ("details", ("message", CREATE_JOIN_ROOM_RESPONSE)),
}
LEAVE_ROOM_REQUEST = {1: ("room_id", "varint"), 2: ("req_id", "varint")}
LEAVE_ROOM_REPLY = {1: ("room_id", "varint")}
REQUEST_SIGNALING_INFOS_REQUEST = {1: ("target_npid", "string")}
REQUEST_SIGNALING_INFOS_REPLY = {
1: ("target_npid", "string"),
2: ("target_ip", "string"),
3: ("target_port", "varint"),
4: ("target_member_id", "varint"),
}
SET_ROOM_DATA_INTERNAL_REQUEST = {
1: ("req_id", "varint"),
2: ("room_id", "varint"),
3: ("flag_filter", "varint"),
4: ("flag_attr", "varint"),
5: ("bin_attrs", ("repeated", ("message", MATCHING_BIN_ATTR))),
6: ("has_passwd_mask", "bool"),
7: ("passwd_slot_mask", "varint"),
}
SET_ROOM_DATA_INTERNAL_REPLY = {1: ("room_id", "varint")}
SET_ROOM_DATA_EXTERNAL_REQUEST = {
1: ("req_id", "varint"),
2: ("room_id", "varint"),
3: ("search_int_attrs", ("repeated", ("message", MATCHING_INT_ATTR))),
4: ("search_bin_attrs", ("repeated", ("message", MATCHING_BIN_ATTR))),
5: ("ext_bin_attrs", ("repeated", ("message", MATCHING_BIN_ATTR))),
}
SET_ROOM_DATA_EXTERNAL_REPLY = {1: ("room_id", "varint")}
KICKOUT_ROOM_MEMBER_REQUEST = {
1: ("room_id", "varint"),
2: ("req_id", "varint"),
3: ("target_member_id", "varint"),
4: ("block_kick_flag", "varint"),
}
KICKOUT_ROOM_MEMBER_REPLY = {1: ("room_id", "varint")}
SEARCH_ROOM_REPLY = {
1: ("rooms", ("repeated", ("message", {
1: ("max_slot", "varint"),
2: ("cur_members", "varint"),
3: ("flags", "varint"),
4: ("server_id", "varint"),
5: ("world_id", "varint"),
6: ("lobby_id", "varint"),
7: ("room_id", "varint"),
}))),
2: ("range_start", "varint"),
3: ("range_total", "varint"),
4: ("range_result", "varint"),
}
GET_WORLD_INFO_LIST_REQUEST = {1: ("server_id", "varint")}
GET_WORLD_INFO_LIST_REPLY = {1: ("worlds", ("repeated", ("message", MATCHING_WORLD)))}
FRIEND_COMMAND_REQUEST = {1: ("npid", "string")}
NOTIFY_ROOM_EVENT = {
1: ("ctx_id", "varint"),
2: ("room_id", "varint"),
3: ("event", "varint"),
4: ("event_cause", "varint"),
5: ("error_code", "varint"),
6: ("member", ("message", MATCHING_ROOM_MEMBER_DATA)),
7: ("bin_attrs", ("repeated", ("message", MATCHING_BIN_ATTR))),
8: ("flags", "varint"),
9: ("has_passwd_mask", "bool"),
10: ("passwd_slot_mask", "varint"),
}
SET_APPEAR_OFFLINE_REQUEST = {1: ("appear_offline", "bool")}
# ---- packet framing ---------------------------------------------------------
def build_packet(packet_type, command, packet_id, payload=b""):
size = HEADER_SIZE + len(payload)
return struct.pack("<BHIQ", packet_type, command, size, packet_id) + payload
def build_server_info():
return build_packet(PACKET_SERVER_INFO, 0, 0, struct.pack("<I", PROTOCOL_VERSION))
def build_reply(command, packet_id, error, payload=b""):
return build_packet(PACKET_REPLY, command, packet_id, bytes([error]) + payload)
def build_notification(notification_type, payload):
return build_packet(PACKET_NOTIFICATION, notification_type, 0, payload)
def proto_blob(message_bytes):
"""The u32-LE length prefix + message framing every proto payload uses."""
return struct.pack("<I", len(message_bytes)) + message_bytes
def parse_proto_blob(payload):
if len(payload) < 4:
return None
(size,) = struct.unpack_from("<I", payload, 0)
return payload[4:4 + size]
def parse_header(buf):
if len(buf) < HEADER_SIZE:
return None
packet_type, command, size, packet_id = struct.unpack_from("<BHIQ", buf, 0)
return packet_type, command, size, packet_id
+778
View File
@@ -0,0 +1,778 @@
#!/usr/bin/env python3
"""End-to-end tests for the C++ bridge (bin/bbbridge-cli.exe).
The same checks the Python bridge passed (its test_offline.py and test_p2p.py),
run against the C++ build over real sockets: a fake bbhost server, a fake
STUN/relay server, a shadNet client, fake port and peer UDP sockets. The
protobuf / shadNet codecs used to read the bridge's answers are the Python
bridge's own (proto3.py, shadnet_proto.py) - an independent implementation.
python tests/e2e/test_e2e.py [path\\to\\bbbridge-cli.exe] (Linux: the path to bbbridge-cli)
"""
import hashlib
import json
import os
import socket
import struct
import subprocess
import sys
import tempfile
import threading
import time
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import proto3 # noqa: E402
import shadnet_proto as sp # noqa: E402
EXE = sys.argv[1] if len(sys.argv) > 1 else os.path.join(HERE, "..", "..", "bin", "bbbridge-cli.exe")
HOST = "127.0.0.1"
NPID, HOST_NPID = "tester01", "hostplayer"
TIMEOUT = 5.0
PASSED, FAILED = [], []
def check(name, cond, detail=""):
(PASSED if cond else FAILED).append((name, detail))
print(" [%s] %s%s" % ("PASS" if cond else "FAIL", name, "" if cond else " " + str(detail)))
def account_id_for(npid):
d = hashlib.sha256(("bbbridge:" + npid).encode()).digest()
return (1 << 52) | (int.from_bytes(d[:8], "big") & ((1 << 48) - 1))
# ---------------------------------------------------------------- fake bbhost
class FakeBBHost(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *a):
pass
def _json(self, obj, status=200):
body = json.dumps(obj).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
n = int(self.headers.get("Content-Length", 0) or 0)
try:
body = json.loads(self.rfile.read(n).decode()) if n else {}
except ValueError:
body = {}
path = self.path.split("?")[0]
st = self.server.state
with st["lock"]:
st["calls"].append(path)
emit = st["emit_event"]
event = st.get("event")
r = {"ResKind": 0}
if path == "/auth/account/create":
r.update(Token="tok-123", OnlineId=body.get("Name"), RecoveryCode="RC-42")
elif path == "/auth/recover":
r.update(Token="tok-456", OnlineId=body.get("Name"), RecoveryCode="RC-43")
elif path == "/mp/matching2/create_room":
r.update(RoomId=777, SessionId="sess-A", MemberId=1, OwnerMemberId=1, MaxMembers=5, Members=[])
elif path == "/mp/matching2/join_room":
r.update(SessionId="sess-B", MemberId=2, OwnerMemberId=1, MaxMembers=5, Members=[
{"MemberId": 1, "OnlineId": HOST_NPID, "LocalAddr": "10.0.0.9", "LocalPort": 5551,
"Addr": "9.9.9.8", "Port": 5552, "MappedAddr": "9.9.9.8", "MappedPort": 5552}])
elif path == "/mp/matching2/heartbeat":
r.update(InRoom=1)
elif path == "/np/signaling/resolve":
if self.headers.get("X-BB-Token") == "tok-expired":
self._json({"ResKind": 1, "Message": "bad or expired token"}, 401)
return
r.update(Addr="9.9.9.9", Port=5555)
elif path == "/auth/device/start":
st["device_polls"] = 0
r.update(DeviceCode="dev-1", UserCode="ABCD-EFGH", VerificationUri="http://example.test/link",
Interval=1, ExpiresIn=60, Device=body.get("DeviceName"))
elif path == "/auth/device/poll":
st["device_polls"] = st.get("device_polls", 0) + 1
if body.get("DeviceCode") == "dev-1" and st["device_polls"] >= 2:
r.update(Status="approved", Token="tok-discord", OnlineId="discorduser")
else:
r.update(Status="pending")
elif path == "/auth/web_code":
if body.get("Token") != "tok-discord":
self._json({"ResKind": 1, "Message": "unknown token"})
return
r.update(Code="WXYZ-1234")
elif path == "/np/events/poll":
r.update(NextCursor=body.get("Cursor", 0) + 1, HasEvent=bool(emit))
if emit:
r.update(event or {"Name": "room_member_joined", "MemberId": 2, "OnlineId": HOST_NPID,
"RoomId": 777, "LocalAddr": "10.0.0.9", "LocalPort": 5551,
"Addr": "9.9.9.8", "Port": 5552, "MappedAddr": "9.9.9.8",
"MappedPort": 5552})
elif path == "/summon_messenger/create":
r.update(MessageId="SummonDataCreateResponse")
st["summon_token"] = self.headers.get("X-BB-Token", "")
elif path == "/gs/echo":
r.update(Token=self.headers.get("X-BB-Token", ""), UA=self.headers.get("User-Agent", ""), Body=body)
elif path not in ("/mp/matching2/context_start", "/mp/matching2/leave_room", "/auth/logout",
"/mp/matching2/signaling_update", "/np/events/ack"):
self._json({"ResKind": 1, "Message": "unknown path"}, 404)
return
self._json(r)
class QuietServer(ThreadingHTTPServer):
def handle_error(self, request, client_address):
pass # a bridge we terminate drops its keep-alive connections; that is expected
def fake_bbhost(port):
srv = QuietServer((HOST, port), FakeBBHost)
srv.state = {"lock": threading.Lock(), "calls": [], "emit_event": False, "event": None}
threading.Thread(target=srv.serve_forever, daemon=True).start()
return srv
# ---------------------------------------------------------------- bridge process
class ExeBridge:
def __init__(self, cfg):
self.dir = tempfile.mkdtemp(prefix="bbbridge-e2e-")
self.cfg = dict(cfg, state_file=os.path.join(self.dir, "state.json"), debug_endpoints=True)
path = os.path.join(self.dir, "config.json")
with open(path, "w") as fh:
json.dump(self.cfg, fh)
self.log = os.path.join(self.dir, "bridge.log")
self.proc = subprocess.Popen([EXE, "--config", path, "--log", self.log],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
deadline = time.time() + 10
while time.time() < deadline:
try:
urllib.request.urlopen("http://%s:%d/status" % (HOST, self.cfg["webapi_port"]), timeout=1).close()
return
except OSError:
time.sleep(0.1)
raise RuntimeError("bridge did not start; log: %s" % self.log)
def _url(self, path):
return "http://%s:%d%s" % (HOST, self.cfg["webapi_port"], path)
def debug(self):
with urllib.request.urlopen(self._url("/debug/state"), timeout=TIMEOUT) as r:
return json.loads(r.read())
def post(self, path, obj):
req = urllib.request.Request(self._url(path), data=json.dumps(obj).encode(), method="POST",
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
return json.loads(r.read())
def peer(self, online_id):
return next((p for p in self.debug()["peers"] if p["online_id"] == online_id), None)
def stop(self):
self.proc.terminate()
self.proc.wait(10)
class ShadNetClient:
def __init__(self, port):
self.sock = socket.create_connection((HOST, port), timeout=TIMEOUT)
self.next_id = 1
self.queue = []
ptype, _, _, payload = self._read()
assert ptype == sp.PACKET_SERVER_INFO and struct.unpack("<I", payload)[0] == sp.PROTOCOL_VERSION
def _exact(self, n):
buf = b""
while len(buf) < n:
c = self.sock.recv(n - len(buf))
if not c:
raise ConnectionError("closed")
buf += c
return buf
def _read(self):
ptype, cmd, size, pid = sp.parse_header(self._exact(sp.HEADER_SIZE))
return ptype, cmd, pid, self._exact(size - sp.HEADER_SIZE) if size > sp.HEADER_SIZE else b""
def request(self, command, msg=None):
payload = sp.proto_blob(msg) if msg is not None else b""
self.sock.sendall(sp.build_packet(sp.PACKET_REQUEST, command, self.next_id, payload))
self.next_id += 1
deadline = time.time() + TIMEOUT * 2
while time.time() < deadline:
ptype, cmd, _, raw = self._read()
if ptype == sp.PACKET_REPLY and cmd == command:
return (raw[0] if raw else 255), sp.parse_proto_blob(raw[1:])
self.queue.append((ptype, cmd, raw))
raise TimeoutError(command)
def notification(self, kind, timeout=3.0):
deadline = time.time() + timeout
while time.time() < deadline:
for i, (ptype, cmd, raw) in enumerate(self.queue):
if ptype == sp.PACKET_NOTIFICATION and cmd == kind:
return self.queue.pop(i)[2]
try:
self.sock.settimeout(0.5)
ptype, cmd, _, raw = self._read()
except (socket.timeout, TimeoutError):
continue
finally:
self.sock.settimeout(TIMEOUT)
self.queue.append((ptype, cmd, raw))
return None
def close(self):
self.sock.close()
def stun_ping(npid, udp_port):
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(TIMEOUT)
s.sendto(b"\x01" + npid.encode()[:16].ljust(16, b"\0") + socket.inet_aton(HOST), (HOST, udp_port))
data, _ = s.recvfrom(64)
port = s.getsockname()[1]
s.close()
return socket.inet_ntoa(data[:4]), struct.unpack(">H", data[4:6])[0], port
def get_json(url):
with urllib.request.urlopen(url, timeout=TIMEOUT) as r:
return r.status, json.loads(r.read())
def post_json(url, obj, headers=None):
req = urllib.request.Request(url, data=json.dumps(obj).encode(), method="POST",
headers=dict({"Content-Type": "application/json"}, **(headers or {})))
with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
return json.loads(r.read())
# ---------------------------------------------------------------- A: server path
def part_a():
print("-- A: server path (gateway off)")
ports = {"tcp": 42313, "udp": 42314, "webapi": 42315, "bbhost": 42671, "gs": 42316}
fake = fake_bbhost(ports["bbhost"])
up = "http://%s:%d" % (HOST, ports["bbhost"])
br = ExeBridge({"bbhost_server": up, "community_server": up, "bind_host": HOST,
"tcp_port": ports["tcp"], "udp_port": ports["udp"], "webapi_port": ports["webapi"],
"p2p_gateway": False, "local_addr": HOST, "poll_interval": 0.05,
"game_proxy_routes": {str(ports["gs"]): up}, "create_accounts": True})
c = None
try:
c = ShadNetClient(ports["tcp"])
err, blob = c.request(sp.Command.Login, proto3.encode_message(sp.LOGIN_REQUEST, {1: NPID, 4: "CUSA03173"}))
reply = proto3.decode_message(sp.LOGIN_REPLY, blob) if blob else {}
check("login accepted with the per-player account id",
err == 0 and reply.get("user_id") == account_id_for(NPID), (err, reply))
check("bbhost account created", "/auth/account/create" in fake.state["calls"])
err, blob = c.request(sp.Command.GetServerFeatures)
check("matching2 enabled", err == 0 and proto3.decode_message(sp.SERVER_FEATURES_REPLY, blob).get("matching2_enabled") is True)
err, _ = c.request(sp.Command.SetClientVersion)
check("client version accepted", err == 0, err)
err, blob = c.request(sp.Command.GetToken)
tok = proto3.decode_message(sp.GET_TOKEN_REPLY, blob)
check("token relayed", err == 0 and tok.get("token") == "tok-123" and tok.get("npid") == NPID, tok)
ip, port, local = stun_ping(NPID, ports["udp"])
check("stun reply", ip == HOST and port == local, (ip, port, local))
time.sleep(0.2)
check("stun registry", br.debug()["registry"].get(NPID) == [HOST, local], br.debug()["registry"])
# bbport sends its ping inside a P2P signaling frame (FF FF FF FF first): registered, no echo.
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(0.5)
s.sendto(b"\xff\xff\xff\xff\x01" + b"framed".ljust(16, b"\0") + socket.inet_aton(HOST), (HOST, ports["udp"]))
framed_port = s.getsockname()[1]
try:
s.recvfrom(64)
echoed = True
except socket.timeout:
echoed = False
s.close()
check("framed stun ping registered without an echo",
br.debug()["registry"].get("framed") == [HOST, framed_port] and not echoed, br.debug()["registry"])
err, _ = c.request(sp.Command.ContextStart, proto3.encode_message(sp.CONTEXT_START_REQUEST, {1: 7}))
check("context_start", err == 0 and "/mp/matching2/context_start" in fake.state["calls"], err)
err, blob = c.request(sp.Command.CreateRoom, proto3.encode_message(sp.CREATE_ROOM_REQUEST, {1: 1, 2: 5}))
reply = proto3.decode_message(sp.CREATE_ROOM_REPLY, blob) if blob else {}
check("create_room accepted", err == 0 and reply.get("room_id") == 777 and reply.get("member_id") == 1
and reply.get("max_slots") == 5, (err, reply))
details = reply.get("details") or {}
check("create_room details", details.get("me_member_id") == 1 and details.get("owner_member_id") == 1
and (details.get("room_data") or {}).get("room_id") == 777, details)
me = [m for m in details.get("members") or [] if m.get("member_id") == 1]
check("create_room lists ourselves (port needs members.me/owner)",
len(me) == 1 and me[0].get("npid") == NPID and me[0].get("is_owner") is True, details.get("members"))
with fake.state["lock"]:
fake.state["emit_event"] = True
raw = c.notification(sp.Notify.RoomEvent)
with fake.state["lock"]:
fake.state["emit_event"] = False
ev = proto3.decode_message(sp.NOTIFY_ROOM_EVENT, sp.parse_proto_blob(raw)) if raw else {}
check("room_member_joined notification", ev.get("event") == sp.RoomEvent.MemberJoined
and ev.get("room_id") == 777 and (ev.get("member") or {}).get("npid") == HOST_NPID
and (ev.get("member") or {}).get("member_id") == 2, ev)
err, blob = c.request(sp.Command.RequestSignalingInfos,
proto3.encode_message(sp.REQUEST_SIGNALING_INFOS_REQUEST, {1: HOST_NPID}))
sig = proto3.decode_message(sp.REQUEST_SIGNALING_INFOS_REPLY, blob) if blob else {}
check("signaling resolve", err == 0 and sig.get("target_ip") == "9.9.9.9" and sig.get("target_port") == 5555, sig)
webapi = "http://%s:%d" % (HOST, ports["webapi"])
check("webapi /status", get_json(webapi + "/status")[1].get("ok") is True)
ok = True
for name in ("friendList", "blockList"):
status, body = get_json("%s/v1/users/%s/%s?fields=npId&limit=50" % (webapi, NPID, name))
ok = ok and status == 200 and body.get(name) == [] and body.get("totalResults") == 0
check("webapi friendList/blockList answer empty lists", ok)
ad = {"SessionId": "sess-A", "UserId": 1, "AreaId": 3, "SummonData": "QUJD", "MatchingLevel": 44}
r = post_json(webapi + "/summon_messenger/create", ad)
check("summon create proxied", r.get("ResKind") == 0 and r.get("MessageId") == "SummonDataCreateResponse", r)
check("summon proxy carries the account token", fake.state.get("summon_token") == "tok-123",
fake.state.get("summon_token"))
echo = post_json("http://%s:%d/gs/echo" % (HOST, ports["gs"]), {"x": 1}, {"User-Agent": "BloodborneGame"})
check("game proxy forwards with token and own UA", echo.get("Token") == "tok-123" and echo.get("Body") == {"x": 1}
and echo.get("UA", "").startswith("BloodborneGame bbbridge/"), echo)
check("advertisement captured", (br.debug().get("last_advertisement") or {}).get("AreaId") == 3)
err, blob = c.request(sp.Command.LeaveRoom, proto3.encode_message(sp.LEAVE_ROOM_REQUEST, {1: 777, 2: 2}))
check("leave_room accepted", err == 0 and proto3.decode_message(sp.LEAVE_ROOM_REPLY, blob).get("room_id") == 777
and "/mp/matching2/leave_room" in fake.state["calls"])
err, _ = c.request(sp.Command.Terminate)
closed = False
try:
c.sock.settimeout(2)
closed = not c.sock.recv(16)
except OSError:
closed = True
check("terminate closes the connection", err == 0 and closed)
c2 = ShadNetClient(ports["tcp"])
err, _ = c2.request(sp.Command.Login, proto3.encode_message(sp.LOGIN_REQUEST, {1: NPID}))
_, blob = c2.request(sp.Command.GetToken)
c2.close()
check("second login uses the stored token, no recover",
err == 0 and fake.state["calls"].count("/auth/recover") == 0
and proto3.decode_message(sp.GET_TOKEN_REPLY, blob).get("token") == "tok-123")
finally:
if c:
c.close()
br.stop()
fake.shutdown()
fake.server_close()
# ---------------------------------------------------------------- B: gateway
class FakeStun(threading.Thread):
def __init__(self):
super().__init__(daemon=True)
self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.sock.bind((HOST, 0))
self.sock.settimeout(0.2)
self.port = self.sock.getsockname()[1]
self.relay_port = 41000
self.token = b"RELAYTOK"
self.requests, self.relayed = [], []
self.stop = False
def run(self):
while not self.stop:
try:
data, src = self.sock.recvfrom(65536)
except (socket.timeout, OSError):
continue
if data[:2] == b"\xfbR":
self.relayed.append(data)
continue
if len(data) < 20 or struct.unpack(">H", data[:2])[0] != 1:
continue
self.requests.append(data)
attrs = struct.pack(">HHBBH4s", 0x0001, 8, 0, 1, 5678, socket.inet_aton("1.2.3.4"))
if self.relay_port and len(data) >= 36 and data[20:22] == b"\x81\x00":
v = b"bbr1" + self.token + struct.pack(">H", self.relay_port) + socket.inet_aton(src[0]) + struct.pack(">H", src[1])
attrs += struct.pack(">HH", 0x8101, len(v)) + v
self.sock.sendto(struct.pack(">HH", 0x0101, len(attrs)) + data[4:20] + attrs, src)
def udp(timeout=2.0):
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind((HOST, 0))
s.settimeout(timeout)
return s
def recv_until(sock, pred, timeout=2.0):
deadline = time.time() + timeout
while time.time() < deadline:
sock.settimeout(max(0.05, deadline - time.time()))
try:
data, src = sock.recvfrom(65536)
except socket.timeout:
return None
if pred(data):
return data, src
return None
def handshake(kind, frm, to, from_conn=7, nonce=0):
return b"SHAD" + bytes([0x07, kind]) + frm.encode().ljust(16, b"\0") + to.encode().ljust(16, b"\0") + \
struct.pack("<IIIHHQ", from_conn, 0, 0, 0, 0, nonce)
def m2(kind, room, frm_member, to_member, frm_id, nonce=0):
return b"SHAD" + bytes([0x21, kind]) + struct.pack("<QHH", room, frm_member, to_member) + \
frm_id.encode().ljust(16, b"\0") + struct.pack("<IHHQ", 0, 0, 0, nonce)
def part_b():
print("-- B: gateway over real sockets")
stun = FakeStun()
stun.start()
ports = {"tcp": 43313, "udp": 43314, "webapi": 43315}
br = ExeBridge({"bbhost_server": "http://%s:1" % HOST, "bind_host": HOST, "tcp_port": ports["tcp"],
"udp_port": ports["udp"], "webapi_port": ports["webapi"], "local_addr": HOST,
"stun_server": "%s:%d" % (HOST, stun.port), "stun_refresh": 0.3, "game_proxy_routes": {}})
port_sock, peer_sock = udp(), udp()
try:
deadline = time.time() + 4
while time.time() < deadline and not (br.debug().get("mapped") and br.debug().get("relay_port")):
time.sleep(0.1)
st = br.debug()
check("STUN request carries BBHOST-HELLO", bool(stun.requests) and stun.requests[0][20:24] == b"\x81\x00\x00\x0c")
check("STUN mapping learned", st.get("mapped") == ["1.2.3.4", 5678], st.get("mapped"))
check("relay turned on", st.get("relay_port") == 41000, st.get("relay_port"))
stun.relay_port = 0 # direct part: the next refresh turns the relay off
deadline = time.time() + 3
while time.time() < deadline and br.debug().get("relay_port"):
time.sleep(0.1)
inner = tuple(br.post("/debug/add_peer", {
"member_id": 2, "online_id": "hunter", "endpoint": list(peer_sock.getsockname()),
"port_online_id": "porthost", "port_member_id": 1, "room_id": 777,
"port_endpoint": list(port_sock.getsockname())})["inner"])
check("inner endpoint on loopback", inner[0] == HOST and inner[1] > 0, inner)
opening = recv_until(port_sock, lambda d: d[4:9] == b"SHAD\x10")
check("peer opens with ActivationRequest to the port",
opening is not None and opening[0][9] == 1 and opening[1] == inner, opening)
port_sock.sendto(struct.pack(">HH", 40, 30) + b"hello-peer", inner)
got = recv_until(peer_sock, lambda d: d[:1] == b"\xff")
check("port game data reaches the peer in bbhost framing",
got is not None and got[0] == b"\xff\xc3\x28\x1ehello-peer", got)
outer_port = got[1][1] if got else 0
peer_sock.sendto(b"\xff\xc3\x1e\x28hello-port", (HOST, outer_port))
got = recv_until(port_sock, lambda d: d.endswith(b"hello-port"))
check("peer game data reaches the port in port framing",
got is not None and got[0] == struct.pack(">HH", 30, 40) + b"hello-port" and got[1] == inner, got)
peer_sock.sendto(b"\xfebbhp\0\0\0", (HOST, outer_port))
got = recv_until(port_sock, lambda d: d[:4] != b"\xff\xff\xff\xff", timeout=0.4)
check("hole-punch probe not delivered to the port", got is None, got)
sig = struct.pack(">HH", 0xFFFF, 0xFFFF)
port_sock.sendto(sig + handshake(1, "porthost", "hunter"), inner)
check("port's handshake Offer answered with Accept",
recv_until(port_sock, lambda d: d[4:10] == b"SHAD\x07\x02") is not None)
port_sock.sendto(sig + m2(3, 777, 1, 2, "porthost", nonce=42), inner)
got = recv_until(port_sock, lambda d: d[4:10] == b"SHAD\x21\x04")
check("port's matching2 Check answered with CheckAck",
got is not None and struct.unpack("<Q", got[0][4 + 42:4 + 50])[0] == 42)
stun.relay_port = 41000
deadline = time.time() + 3
while time.time() < deadline and not br.debug().get("relay_port"):
time.sleep(0.1)
inner3 = tuple(br.post("/debug/add_peer", {
"member_id": 3, "online_id": "relayed", "endpoint": [HOST, 41001], "port_online_id": "porthost",
"port_member_id": 1, "room_id": 777, "port_endpoint": list(port_sock.getsockname())})["inner"])
port_sock.sendto(struct.pack(">HH", 40, 30) + b"via-relay", inner3)
deadline = time.time() + 2
while time.time() < deadline and not stun.relayed:
time.sleep(0.05)
expect = b"\xfbR" + stun.token + struct.pack(">H", 41001) + b"\xff\xc3\x28\x1evia-relay"
check("datagram for a relayed peer goes framed to the STUN port", stun.relayed[:1] == [expect], stun.relayed[:1])
stun.sock.sendto(b"\xfbr" + struct.pack(">H", 41001) + b"\xff\xc3\x1e\x28back", (HOST, outer_port))
got = recv_until(port_sock, lambda d: d.endswith(b"back"))
check("relay delivery reaches the port from the right member", got is not None and got[1] == inner3, got)
br.post("/debug/remove_peer", {"member_id": 2})
check("removed peer forgotten", br.peer("hunter") is None)
finally:
stun.stop = True
br.stop()
port_sock.close()
peer_sock.close()
# ---------------------------------------------------------------- C: bridge + gateway
def part_c():
print("-- C: bridge flows with the gateway on")
ports = {"tcp": 44313, "udp": 44314, "webapi": 44315, "bbhost": 44671}
fake = fake_bbhost(ports["bbhost"])
up = "http://%s:%d" % (HOST, ports["bbhost"])
br = ExeBridge({"bbhost_server": up, "community_server": up, "bind_host": HOST, "tcp_port": ports["tcp"],
"udp_port": ports["udp"], "webapi_port": ports["webapi"], "local_addr": HOST,
"poll_interval": 0.05, "stun_server": "off", "game_proxy_routes": {}, "create_accounts": True})
c = None
try:
c = ShadNetClient(ports["tcp"])
c.request(sp.Command.Login, proto3.encode_message(sp.LOGIN_REQUEST, {1: NPID}))
stun_ping(NPID, ports["udp"])
c.request(sp.Command.ContextStart, proto3.encode_message(sp.CONTEXT_START_REQUEST, {1: 7}))
with fake.state["lock"]:
fake.state["event"] = {"Name": "guest_invite", "RoomId": 777, "HostOnlineId": HOST_NPID,
"HostAddr": "9.9.9.8", "HostPort": 5552, "HostLocalAddr": "10.0.0.9",
"HostLocalPort": 5551, "HostMappedAddr": "9.9.9.8", "HostMappedPort": 5552,
"HostArea": 3, "HostLevel": 26}
fake.state["emit_event"] = True
deadline = time.time() + 3
while time.time() < deadline and br.peer(HOST_NPID) is None:
time.sleep(0.05)
with fake.state["lock"]:
fake.state["emit_event"] = False
fake.state["event"] = None
seeded = br.peer(HOST_NPID)
check("guest_invite seeds the host in the gateway", seeded is not None)
err, blob = c.request(sp.Command.JoinRoom, proto3.encode_message(sp.JOIN_ROOM_REQUEST, {1: 777, 2: 1}))
reply = proto3.decode_message(sp.JOIN_ROOM_REPLY, blob) if blob else {}
host = next((m for m in (reply.get("details") or {}).get("members") or [] if m.get("npid") == HOST_NPID), {})
link = br.peer(HOST_NPID)
check("joining the invited room keeps the host's inner endpoint",
seeded is not None and link is not None and link["inner"] == seeded["inner"], (seeded, link))
check("the join fills in the port's member id for the handshakes",
link is not None and link["port_member_id"] == 2 and link["room_id"] == 777, link)
check("joined room member points at the gateway",
err == 0 and link is not None and host.get("addr") == HOST and host.get("port") == link["inner"][1], host)
err, blob = c.request(sp.Command.RequestSignalingInfos,
proto3.encode_message(sp.REQUEST_SIGNALING_INFOS_REQUEST, {1: HOST_NPID}))
sig = proto3.decode_message(sp.REQUEST_SIGNALING_INFOS_REPLY, blob) if blob else {}
check("signaling infos point at the gateway",
err == 0 and link is not None and sig.get("target_ip") == HOST and sig.get("target_port") == link["inner"][1], sig)
err, blob = c.request(sp.Command.RequestSignalingInfos,
proto3.encode_message(sp.REQUEST_SIGNALING_INFOS_REQUEST, {1: "summonedguest"}))
sig2 = proto3.decode_message(sp.REQUEST_SIGNALING_INFOS_REPLY, blob) if blob else {}
early = br.peer("summonedguest")
check("signaling infos for a non-member resolve through the server",
err == 0 and early is not None and sig2.get("target_port") == early["inner"][1], (sig2, early))
with fake.state["lock"]:
fake.state["event"] = {"Name": "room_member_joined", "MemberId": 3, "RoomId": 777,
"OnlineId": "summonedguest", "Addr": "9.9.9.9", "Port": 5555}
fake.state["emit_event"] = True
deadline = time.time() + 3
while time.time() < deadline and (br.peer("summonedguest") or {}).get("member_id") != 3:
time.sleep(0.05)
with fake.state["lock"]:
fake.state["emit_event"] = False
fake.state["event"] = None
joined = br.peer("summonedguest")
check("the guest joining keeps the inner endpoint the port already uses",
joined is not None and early is not None and joined["member_id"] == 3 and joined["inner"] == early["inner"]
and joined["peer_member_id"] == 3 and all(p["member_id"] > 0 for p in br.debug()["peers"]), joined)
time.sleep(5.6)
check("heartbeat inside the server's 15 s window", fake.state["calls"].count("/mp/matching2/heartbeat") >= 1)
c.request(sp.Command.LeaveRoom, proto3.encode_message(sp.LEAVE_ROOM_REQUEST, {1: 777}))
check("leaving the room drops the peers", br.debug()["peers"] == [], br.debug()["peers"])
finally:
if c:
c.close()
br.stop()
fake.shutdown()
fake.server_close()
# ---------------------------------------------------------------- D: Discord sign-in
def part_d():
print("-- D: sign in with Discord, website code, expired token")
port = 45671
fake = fake_bbhost(port)
up = "http://%s:%d" % (HOST, port)
d = tempfile.mkdtemp(prefix="bbbridge-e2e-")
state = os.path.join(d, "state.json")
cfg_path = os.path.join(d, "config.json")
with open(cfg_path, "w") as fh:
json.dump({"auth_server": up, "bbhost_server": up, "state_file": state}, fh)
try:
r = subprocess.run([EXE, "--config", cfg_path, "--link-discord"], capture_output=True, text=True, timeout=30)
check("device code shown to the player", "ABCD-EFGH" in r.stdout and "http://example.test/link" in r.stdout,
r.stdout)
check("approval signs in", r.returncode == 0 and "signed in as discorduser" in r.stdout, r.stdout + r.stderr)
with open(state) as fh:
acc = json.load(fh)["accounts"].get("discorduser", {})
check("Discord account stored under its name",
acc.get("token") == "tok-discord" and acc.get("linked") == "discord" and not acc.get("recovery_code"), acc)
r = subprocess.run([EXE, "--config", cfg_path, "--web-code", "discorduser"], capture_output=True, text=True,
timeout=30)
check("website sign-in code", r.returncode == 0 and r.stdout.strip() == "WXYZ-1234", r.stdout + r.stderr)
def cli(*args):
return subprocess.run([EXE, "--config", cfg_path, *args], capture_output=True, text=True, timeout=30)
def accounts():
with open(state) as fh:
return json.load(fh)["accounts"]
r = cli("--create", "pcplayer")
check("create account on this PC", r.returncode == 0 and accounts().get("pcplayer", {}).get("recovery_code") == "RC-42"
and accounts()["pcplayer"].get("token") == "tok-123", r.stdout + str(accounts().get("pcplayer")))
r = cli("--recover", "pcplayer", "RC-42")
check("recover account keeps the server's new code", r.returncode == 0
and accounts()["pcplayer"].get("recovery_code") == "RC-43" and accounts()["pcplayer"].get("token") == "tok-456",
accounts().get("pcplayer"))
r = cli("--sign-out", "pcplayer")
check("sign out ends the token, keeps the recovery code", r.returncode == 0 and "/auth/logout" in fake.state["calls"]
and "token" not in accounts()["pcplayer"] and accounts()["pcplayer"].get("recovery_code") == "RC-43",
accounts().get("pcplayer"))
r = cli("--sign-out", "discorduser")
check("signing out of a Discord account forgets it here", r.returncode == 0 and "discorduser" not in accounts())
acc = dict(acc) # the bridge part below uses the Discord account as linked earlier
creates = fake.state["calls"].count("/auth/account/create") # the bridge must add none below
br = ExeBridge({"bbhost_server": up, "community_server": up, "auth_server": up, "bind_host": HOST,
"tcp_port": 45313, "udp_port": 45314, "webapi_port": 45315, "p2p_gateway": False,
"local_addr": HOST, "game_proxy_routes": {}})
try:
with open(br.cfg["state_file"], "w") as fh:
json.dump({"accounts": {"discorduser": acc, "expired": {
"bbhost_name": "expired", "token": "tok-expired", "online_id": "expired", "linked": "discord"}}}, fh)
c = ShadNetClient(45313)
err, _ = c.request(sp.Command.Login, proto3.encode_message(sp.LOGIN_REQUEST, {1: "discorduser"}))
_, blob = c.request(sp.Command.GetToken)
c.close()
check("the bridge plays as the Discord account",
err == 0 and proto3.decode_message(sp.GET_TOKEN_REPLY, blob).get("token") == "tok-discord"
and fake.state["calls"].count("/auth/account/create") == creates)
c = ShadNetClient(45313)
err, _ = c.request(sp.Command.Login, proto3.encode_message(sp.LOGIN_REQUEST, {1: "expired"}))
c.close()
with open(br.log) as fh:
said = "has expired" in fh.read()
check("an expired Discord sign-in is refused, not replaced by a new account",
err == sp.Error.LoginError and said and fake.state["calls"].count("/auth/account/create") == creates, err)
c = ShadNetClient(45313)
err, _ = c.request(sp.Command.Login, proto3.encode_message(sp.LOGIN_REQUEST, {1: "stranger"}))
c.close()
with open(br.log) as fh:
said = "use 'Sign in with Discord' in bbbridge first" in fh.read()
check("no account on this PC: sign in with Discord first, nothing created",
err == sp.Error.LoginError and said and fake.state["calls"].count("/auth/account/create") == creates, err)
finally:
br.stop()
finally:
fake.shutdown()
fake.server_close()
# ---------------------------------------------------------------- E: bbport setup
def part_e():
print("-- E: routing a fresh bbport (sandboxed APPDATA)")
tmp = tempfile.mkdtemp(prefix="bbbridge-e2e-")
appdata, bbport = os.path.join(tmp, "AppData"), os.path.join(tmp, "bloodborne")
user = os.path.join(bbport, "user")
os.makedirs(os.path.join(appdata, "bbport-launcher"))
os.makedirs(user)
open(os.path.join(bbport, "Bloodborne.exe"), "wb").close()
mine = {"https://example.test:1": "http://127.0.0.1:1"}
with open(os.path.join(user, "host_overrides.custom.json"), "w") as fh:
json.dump(mine, fh)
sfile = os.path.join(appdata, "bbport-launcher", "settings.json")
fresh = {"ui_language": "", "game_dir": os.path.join(bbport, "CUSA03173"), "user_dir": "", "online": False,
"online_server": "srv.shadps4.net:31313", "online_webapi": "", "online_npid": "", "online_password": "",
"online_upnp": True, "extra_env": "BB_GPU_LOG=info SHADPS4_P2P_PORT=1"}
with open(sfile, "w") as fh:
json.dump(fresh, fh)
env = dict(os.environ, APPDATA=appdata)
def route(on):
return subprocess.run([EXE, "--route", "on" if on else "off", "--bbport", bbport], env=env,
capture_output=True, text=True, timeout=30)
def settings():
with open(sfile) as fh:
return json.load(fh)
r = route(True)
s = settings()
check("route on: bbport points at the bridge", r.returncode == 0 and "routed=1" in r.stdout and s["online"] is True
and s["online_server"] == "127.0.0.1:31313" and s["online_webapi"] == "http://127.0.0.1:31315", r.stdout + str(s))
check("route on: an empty password gets a placeholder (bbport skips online without one)",
s["online_password"] == "bbbridge", s["online_password"])
check("route on: extra variables merged, P2P port pinned once",
s["extra_env"].split() == ["BB_GPU_LOG=info", "SHADPS4_P2P_PORT=3658"], s["extra_env"])
check("route on: npid untouched, key order kept", s["online_npid"] == "" and list(s)[:4] == list(fresh)[:4], list(s))
ov = json.load(open(os.path.join(user, "host_overrides.custom.json")))
check("route on: overrides written, the player's own backed up",
"_bbbridge" in ov and json.load(open(os.path.join(user, "host_overrides.custom.json.before-bbbridge"))) == mine)
r = route(False)
s = settings()
check("route off: back to shadNet, password and variables restored",
r.returncode == 0 and s["online_server"] == "srv.shadps4.net:31313" and s["online_webapi"] == ""
and s["online_password"] == "" and s["extra_env"] == "BB_GPU_LOG=info", s)
check("route off: the player's own overrides restored",
json.load(open(os.path.join(user, "host_overrides.custom.json"))) == mine)
s["online_password"] = "secret"
with open(sfile, "w") as fh:
json.dump(s, fh)
route(True)
on_pw = settings()["online_password"]
route(False)
check("a real shadNet password is left alone", on_pw == "secret" and settings()["online_password"] == "secret",
(on_pw, settings()["online_password"]))
def set_settings(**kv):
s = settings()
s.update(kv)
with open(sfile, "w") as fh:
json.dump(s, fh)
# the Python bbbridge's file is ours too: replaced, then removed - not kept as the player's
ovfile = os.path.join(user, "host_overrides.custom.json")
with open(ovfile, "w") as fh:
json.dump({"_note": "bbbridge: the game's own server traffic goes through the bridge",
"https://thehuntersdream.com:20443": "http://127.0.0.1:31316"}, fh)
route(True)
route(False)
check("route off: an old (Python) bbbridge overrides file is removed, not restored",
not os.path.exists(ovfile) and not os.path.exists(ovfile + ".before-bbbridge"),
os.listdir(user))
# the app puts the Hunter's Dream account in while routed; off brings the shadNet one back
for name, pw in (("hunter_sn", "secret"), ("renamed_sn", "secret2")): # also when changed in between
set_settings(online_npid=name, online_password=pw)
route(True)
set_settings(online_npid="discord_name")
route(False)
s = settings()
check("route off: the shadNet account comes back (%s)" % name,
s["online_npid"] == name and s["online_password"] == pw and s["online_server"] == "srv.shadps4.net:31313",
(s["online_npid"], s["online_server"]))
def main():
print("== bbbridge C++ end-to-end (%s) ==" % os.path.abspath(EXE))
part_a()
part_b()
part_c()
part_d()
if os.name == "nt": # routing bbport for Windows (setup.cpp); on Linux the bbport launcher does it
part_e()
print("\n%d passed, %d failed" % (len(PASSED), len(FAILED)))
sys.exit(1 if FAILED else 0)
if __name__ == "__main__":
main()
+209
View File
@@ -0,0 +1,209 @@
// Byte-level unit tests: the same checks as the Python bridge's test_p2p.py
// part 1, plus protobuf and the account id (values the Python bridge produced).
#include <cstdio>
#include <fstream>
#include <string>
#include <vector>
#include "bridge.hpp"
#include "p2p.hpp"
#include "proto3.hpp"
#ifdef _WIN32
#include "setup.hpp"
#endif
#include "shadnet.hpp"
using namespace bb;
using namespace bb::p2p;
static int g_pass = 0, g_fail = 0;
static void check(const char* name, bool ok, const std::string& detail = "") {
if (ok) {
++g_pass;
std::printf(" [PASS] %s\n", name);
} else {
++g_fail;
std::printf(" [FAIL] %s %s\n", name, detail.c_str());
}
}
static std::string hex(const std::string& s) {
std::string out;
for (unsigned char c : s) out += fmt("%02x", c);
return out;
}
static std::string be16s(uint16_t v) { return std::string{static_cast<char>(v >> 8), static_cast<char>(v & 0xFF)}; }
static std::string handshake(uint8_t kind, const std::string& from, const std::string& to, uint32_t from_conn,
uint64_t nonce) {
std::string p = "SHAD";
p.push_back(static_cast<char>(T_HANDSHAKE));
p.push_back(static_cast<char>(kind));
std::string f = from, t = to;
f.resize(16, '\0');
t.resize(16, '\0');
p += f + t;
for (int i = 0; i < 4; ++i) p.push_back(static_cast<char>((from_conn >> (8 * i)) & 0xFF));
p += std::string(12, '\0');
for (int i = 0; i < 8; ++i) p.push_back(static_cast<char>((nonce >> (8 * i)) & 0xFF));
return p;
}
static std::string m2(uint8_t kind, uint64_t room, uint16_t from_member, uint16_t to_member, const std::string& from,
uint64_t nonce) {
std::string p = "SHAD";
p.push_back(static_cast<char>(T_M2));
p.push_back(static_cast<char>(kind));
for (int i = 0; i < 8; ++i) p.push_back(static_cast<char>((room >> (8 * i)) & 0xFF));
p.push_back(static_cast<char>(from_member & 0xFF));
p.push_back(static_cast<char>(from_member >> 8));
p.push_back(static_cast<char>(to_member & 0xFF));
p.push_back(static_cast<char>(to_member >> 8));
std::string f = from;
f.resize(16, '\0');
p += f + std::string(8, '\0');
for (int i = 0; i < 8; ++i) p.push_back(static_cast<char>((nonce >> (8 * i)) & 0xFF));
return p;
}
static std::string control(uint8_t kind, const std::string& from, const std::string& to) {
std::string p = "SHAD";
p.push_back(static_cast<char>(T_CONTROL));
p.push_back(static_cast<char>(kind));
std::string f = from, t = to;
f.resize(16, '\0');
t.resize(16, '\0');
p += f + t + std::string(12, '\0');
return p;
}
template <typename T>
static T le(const std::string& s, size_t at) {
uint64_t v = 0;
for (size_t i = 0; i < sizeof(T); ++i) v |= static_cast<uint64_t>(static_cast<uint8_t>(s[at + i])) << (8 * i);
return static_cast<T>(v);
}
int main() {
std::printf("== bbbridge unit ==\n");
std::string out;
// framing
check("port->bbhost one-byte vports",
port_to_ps4(be16s(40) + be16s(30) + "game", out) == Frame::Game && out == std::string("\xff\xc3\x28\x1e") + "game",
hex(out));
check("bbhost->port one-byte vports",
ps4_to_port(std::string("\xff\xc3\x1e\x28") + "data", out) && out == be16s(30) + be16s(40) + "data", hex(out));
std::string two;
port_to_ps4(be16s(3000) + be16s(40) + "x", two);
check("two-byte vports round trip", ps4_to_port(two, out) && out == be16s(3000) + be16s(40) + "x", hex(two));
check("comid flag skipped",
ps4_to_port(std::string("\xff\xe3") + "ABCD" + "\x28\x1e" + "z", out) && out == be16s(40) + be16s(30) + "z");
check("probe is not game data", !ps4_to_port(kProbe, out));
check("port signaling channel detected",
port_to_ps4(be16s(0xFFFF) + be16s(0xFFFF) + "SHAD\x05", out) == Frame::Signaling && out == "SHAD\x05");
const std::string token = "T0K3N!!!";
const std::string wrapped = relay_wrap(token, 41000, std::string("\xff\xc3\x28\x1e"));
check("relay frame to server", wrapped.substr(0, 2) == "\xfbR" && wrapped.substr(2, 8) == token &&
wrapped.substr(10, 2) == be16s(41000) && wrapped.size() == 16);
int port = 0;
check("relay frame from server",
relay_unwrap(std::string("\xfbr") + be16s(41001) + "dg", port, out) && port == 41001 && out == "dg");
// STUN
std::string txid;
const std::string req = stun_request("", txid);
check("stun request shape", req.substr(0, 4) == be16s(1) + be16s(16) && req.substr(4, 4) == "\x21\x12\xa4\x42" &&
req.substr(20, 4) == std::string("\x81\x00\x00\x0c", 4) && req.substr(24, 4) == "bbr1");
const uint32_t addr = (5u << 24) | (6u << 16) | (7u << 8) | 8u;
const uint32_t xaddr = addr ^ ((static_cast<uint8_t>(txid[0]) << 24) | (static_cast<uint8_t>(txid[1]) << 16) |
(static_cast<uint8_t>(txid[2]) << 8) | static_cast<uint8_t>(txid[3]));
const uint16_t xport = static_cast<uint16_t>(4321 ^ ((static_cast<uint8_t>(txid[0]) << 8) | static_cast<uint8_t>(txid[1])));
std::string attr = be16s(0x0020) + be16s(8) + std::string("\x00\x01", 2) + be16s(xport) + be16s(xaddr >> 16) +
be16s(xaddr & 0xFFFF);
const std::string resp = be16s(0x0101) + be16s(static_cast<uint16_t>(attr.size())) + txid + attr;
StunAnswer a;
check("stun xor-mapped parsed", stun_parse(resp, txid, a) && a.mapped.ip == "5.6.7.8" && a.mapped.port == 4321,
a.mapped.str());
check("stun wrong txid rejected", !stun_parse(resp, std::string(16, '\0'), a));
// signaling, answered for the bbhost peer
PeerSignaling ps("hunter", 2, "porthost", 1, 777);
auto r = ps.handle(handshake(HS_OFFER, "porthost", "hunter", 5, 0));
check("Offer -> Accept from the peer",
r.size() == 1 && r[0].size() == 62 && static_cast<uint8_t>(r[0][5]) == HS_ACCEPT &&
r[0].compare(6, 6, "hunter") == 0 && r[0].compare(22, 8, "porthost") == 0 && le<uint32_t>(r[0], 42) == 5 &&
le<uint32_t>(r[0], 46) == 0);
r = ps.handle(handshake(HS_CHECK, "porthost", "hunter", 7, 123456));
check("Check -> CheckAck echoes nonce",
r.size() == 1 && static_cast<uint8_t>(r[0][5]) == HS_CHECK_ACK && le<uint64_t>(r[0], 54) == 123456);
r = ps.handle(control(CTL_ACTIVATION_REQUEST, "porthost", "hunter"));
check("ActivationRequest -> ActivationAck", r.size() == 1 && r[0].size() == 50 && static_cast<uint8_t>(r[0][5]) == CTL_ACTIVATION_ACK);
const auto first = ps.handle(control(CTL_ESTABLISHED, "porthost", "hunter"));
const auto second = ps.handle(control(CTL_ESTABLISHED, "porthost", "hunter"));
check("Established answered once", first.size() == 1 && second.empty());
r = ps.handle(m2(HS_OFFER, 777, 1, 2, "porthost", 0));
check("matching2 Offer -> Accept, member ids swapped",
r.size() == 1 && r[0].size() == 50 && static_cast<uint8_t>(r[0][5]) == HS_ACCEPT && le<uint64_t>(r[0], 6) == 777 &&
le<uint16_t>(r[0], 14) == 2 && le<uint16_t>(r[0], 16) == 1 && r[0].compare(18, 6, "hunter") == 0);
r = ps.handle(m2(HS_CHECK, 777, 1, 2, "porthost", 99));
check("matching2 Check -> CheckAck", r.size() == 1 && static_cast<uint8_t>(r[0][5]) == HS_CHECK_ACK && le<uint64_t>(r[0], 42) == 99);
PeerSignaling provisional("guest", -29999, "porthost", 1, 777);
const auto opening = provisional.opening();
check("a provisional (negative) key opens without matching2",
opening.size() == 1 && static_cast<uint8_t>(opening[0][5]) == CTL_ACTIVATION_REQUEST &&
provisional.conn_id == 0x4242);
r = ps.handle(std::string("SHAD\x05", 5) + std::string("\x03\x00\x00\x00", 4) + std::string("\x2b\x02\x00\x00\x00\x00\x00\x00", 8));
check("echo ping -> pong", r.size() == 1 && r[0].size() == 17 && static_cast<uint8_t>(r[0][4]) == T_ECHO_PONG &&
le<uint32_t>(r[0], 5) == 3 && le<uint64_t>(r[0], 9) == 555);
// protobuf + shadNet framing
pb::Writer w;
w.s(1, "npid").u(2, 300).flag(4, true).u(5, 0).msg(6, "");
pb::Message m(w.str());
check("protobuf round trip", m.ok() && m.s(1) == "npid" && m.u(2) == 300 && m.u(4) == 1 && !m.has(5) && m.has(6));
std::string blob;
check("proto blob framing", shadnet::parse_proto_blob(shadnet::proto_blob("abc"), blob) && blob == "abc");
shadnet::Header h;
check("packet header", shadnet::parse_header(shadnet::build_reply(101, 7, 0, "xy"), h) && h.type == shadnet::Reply &&
h.command == 101 && h.size == 18 && h.packet_id == 7);
// account id: the values the Python bridge gave these names
check("account id matches the Python bridge (porthost)", account_id_for("porthost") == 4562636682122493ull,
std::to_string(account_id_for("porthost")));
check("account id matches the Python bridge (tester01)", account_id_for("tester01") == 4533768973479740ull,
std::to_string(account_id_for("tester01")));
#ifdef _WIN32 // bbport for Windows' folder (setup.cpp); on Linux the launcher routes the game
// where bbport is found: chosen > the folder bbbridge is in > the launcher's game folder
{
const fs::path root = fs::temp_directory_path() / L"bbbridge-unit-find";
std::error_code ec;
fs::remove_all(root, ec);
const fs::path bbport_dir = root / L"bloodborne", other = root / L"other", games = root / L"games";
for (const auto& d : {bbport_dir / L"bbbridge", other, games / L"CUSA03173"}) fs::create_directories(d);
std::ofstream(bbport_dir / L"Bloodborne.exe") << "x";
std::ofstream(games / L"Play Bloodborne.exe") << "x";
const std::string game_dir = narrow((games / L"CUSA03173").wstring());
BbportSource src{};
check("bbport: the folder bbbridge is in",
find_bbport(bbport_dir / L"bbbridge", {}, game_dir, &src) == bbport_dir && src == BbportSource::ExeFolder);
check("bbport: bbbridge.exe right next to Bloodborne.exe",
find_bbport(bbport_dir, {}, "", &src) == bbport_dir && src == BbportSource::ExeFolder);
check("bbport: a chosen folder that is gone falls back",
find_bbport(bbport_dir / L"bbbridge", other, game_dir, &src) == bbport_dir && src == BbportSource::ExeFolder);
check("bbport: the chosen folder wins",
find_bbport(bbport_dir / L"bbbridge", games, "", &src) == games && src == BbportSource::Chosen);
check("bbport: from the launcher's game folder",
find_bbport(other / L"bbbridge", {}, game_dir, &src) == games && src == BbportSource::Launcher);
check("bbport: not found",
find_bbport(other / L"bbbridge", {}, "", &src).empty() && src == BbportSource::None);
fs::remove_all(root, ec);
}
#endif
std::printf("\n%d passed, %d failed\n", g_pass, g_fail);
return g_fail ? 1 : 0;
}
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014-2024 Omar Cornut
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+138
View File
@@ -0,0 +1,138 @@
//-----------------------------------------------------------------------------
// DEAR IMGUI COMPILE-TIME OPTIONS
// Runtime options (clipboard callbacks, enabling various features, etc.) can generally be set via the ImGuiIO structure.
// You can use ImGui::SetAllocatorFunctions() before calling ImGui::CreateContext() to rewire memory allocation functions.
//-----------------------------------------------------------------------------
// A) You may edit imconfig.h (and not overwrite it when updating Dear ImGui, or maintain a patch/rebased branch with your modifications to it)
// B) or '#define IMGUI_USER_CONFIG "my_imgui_config.h"' in your project and then add directives in your own file without touching this template.
//-----------------------------------------------------------------------------
// You need to make sure that configuration settings are defined consistently _everywhere_ Dear ImGui is used, which include the imgui*.cpp
// files but also _any_ of your code that uses Dear ImGui. This is because some compile-time options have an affect on data structures.
// Defining those options in imconfig.h will ensure every compilation unit gets to see the same data structure layouts.
// Call IMGUI_CHECKVERSION() from your .cpp file to verify that the data structures your files are using are matching the ones imgui.cpp is using.
//-----------------------------------------------------------------------------
#pragma once
//---- Define assertion handler. Defaults to calling assert().
// If your macro uses multiple statements, make sure is enclosed in a 'do { .. } while (0)' block so it can be used as a single statement.
//#define IM_ASSERT(_EXPR) MyAssert(_EXPR)
//#define IM_ASSERT(_EXPR) ((void)(_EXPR)) // Disable asserts
//---- Define attributes of all API symbols declarations, e.g. for DLL under Windows
// Using Dear ImGui via a shared library is not recommended, because of function call overhead and because we don't guarantee backward nor forward ABI compatibility.
// - Windows DLL users: heaps and globals are not shared across DLL boundaries! You will need to call SetCurrentContext() + SetAllocatorFunctions()
// for each static/DLL boundary you are calling from. Read "Context and Memory Allocators" section of imgui.cpp for more details.
//#define IMGUI_API __declspec(dllexport) // MSVC Windows: DLL export
//#define IMGUI_API __declspec(dllimport) // MSVC Windows: DLL import
//#define IMGUI_API __attribute__((visibility("default"))) // GCC/Clang: override visibility when set is hidden
//---- Don't define obsolete functions/enums/behaviors. Consider enabling from time to time after updating to clean your code of obsolete function/names.
//#define IMGUI_DISABLE_OBSOLETE_FUNCTIONS
//---- Disable all of Dear ImGui or don't implement standard windows/tools.
// It is very strongly recommended to NOT disable the demo windows and debug tool during development. They are extremely useful in day to day work. Please read comments in imgui_demo.cpp.
//#define IMGUI_DISABLE // Disable everything: all headers and source files will be empty.
//#define IMGUI_DISABLE_DEMO_WINDOWS // Disable demo windows: ShowDemoWindow()/ShowStyleEditor() will be empty.
//#define IMGUI_DISABLE_DEBUG_TOOLS // Disable metrics/debugger and other debug tools: ShowMetricsWindow(), ShowDebugLogWindow() and ShowIDStackToolWindow() will be empty.
//---- Don't implement some functions to reduce linkage requirements.
//#define IMGUI_DISABLE_WIN32_DEFAULT_CLIPBOARD_FUNCTIONS // [Win32] Don't implement default clipboard handler. Won't use and link with OpenClipboard/GetClipboardData/CloseClipboard etc. (user32.lib/.a, kernel32.lib/.a)
//#define IMGUI_ENABLE_WIN32_DEFAULT_IME_FUNCTIONS // [Win32] [Default with Visual Studio] Implement default IME handler (require imm32.lib/.a, auto-link for Visual Studio, -limm32 on command-line for MinGW)
//#define IMGUI_DISABLE_WIN32_DEFAULT_IME_FUNCTIONS // [Win32] [Default with non-Visual Studio compilers] Don't implement default IME handler (won't require imm32.lib/.a)
//#define IMGUI_DISABLE_WIN32_FUNCTIONS // [Win32] Won't use and link with any Win32 function (clipboard, IME).
//#define IMGUI_ENABLE_OSX_DEFAULT_CLIPBOARD_FUNCTIONS // [OSX] Implement default OSX clipboard handler (need to link with '-framework ApplicationServices', this is why this is not the default).
//#define IMGUI_DISABLE_DEFAULT_SHELL_FUNCTIONS // Don't implement default platform_io.Platform_OpenInShellFn() handler (Win32: ShellExecute(), require shell32.lib/.a, Mac/Linux: use system("")).
//#define IMGUI_DISABLE_DEFAULT_FORMAT_FUNCTIONS // Don't implement ImFormatString/ImFormatStringV so you can implement them yourself (e.g. if you don't want to link with vsnprintf)
//#define IMGUI_DISABLE_DEFAULT_MATH_FUNCTIONS // Don't implement ImFabs/ImSqrt/ImPow/ImFmod/ImCos/ImSin/ImAcos/ImAtan2 so you can implement them yourself.
//#define IMGUI_DISABLE_FILE_FUNCTIONS // Don't implement ImFileOpen/ImFileClose/ImFileRead/ImFileWrite and ImFileHandle at all (replace them with dummies)
//#define IMGUI_DISABLE_DEFAULT_FILE_FUNCTIONS // Don't implement ImFileOpen/ImFileClose/ImFileRead/ImFileWrite and ImFileHandle so you can implement them yourself if you don't want to link with fopen/fclose/fread/fwrite. This will also disable the LogToTTY() function.
//#define IMGUI_DISABLE_DEFAULT_ALLOCATORS // Don't implement default allocators calling malloc()/free() to avoid linking with them. You will need to call ImGui::SetAllocatorFunctions().
//#define IMGUI_DISABLE_SSE // Disable use of SSE intrinsics even if available
//---- Enable Test Engine / Automation features.
//#define IMGUI_ENABLE_TEST_ENGINE // Enable imgui_test_engine hooks. Generally set automatically by include "imgui_te_config.h", see Test Engine for details.
//---- Include imgui_user.h at the end of imgui.h as a convenience
// May be convenient for some users to only explicitly include vanilla imgui.h and have extra stuff included.
//#define IMGUI_INCLUDE_IMGUI_USER_H
//#define IMGUI_USER_H_FILENAME "my_folder/my_imgui_user.h"
//---- Pack colors to BGRA8 instead of RGBA8 (to avoid converting from one to another)
//#define IMGUI_USE_BGRA_PACKED_COLOR
//---- Use 32-bit for ImWchar (default is 16-bit) to support Unicode planes 1-16. (e.g. point beyond 0xFFFF like emoticons, dingbats, symbols, shapes, ancient languages, etc...)
//#define IMGUI_USE_WCHAR32
//---- Avoid multiple STB libraries implementations, or redefine path/filenames to prioritize another version
// By default the embedded implementations are declared static and not available outside of Dear ImGui sources files.
//#define IMGUI_STB_TRUETYPE_FILENAME "my_folder/stb_truetype.h"
//#define IMGUI_STB_RECT_PACK_FILENAME "my_folder/stb_rect_pack.h"
//#define IMGUI_STB_SPRINTF_FILENAME "my_folder/stb_sprintf.h" // only used if IMGUI_USE_STB_SPRINTF is defined.
//#define IMGUI_DISABLE_STB_TRUETYPE_IMPLEMENTATION
//#define IMGUI_DISABLE_STB_RECT_PACK_IMPLEMENTATION
//#define IMGUI_DISABLE_STB_SPRINTF_IMPLEMENTATION // only disabled if IMGUI_USE_STB_SPRINTF is defined.
//---- Use stb_sprintf.h for a faster implementation of vsnprintf instead of the one from libc (unless IMGUI_DISABLE_DEFAULT_FORMAT_FUNCTIONS is defined)
// Compatibility checks of arguments and formats done by clang and GCC will be disabled in order to support the extra formats provided by stb_sprintf.h.
//#define IMGUI_USE_STB_SPRINTF
//---- Use FreeType to build and rasterize the font atlas (instead of stb_truetype which is embedded by default in Dear ImGui)
// Requires FreeType headers to be available in the include path. Requires program to be compiled with 'misc/freetype/imgui_freetype.cpp' (in this repository) + the FreeType library (not provided).
// On Windows you may use vcpkg with 'vcpkg install freetype --triplet=x64-windows' + 'vcpkg integrate install'.
//#define IMGUI_ENABLE_FREETYPE
//---- Use FreeType + plutosvg or lunasvg to render OpenType SVG fonts (SVGinOT)
// Only works in combination with IMGUI_ENABLE_FREETYPE.
// - lunasvg is currently easier to acquire/install, as e.g. it is part of vcpkg.
// - plutosvg will support more fonts and may load them faster. It currently requires to be built manually but it is fairly easy. See misc/freetype/README for instructions.
// - Both require headers to be available in the include path + program to be linked with the library code (not provided).
// - (note: lunasvg implementation is based on Freetype's rsvg-port.c which is licensed under CeCILL-C Free Software License Agreement)
//#define IMGUI_ENABLE_FREETYPE_PLUTOSVG
//#define IMGUI_ENABLE_FREETYPE_LUNASVG
//---- Use stb_truetype to build and rasterize the font atlas (default)
// The only purpose of this define is if you want force compilation of the stb_truetype backend ALONG with the FreeType backend.
//#define IMGUI_ENABLE_STB_TRUETYPE
//---- Define constructor and implicit cast operators to convert back<>forth between your math types and ImVec2/ImVec4.
// This will be inlined as part of ImVec2 and ImVec4 class declarations.
/*
#define IM_VEC2_CLASS_EXTRA \
constexpr ImVec2(const MyVec2& f) : x(f.x), y(f.y) {} \
operator MyVec2() const { return MyVec2(x,y); }
#define IM_VEC4_CLASS_EXTRA \
constexpr ImVec4(const MyVec4& f) : x(f.x), y(f.y), z(f.z), w(f.w) {} \
operator MyVec4() const { return MyVec4(x,y,z,w); }
*/
//---- ...Or use Dear ImGui's own very basic math operators.
//#define IMGUI_DEFINE_MATH_OPERATORS
//---- Use 32-bit vertex indices (default is 16-bit) is one way to allow large meshes with more than 64K vertices.
// Your renderer backend will need to support it (most example renderer backends support both 16/32-bit indices).
// Another way to allow large meshes while keeping 16-bit indices is to handle ImDrawCmd::VtxOffset in your renderer.
// Read about ImGuiBackendFlags_RendererHasVtxOffset for details.
//#define ImDrawIdx unsigned int
//---- Override ImDrawCallback signature (will need to modify renderer backends accordingly)
//struct ImDrawList;
//struct ImDrawCmd;
//typedef void (*MyImDrawCallback)(const ImDrawList* draw_list, const ImDrawCmd* cmd, void* my_renderer_user_data);
//#define ImDrawCallback MyImDrawCallback
//---- Debug Tools: Macro to break in Debugger (we provide a default implementation of this in the codebase)
// (use 'Metrics->Tools->Item Picker' to pick widgets with the mouse and break into them for easy debugging.)
//#define IM_DEBUG_BREAK IM_ASSERT(0)
//#define IM_DEBUG_BREAK __debugbreak()
//---- Debug Tools: Enable slower asserts
//#define IMGUI_DEBUG_PARANOID
//---- Tip: You can add extra functions within the ImGui:: namespace from anywhere (e.g. your own sources/header files)
/*
namespace ImGui
{
void MyFunction(const char* name, MyMatrix44* mtx);
}
*/
File diff suppressed because it is too large. Load diff
+3714
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+618
View File
@@ -0,0 +1,618 @@
// dear imgui: Renderer Backend for DirectX11
// This needs to be used along with a Platform Backend (e.g. Win32)
// Implemented features:
// [X] Renderer: User texture binding. Use 'ID3D11ShaderResourceView*' as ImTextureID. Read the FAQ about ImTextureID!
// [X] Renderer: Large meshes support (64k+ vertices) with 16-bit indices.
// [X] Renderer: Expose selected render state for draw callbacks to use. Access in '(ImGui_ImplXXXX_RenderState*)GetPlatformIO().Renderer_RenderState'.
// You can use unmodified imgui_impl_* files in your project. See examples/ folder for examples of using this.
// Prefer including the entire imgui/ repository into your project (either as a copy or as a submodule), and only build the backends you need.
// Learn about Dear ImGui:
// - FAQ https://dearimgui.com/faq
// - Getting Started https://dearimgui.com/getting-started
// - Documentation https://dearimgui.com/docs (same as your local docs/ folder).
// - Introduction, links and more at the top of imgui.cpp
// CHANGELOG
// (minor and older changes stripped away, please see git history for details)
// 2024-10-07: DirectX11: Changed default texture sampler to Clamp instead of Repeat/Wrap.
// 2024-10-07: DirectX11: Expose selected render state in ImGui_ImplDX11_RenderState, which you can access in 'void* platform_io.Renderer_RenderState' during draw callbacks.
// 2022-10-11: Using 'nullptr' instead of 'NULL' as per our switch to C++11.
// 2021-06-29: Reorganized backend to pull data from a single structure to facilitate usage with multiple-contexts (all g_XXXX access changed to bd->XXXX).
// 2021-05-19: DirectX11: Replaced direct access to ImDrawCmd::TextureId with a call to ImDrawCmd::GetTexID(). (will become a requirement)
// 2021-02-18: DirectX11: Change blending equation to preserve alpha in output buffer.
// 2019-08-01: DirectX11: Fixed code querying the Geometry Shader state (would generally error with Debug layer enabled).
// 2019-07-21: DirectX11: Backup, clear and restore Geometry Shader is any is bound when calling ImGui_ImplDX11_RenderDrawData. Clearing Hull/Domain/Compute shaders without backup/restore.
// 2019-05-29: DirectX11: Added support for large mesh (64K+ vertices), enable ImGuiBackendFlags_RendererHasVtxOffset flag.
// 2019-04-30: DirectX11: Added support for special ImDrawCallback_ResetRenderState callback to reset render state.
// 2018-12-03: Misc: Added #pragma comment statement to automatically link with d3dcompiler.lib when using D3DCompile().
// 2018-11-30: Misc: Setting up io.BackendRendererName so it can be displayed in the About Window.
// 2018-08-01: DirectX11: Querying for IDXGIFactory instead of IDXGIFactory1 to increase compatibility.
// 2018-07-13: DirectX11: Fixed unreleased resources in Init and Shutdown functions.
// 2018-06-08: Misc: Extracted imgui_impl_dx11.cpp/.h away from the old combined DX11+Win32 example.
// 2018-06-08: DirectX11: Use draw_data->DisplayPos and draw_data->DisplaySize to setup projection matrix and clipping rectangle.
// 2018-02-16: Misc: Obsoleted the io.RenderDrawListsFn callback and exposed ImGui_ImplDX11_RenderDrawData() in the .h file so you can call it yourself.
// 2018-02-06: Misc: Removed call to ImGui::Shutdown() which is not available from 1.60 WIP, user needs to call CreateContext/DestroyContext themselves.
// 2016-05-07: DirectX11: Disabling depth-write.
#include "imgui.h"
#ifndef IMGUI_DISABLE
#include "imgui_impl_dx11.h"
// DirectX
#include <stdio.h>
#include <d3d11.h>
#include <d3dcompiler.h>
#ifdef _MSC_VER
#pragma comment(lib, "d3dcompiler") // Automatically link with d3dcompiler.lib as we are using D3DCompile() below.
#endif
// DirectX11 data
struct ImGui_ImplDX11_Data
{
ID3D11Device* pd3dDevice;
ID3D11DeviceContext* pd3dDeviceContext;
IDXGIFactory* pFactory;
ID3D11Buffer* pVB;
ID3D11Buffer* pIB;
ID3D11VertexShader* pVertexShader;
ID3D11InputLayout* pInputLayout;
ID3D11Buffer* pVertexConstantBuffer;
ID3D11PixelShader* pPixelShader;
ID3D11SamplerState* pFontSampler;
ID3D11ShaderResourceView* pFontTextureView;
ID3D11RasterizerState* pRasterizerState;
ID3D11BlendState* pBlendState;
ID3D11DepthStencilState* pDepthStencilState;
int VertexBufferSize;
int IndexBufferSize;
ImGui_ImplDX11_Data() { memset((void*)this, 0, sizeof(*this)); VertexBufferSize = 5000; IndexBufferSize = 10000; }
};
struct VERTEX_CONSTANT_BUFFER_DX11
{
float mvp[4][4];
};
// Backend data stored in io.BackendRendererUserData to allow support for multiple Dear ImGui contexts
// It is STRONGLY preferred that you use docking branch with multi-viewports (== single Dear ImGui context + multiple windows) instead of multiple Dear ImGui contexts.
static ImGui_ImplDX11_Data* ImGui_ImplDX11_GetBackendData()
{
return ImGui::GetCurrentContext() ? (ImGui_ImplDX11_Data*)ImGui::GetIO().BackendRendererUserData : nullptr;
}
// Functions
static void ImGui_ImplDX11_SetupRenderState(ImDrawData* draw_data, ID3D11DeviceContext* device_ctx)
{
ImGui_ImplDX11_Data* bd = ImGui_ImplDX11_GetBackendData();
// Setup viewport
D3D11_VIEWPORT vp;
memset(&vp, 0, sizeof(D3D11_VIEWPORT));
vp.Width = draw_data->DisplaySize.x;
vp.Height = draw_data->DisplaySize.y;
vp.MinDepth = 0.0f;
vp.MaxDepth = 1.0f;
vp.TopLeftX = vp.TopLeftY = 0;
device_ctx->RSSetViewports(1, &vp);
// Setup shader and vertex buffers
unsigned int stride = sizeof(ImDrawVert);
unsigned int offset = 0;
device_ctx->IASetInputLayout(bd->pInputLayout);
device_ctx->IASetVertexBuffers(0, 1, &bd->pVB, &stride, &offset);
device_ctx->IASetIndexBuffer(bd->pIB, sizeof(ImDrawIdx) == 2 ? DXGI_FORMAT_R16_UINT : DXGI_FORMAT_R32_UINT, 0);
device_ctx->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
device_ctx->VSSetShader(bd->pVertexShader, nullptr, 0);
device_ctx->VSSetConstantBuffers(0, 1, &bd->pVertexConstantBuffer);
device_ctx->PSSetShader(bd->pPixelShader, nullptr, 0);
device_ctx->PSSetSamplers(0, 1, &bd->pFontSampler);
device_ctx->GSSetShader(nullptr, nullptr, 0);
device_ctx->HSSetShader(nullptr, nullptr, 0); // In theory we should backup and restore this as well.. very infrequently used..
device_ctx->DSSetShader(nullptr, nullptr, 0); // In theory we should backup and restore this as well.. very infrequently used..
device_ctx->CSSetShader(nullptr, nullptr, 0); // In theory we should backup and restore this as well.. very infrequently used..
// Setup blend state
const float blend_factor[4] = { 0.f, 0.f, 0.f, 0.f };
device_ctx->OMSetBlendState(bd->pBlendState, blend_factor, 0xffffffff);
device_ctx->OMSetDepthStencilState(bd->pDepthStencilState, 0);
device_ctx->RSSetState(bd->pRasterizerState);
}
// Render function
void ImGui_ImplDX11_RenderDrawData(ImDrawData* draw_data)
{
// Avoid rendering when minimized
if (draw_data->DisplaySize.x <= 0.0f || draw_data->DisplaySize.y <= 0.0f)
return;
ImGui_ImplDX11_Data* bd = ImGui_ImplDX11_GetBackendData();
ID3D11DeviceContext* device = bd->pd3dDeviceContext;
// Create and grow vertex/index buffers if needed
if (!bd->pVB || bd->VertexBufferSize < draw_data->TotalVtxCount)
{
if (bd->pVB) { bd->pVB->Release(); bd->pVB = nullptr; }
bd->VertexBufferSize = draw_data->TotalVtxCount + 5000;
D3D11_BUFFER_DESC desc;
memset(&desc, 0, sizeof(D3D11_BUFFER_DESC));
desc.Usage = D3D11_USAGE_DYNAMIC;
desc.ByteWidth = bd->VertexBufferSize * sizeof(ImDrawVert);
desc.BindFlags = D3D11_BIND_VERTEX_BUFFER;
desc.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE;
desc.MiscFlags = 0;
if (bd->pd3dDevice->CreateBuffer(&desc, nullptr, &bd->pVB) < 0)
return;
}
if (!bd->pIB || bd->IndexBufferSize < draw_data->TotalIdxCount)
{
if (bd->pIB) { bd->pIB->Release(); bd->pIB = nullptr; }
bd->IndexBufferSize = draw_data->TotalIdxCount + 10000;
D3D11_BUFFER_DESC desc;
memset(&desc, 0, sizeof(D3D11_BUFFER_DESC));
desc.Usage = D3D11_USAGE_DYNAMIC;
desc.ByteWidth = bd->IndexBufferSize * sizeof(ImDrawIdx);
desc.BindFlags = D3D11_BIND_INDEX_BUFFER;
desc.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE;
if (bd->pd3dDevice->CreateBuffer(&desc, nullptr, &bd->pIB) < 0)
return;
}
// Upload vertex/index data into a single contiguous GPU buffer
D3D11_MAPPED_SUBRESOURCE vtx_resource, idx_resource;
if (device->Map(bd->pVB, 0, D3D11_MAP_WRITE_DISCARD, 0, &vtx_resource) != S_OK)
return;
if (device->Map(bd->pIB, 0, D3D11_MAP_WRITE_DISCARD, 0, &idx_resource) != S_OK)
return;
ImDrawVert* vtx_dst = (ImDrawVert*)vtx_resource.pData;
ImDrawIdx* idx_dst = (ImDrawIdx*)idx_resource.pData;
for (int n = 0; n < draw_data->CmdListsCount; n++)
{
const ImDrawList* draw_list = draw_data->CmdLists[n];
memcpy(vtx_dst, draw_list->VtxBuffer.Data, draw_list->VtxBuffer.Size * sizeof(ImDrawVert));
memcpy(idx_dst, draw_list->IdxBuffer.Data, draw_list->IdxBuffer.Size * sizeof(ImDrawIdx));
vtx_dst += draw_list->VtxBuffer.Size;
idx_dst += draw_list->IdxBuffer.Size;
}
device->Unmap(bd->pVB, 0);
device->Unmap(bd->pIB, 0);
// Setup orthographic projection matrix into our constant buffer
// Our visible imgui space lies from draw_data->DisplayPos (top left) to draw_data->DisplayPos+data_data->DisplaySize (bottom right). DisplayPos is (0,0) for single viewport apps.
{
D3D11_MAPPED_SUBRESOURCE mapped_resource;
if (device->Map(bd->pVertexConstantBuffer, 0, D3D11_MAP_WRITE_DISCARD, 0, &mapped_resource) != S_OK)
return;
VERTEX_CONSTANT_BUFFER_DX11* constant_buffer = (VERTEX_CONSTANT_BUFFER_DX11*)mapped_resource.pData;
float L = draw_data->DisplayPos.x;
float R = draw_data->DisplayPos.x + draw_data->DisplaySize.x;
float T = draw_data->DisplayPos.y;
float B = draw_data->DisplayPos.y + draw_data->DisplaySize.y;
float mvp[4][4] =
{
{ 2.0f/(R-L), 0.0f, 0.0f, 0.0f },
{ 0.0f, 2.0f/(T-B), 0.0f, 0.0f },
{ 0.0f, 0.0f, 0.5f, 0.0f },
{ (R+L)/(L-R), (T+B)/(B-T), 0.5f, 1.0f },
};
memcpy(&constant_buffer->mvp, mvp, sizeof(mvp));
device->Unmap(bd->pVertexConstantBuffer, 0);
}
// Backup DX state that will be modified to restore it afterwards (unfortunately this is very ugly looking and verbose. Close your eyes!)
struct BACKUP_DX11_STATE
{
UINT ScissorRectsCount, ViewportsCount;
D3D11_RECT ScissorRects[D3D11_VIEWPORT_AND_SCISSORRECT_OBJECT_COUNT_PER_PIPELINE];
D3D11_VIEWPORT Viewports[D3D11_VIEWPORT_AND_SCISSORRECT_OBJECT_COUNT_PER_PIPELINE];
ID3D11RasterizerState* RS;
ID3D11BlendState* BlendState;
FLOAT BlendFactor[4];
UINT SampleMask;
UINT StencilRef;
ID3D11DepthStencilState* DepthStencilState;
ID3D11ShaderResourceView* PSShaderResource;
ID3D11SamplerState* PSSampler;
ID3D11PixelShader* PS;
ID3D11VertexShader* VS;
ID3D11GeometryShader* GS;
UINT PSInstancesCount, VSInstancesCount, GSInstancesCount;
ID3D11ClassInstance *PSInstances[256], *VSInstances[256], *GSInstances[256]; // 256 is max according to PSSetShader documentation
D3D11_PRIMITIVE_TOPOLOGY PrimitiveTopology;
ID3D11Buffer* IndexBuffer, *VertexBuffer, *VSConstantBuffer;
UINT IndexBufferOffset, VertexBufferStride, VertexBufferOffset;
DXGI_FORMAT IndexBufferFormat;
ID3D11InputLayout* InputLayout;
};
BACKUP_DX11_STATE old = {};
old.ScissorRectsCount = old.ViewportsCount = D3D11_VIEWPORT_AND_SCISSORRECT_OBJECT_COUNT_PER_PIPELINE;
device->RSGetScissorRects(&old.ScissorRectsCount, old.ScissorRects);
device->RSGetViewports(&old.ViewportsCount, old.Viewports);
device->RSGetState(&old.RS);
device->OMGetBlendState(&old.BlendState, old.BlendFactor, &old.SampleMask);
device->OMGetDepthStencilState(&old.DepthStencilState, &old.StencilRef);
device->PSGetShaderResources(0, 1, &old.PSShaderResource);
device->PSGetSamplers(0, 1, &old.PSSampler);
old.PSInstancesCount = old.VSInstancesCount = old.GSInstancesCount = 256;
device->PSGetShader(&old.PS, old.PSInstances, &old.PSInstancesCount);
device->VSGetShader(&old.VS, old.VSInstances, &old.VSInstancesCount);
device->VSGetConstantBuffers(0, 1, &old.VSConstantBuffer);
device->GSGetShader(&old.GS, old.GSInstances, &old.GSInstancesCount);
device->IAGetPrimitiveTopology(&old.PrimitiveTopology);
device->IAGetIndexBuffer(&old.IndexBuffer, &old.IndexBufferFormat, &old.IndexBufferOffset);
device->IAGetVertexBuffers(0, 1, &old.VertexBuffer, &old.VertexBufferStride, &old.VertexBufferOffset);
device->IAGetInputLayout(&old.InputLayout);
// Setup desired DX state
ImGui_ImplDX11_SetupRenderState(draw_data, device);
// Setup render state structure (for callbacks and custom texture bindings)
ImGuiPlatformIO& platform_io = ImGui::GetPlatformIO();
ImGui_ImplDX11_RenderState render_state;
render_state.Device = bd->pd3dDevice;
render_state.DeviceContext = bd->pd3dDeviceContext;
render_state.SamplerDefault = bd->pFontSampler;
platform_io.Renderer_RenderState = &render_state;
// Render command lists
// (Because we merged all buffers into a single one, we maintain our own offset into them)
int global_idx_offset = 0;
int global_vtx_offset = 0;
ImVec2 clip_off = draw_data->DisplayPos;
for (int n = 0; n < draw_data->CmdListsCount; n++)
{
const ImDrawList* draw_list = draw_data->CmdLists[n];
for (int cmd_i = 0; cmd_i < draw_list->CmdBuffer.Size; cmd_i++)
{
const ImDrawCmd* pcmd = &draw_list->CmdBuffer[cmd_i];
if (pcmd->UserCallback != nullptr)
{
// User callback, registered via ImDrawList::AddCallback()
// (ImDrawCallback_ResetRenderState is a special callback value used by the user to request the renderer to reset render state.)
if (pcmd->UserCallback == ImDrawCallback_ResetRenderState)
ImGui_ImplDX11_SetupRenderState(draw_data, device);
else
pcmd->UserCallback(draw_list, pcmd);
}
else
{
// Project scissor/clipping rectangles into framebuffer space
ImVec2 clip_min(pcmd->ClipRect.x - clip_off.x, pcmd->ClipRect.y - clip_off.y);
ImVec2 clip_max(pcmd->ClipRect.z - clip_off.x, pcmd->ClipRect.w - clip_off.y);
if (clip_max.x <= clip_min.x || clip_max.y <= clip_min.y)
continue;
// Apply scissor/clipping rectangle
const D3D11_RECT r = { (LONG)clip_min.x, (LONG)clip_min.y, (LONG)clip_max.x, (LONG)clip_max.y };
device->RSSetScissorRects(1, &r);
// Bind texture, Draw
ID3D11ShaderResourceView* texture_srv = (ID3D11ShaderResourceView*)pcmd->GetTexID();
device->PSSetShaderResources(0, 1, &texture_srv);
device->DrawIndexed(pcmd->ElemCount, pcmd->IdxOffset + global_idx_offset, pcmd->VtxOffset + global_vtx_offset);
}
}
global_idx_offset += draw_list->IdxBuffer.Size;
global_vtx_offset += draw_list->VtxBuffer.Size;
}
platform_io.Renderer_RenderState = NULL;
// Restore modified DX state
device->RSSetScissorRects(old.ScissorRectsCount, old.ScissorRects);
device->RSSetViewports(old.ViewportsCount, old.Viewports);
device->RSSetState(old.RS); if (old.RS) old.RS->Release();
device->OMSetBlendState(old.BlendState, old.BlendFactor, old.SampleMask); if (old.BlendState) old.BlendState->Release();
device->OMSetDepthStencilState(old.DepthStencilState, old.StencilRef); if (old.DepthStencilState) old.DepthStencilState->Release();
device->PSSetShaderResources(0, 1, &old.PSShaderResource); if (old.PSShaderResource) old.PSShaderResource->Release();
device->PSSetSamplers(0, 1, &old.PSSampler); if (old.PSSampler) old.PSSampler->Release();
device->PSSetShader(old.PS, old.PSInstances, old.PSInstancesCount); if (old.PS) old.PS->Release();
for (UINT i = 0; i < old.PSInstancesCount; i++) if (old.PSInstances[i]) old.PSInstances[i]->Release();
device->VSSetShader(old.VS, old.VSInstances, old.VSInstancesCount); if (old.VS) old.VS->Release();
device->VSSetConstantBuffers(0, 1, &old.VSConstantBuffer); if (old.VSConstantBuffer) old.VSConstantBuffer->Release();
device->GSSetShader(old.GS, old.GSInstances, old.GSInstancesCount); if (old.GS) old.GS->Release();
for (UINT i = 0; i < old.VSInstancesCount; i++) if (old.VSInstances[i]) old.VSInstances[i]->Release();
device->IASetPrimitiveTopology(old.PrimitiveTopology);
device->IASetIndexBuffer(old.IndexBuffer, old.IndexBufferFormat, old.IndexBufferOffset); if (old.IndexBuffer) old.IndexBuffer->Release();
device->IASetVertexBuffers(0, 1, &old.VertexBuffer, &old.VertexBufferStride, &old.VertexBufferOffset); if (old.VertexBuffer) old.VertexBuffer->Release();
device->IASetInputLayout(old.InputLayout); if (old.InputLayout) old.InputLayout->Release();
}
static void ImGui_ImplDX11_CreateFontsTexture()
{
// Build texture atlas
ImGuiIO& io = ImGui::GetIO();
ImGui_ImplDX11_Data* bd = ImGui_ImplDX11_GetBackendData();
unsigned char* pixels;
int width, height;
io.Fonts->GetTexDataAsRGBA32(&pixels, &width, &height);
// Upload texture to graphics system
{
D3D11_TEXTURE2D_DESC desc;
ZeroMemory(&desc, sizeof(desc));
desc.Width = width;
desc.Height = height;
desc.MipLevels = 1;
desc.ArraySize = 1;
desc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
desc.SampleDesc.Count = 1;
desc.Usage = D3D11_USAGE_DEFAULT;
desc.BindFlags = D3D11_BIND_SHADER_RESOURCE;
desc.CPUAccessFlags = 0;
ID3D11Texture2D* pTexture = nullptr;
D3D11_SUBRESOURCE_DATA subResource;
subResource.pSysMem = pixels;
subResource.SysMemPitch = desc.Width * 4;
subResource.SysMemSlicePitch = 0;
bd->pd3dDevice->CreateTexture2D(&desc, &subResource, &pTexture);
IM_ASSERT(pTexture != nullptr);
// Create texture view
D3D11_SHADER_RESOURCE_VIEW_DESC srvDesc;
ZeroMemory(&srvDesc, sizeof(srvDesc));
srvDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
srvDesc.ViewDimension = D3D11_SRV_DIMENSION_TEXTURE2D;
srvDesc.Texture2D.MipLevels = desc.MipLevels;
srvDesc.Texture2D.MostDetailedMip = 0;
bd->pd3dDevice->CreateShaderResourceView(pTexture, &srvDesc, &bd->pFontTextureView);
pTexture->Release();
}
// Store our identifier
io.Fonts->SetTexID((ImTextureID)bd->pFontTextureView);
// Create texture sampler
// (Bilinear sampling is required by default. Set 'io.Fonts->Flags |= ImFontAtlasFlags_NoBakedLines' or 'style.AntiAliasedLinesUseTex = false' to allow point/nearest sampling)
{
D3D11_SAMPLER_DESC desc;
ZeroMemory(&desc, sizeof(desc));
desc.Filter = D3D11_FILTER_MIN_MAG_MIP_LINEAR;
desc.AddressU = D3D11_TEXTURE_ADDRESS_CLAMP;
desc.AddressV = D3D11_TEXTURE_ADDRESS_CLAMP;
desc.AddressW = D3D11_TEXTURE_ADDRESS_CLAMP;
desc.MipLODBias = 0.f;
desc.ComparisonFunc = D3D11_COMPARISON_ALWAYS;
desc.MinLOD = 0.f;
desc.MaxLOD = 0.f;
bd->pd3dDevice->CreateSamplerState(&desc, &bd->pFontSampler);
}
}
bool ImGui_ImplDX11_CreateDeviceObjects()
{
ImGui_ImplDX11_Data* bd = ImGui_ImplDX11_GetBackendData();
if (!bd->pd3dDevice)
return false;
if (bd->pFontSampler)
ImGui_ImplDX11_InvalidateDeviceObjects();
// By using D3DCompile() from <d3dcompiler.h> / d3dcompiler.lib, we introduce a dependency to a given version of d3dcompiler_XX.dll (see D3DCOMPILER_DLL_A)
// If you would like to use this DX11 sample code but remove this dependency you can:
// 1) compile once, save the compiled shader blobs into a file or source code and pass them to CreateVertexShader()/CreatePixelShader() [preferred solution]
// 2) use code to detect any version of the DLL and grab a pointer to D3DCompile from the DLL.
// See https://github.com/ocornut/imgui/pull/638 for sources and details.
// Create the vertex shader
{
static const char* vertexShader =
"cbuffer vertexBuffer : register(b0) \
{\
float4x4 ProjectionMatrix; \
};\
struct VS_INPUT\
{\
float2 pos : POSITION;\
float4 col : COLOR0;\
float2 uv : TEXCOORD0;\
};\
\
struct PS_INPUT\
{\
float4 pos : SV_POSITION;\
float4 col : COLOR0;\
float2 uv : TEXCOORD0;\
};\
\
PS_INPUT main(VS_INPUT input)\
{\
PS_INPUT output;\
output.pos = mul( ProjectionMatrix, float4(input.pos.xy, 0.f, 1.f));\
output.col = input.col;\
output.uv = input.uv;\
return output;\
}";
ID3DBlob* vertexShaderBlob;
if (FAILED(D3DCompile(vertexShader, strlen(vertexShader), nullptr, nullptr, nullptr, "main", "vs_4_0", 0, 0, &vertexShaderBlob, nullptr)))
return false; // NB: Pass ID3DBlob* pErrorBlob to D3DCompile() to get error showing in (const char*)pErrorBlob->GetBufferPointer(). Make sure to Release() the blob!
if (bd->pd3dDevice->CreateVertexShader(vertexShaderBlob->GetBufferPointer(), vertexShaderBlob->GetBufferSize(), nullptr, &bd->pVertexShader) != S_OK)
{
vertexShaderBlob->Release();
return false;
}
// Create the input layout
D3D11_INPUT_ELEMENT_DESC local_layout[] =
{
{ "POSITION", 0, DXGI_FORMAT_R32G32_FLOAT, 0, (UINT)offsetof(ImDrawVert, pos), D3D11_INPUT_PER_VERTEX_DATA, 0 },
{ "TEXCOORD", 0, DXGI_FORMAT_R32G32_FLOAT, 0, (UINT)offsetof(ImDrawVert, uv), D3D11_INPUT_PER_VERTEX_DATA, 0 },
{ "COLOR", 0, DXGI_FORMAT_R8G8B8A8_UNORM, 0, (UINT)offsetof(ImDrawVert, col), D3D11_INPUT_PER_VERTEX_DATA, 0 },
};
if (bd->pd3dDevice->CreateInputLayout(local_layout, 3, vertexShaderBlob->GetBufferPointer(), vertexShaderBlob->GetBufferSize(), &bd->pInputLayout) != S_OK)
{
vertexShaderBlob->Release();
return false;
}
vertexShaderBlob->Release();
// Create the constant buffer
{
D3D11_BUFFER_DESC desc;
desc.ByteWidth = sizeof(VERTEX_CONSTANT_BUFFER_DX11);
desc.Usage = D3D11_USAGE_DYNAMIC;
desc.BindFlags = D3D11_BIND_CONSTANT_BUFFER;
desc.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE;
desc.MiscFlags = 0;
bd->pd3dDevice->CreateBuffer(&desc, nullptr, &bd->pVertexConstantBuffer);
}
}
// Create the pixel shader
{
static const char* pixelShader =
"struct PS_INPUT\
{\
float4 pos : SV_POSITION;\
float4 col : COLOR0;\
float2 uv : TEXCOORD0;\
};\
sampler sampler0;\
Texture2D texture0;\
\
float4 main(PS_INPUT input) : SV_Target\
{\
float4 out_col = input.col * texture0.Sample(sampler0, input.uv); \
return out_col; \
}";
ID3DBlob* pixelShaderBlob;
if (FAILED(D3DCompile(pixelShader, strlen(pixelShader), nullptr, nullptr, nullptr, "main", "ps_4_0", 0, 0, &pixelShaderBlob, nullptr)))
return false; // NB: Pass ID3DBlob* pErrorBlob to D3DCompile() to get error showing in (const char*)pErrorBlob->GetBufferPointer(). Make sure to Release() the blob!
if (bd->pd3dDevice->CreatePixelShader(pixelShaderBlob->GetBufferPointer(), pixelShaderBlob->GetBufferSize(), nullptr, &bd->pPixelShader) != S_OK)
{
pixelShaderBlob->Release();
return false;
}
pixelShaderBlob->Release();
}
// Create the blending setup
{
D3D11_BLEND_DESC desc;
ZeroMemory(&desc, sizeof(desc));
desc.AlphaToCoverageEnable = false;
desc.RenderTarget[0].BlendEnable = true;
desc.RenderTarget[0].SrcBlend = D3D11_BLEND_SRC_ALPHA;
desc.RenderTarget[0].DestBlend = D3D11_BLEND_INV_SRC_ALPHA;
desc.RenderTarget[0].BlendOp = D3D11_BLEND_OP_ADD;
desc.RenderTarget[0].SrcBlendAlpha = D3D11_BLEND_ONE;
desc.RenderTarget[0].DestBlendAlpha = D3D11_BLEND_INV_SRC_ALPHA;
desc.RenderTarget[0].BlendOpAlpha = D3D11_BLEND_OP_ADD;
desc.RenderTarget[0].RenderTargetWriteMask = D3D11_COLOR_WRITE_ENABLE_ALL;
bd->pd3dDevice->CreateBlendState(&desc, &bd->pBlendState);
}
// Create the rasterizer state
{
D3D11_RASTERIZER_DESC desc;
ZeroMemory(&desc, sizeof(desc));
desc.FillMode = D3D11_FILL_SOLID;
desc.CullMode = D3D11_CULL_NONE;
desc.ScissorEnable = true;
desc.DepthClipEnable = true;
bd->pd3dDevice->CreateRasterizerState(&desc, &bd->pRasterizerState);
}
// Create depth-stencil State
{
D3D11_DEPTH_STENCIL_DESC desc;
ZeroMemory(&desc, sizeof(desc));
desc.DepthEnable = false;
desc.DepthWriteMask = D3D11_DEPTH_WRITE_MASK_ALL;
desc.DepthFunc = D3D11_COMPARISON_ALWAYS;
desc.StencilEnable = false;
desc.FrontFace.StencilFailOp = desc.FrontFace.StencilDepthFailOp = desc.FrontFace.StencilPassOp = D3D11_STENCIL_OP_KEEP;
desc.FrontFace.StencilFunc = D3D11_COMPARISON_ALWAYS;
desc.BackFace = desc.FrontFace;
bd->pd3dDevice->CreateDepthStencilState(&desc, &bd->pDepthStencilState);
}
ImGui_ImplDX11_CreateFontsTexture();
return true;
}
void ImGui_ImplDX11_InvalidateDeviceObjects()
{
ImGui_ImplDX11_Data* bd = ImGui_ImplDX11_GetBackendData();
if (!bd->pd3dDevice)
return;
if (bd->pFontSampler) { bd->pFontSampler->Release(); bd->pFontSampler = nullptr; }
if (bd->pFontTextureView) { bd->pFontTextureView->Release(); bd->pFontTextureView = nullptr; ImGui::GetIO().Fonts->SetTexID(0); } // We copied data->pFontTextureView to io.Fonts->TexID so let's clear that as well.
if (bd->pIB) { bd->pIB->Release(); bd->pIB = nullptr; }
if (bd->pVB) { bd->pVB->Release(); bd->pVB = nullptr; }
if (bd->pBlendState) { bd->pBlendState->Release(); bd->pBlendState = nullptr; }
if (bd->pDepthStencilState) { bd->pDepthStencilState->Release(); bd->pDepthStencilState = nullptr; }
if (bd->pRasterizerState) { bd->pRasterizerState->Release(); bd->pRasterizerState = nullptr; }
if (bd->pPixelShader) { bd->pPixelShader->Release(); bd->pPixelShader = nullptr; }
if (bd->pVertexConstantBuffer) { bd->pVertexConstantBuffer->Release(); bd->pVertexConstantBuffer = nullptr; }
if (bd->pInputLayout) { bd->pInputLayout->Release(); bd->pInputLayout = nullptr; }
if (bd->pVertexShader) { bd->pVertexShader->Release(); bd->pVertexShader = nullptr; }
}
bool ImGui_ImplDX11_Init(ID3D11Device* device, ID3D11DeviceContext* device_context)
{
ImGuiIO& io = ImGui::GetIO();
IMGUI_CHECKVERSION();
IM_ASSERT(io.BackendRendererUserData == nullptr && "Already initialized a renderer backend!");
// Setup backend capabilities flags
ImGui_ImplDX11_Data* bd = IM_NEW(ImGui_ImplDX11_Data)();
io.BackendRendererUserData = (void*)bd;
io.BackendRendererName = "imgui_impl_dx11";
io.BackendFlags |= ImGuiBackendFlags_RendererHasVtxOffset; // We can honor the ImDrawCmd::VtxOffset field, allowing for large meshes.
// Get factory from device
IDXGIDevice* pDXGIDevice = nullptr;
IDXGIAdapter* pDXGIAdapter = nullptr;
IDXGIFactory* pFactory = nullptr;
if (device->QueryInterface(IID_PPV_ARGS(&pDXGIDevice)) == S_OK)
if (pDXGIDevice->GetParent(IID_PPV_ARGS(&pDXGIAdapter)) == S_OK)
if (pDXGIAdapter->GetParent(IID_PPV_ARGS(&pFactory)) == S_OK)
{
bd->pd3dDevice = device;
bd->pd3dDeviceContext = device_context;
bd->pFactory = pFactory;
}
if (pDXGIDevice) pDXGIDevice->Release();
if (pDXGIAdapter) pDXGIAdapter->Release();
bd->pd3dDevice->AddRef();
bd->pd3dDeviceContext->AddRef();
return true;
}
void ImGui_ImplDX11_Shutdown()
{
ImGui_ImplDX11_Data* bd = ImGui_ImplDX11_GetBackendData();
IM_ASSERT(bd != nullptr && "No renderer backend to shutdown, or already shutdown?");
ImGuiIO& io = ImGui::GetIO();
ImGui_ImplDX11_InvalidateDeviceObjects();
if (bd->pFactory) { bd->pFactory->Release(); }
if (bd->pd3dDevice) { bd->pd3dDevice->Release(); }
if (bd->pd3dDeviceContext) { bd->pd3dDeviceContext->Release(); }
io.BackendRendererName = nullptr;
io.BackendRendererUserData = nullptr;
io.BackendFlags &= ~ImGuiBackendFlags_RendererHasVtxOffset;
IM_DELETE(bd);
}
void ImGui_ImplDX11_NewFrame()
{
ImGui_ImplDX11_Data* bd = ImGui_ImplDX11_GetBackendData();
IM_ASSERT(bd != nullptr && "Context or backend not initialized! Did you call ImGui_ImplDX11_Init()?");
if (!bd->pFontSampler)
ImGui_ImplDX11_CreateDeviceObjects();
}
//-----------------------------------------------------------------------------
#endif // #ifndef IMGUI_DISABLE
+45
View File
@@ -0,0 +1,45 @@
// dear imgui: Renderer Backend for DirectX11
// This needs to be used along with a Platform Backend (e.g. Win32)
// Implemented features:
// [X] Renderer: User texture binding. Use 'ID3D11ShaderResourceView*' as ImTextureID. Read the FAQ about ImTextureID!
// [X] Renderer: Large meshes support (64k+ vertices) with 16-bit indices.
// [X] Renderer: Expose selected render state for draw callbacks to use. Access in '(ImGui_ImplXXXX_RenderState*)GetPlatformIO().Renderer_RenderState'.
// You can use unmodified imgui_impl_* files in your project. See examples/ folder for examples of using this.
// Prefer including the entire imgui/ repository into your project (either as a copy or as a submodule), and only build the backends you need.
// Learn about Dear ImGui:
// - FAQ https://dearimgui.com/faq
// - Getting Started https://dearimgui.com/getting-started
// - Documentation https://dearimgui.com/docs (same as your local docs/ folder).
// - Introduction, links and more at the top of imgui.cpp
#pragma once
#include "imgui.h" // IMGUI_IMPL_API
#ifndef IMGUI_DISABLE
struct ID3D11Device;
struct ID3D11DeviceContext;
struct ID3D11SamplerState;
// Follow "Getting Started" link and check examples/ folder to learn about using backends!
IMGUI_IMPL_API bool ImGui_ImplDX11_Init(ID3D11Device* device, ID3D11DeviceContext* device_context);
IMGUI_IMPL_API void ImGui_ImplDX11_Shutdown();
IMGUI_IMPL_API void ImGui_ImplDX11_NewFrame();
IMGUI_IMPL_API void ImGui_ImplDX11_RenderDrawData(ImDrawData* draw_data);
// Use if you want to reset your rendering device without losing Dear ImGui state.
IMGUI_IMPL_API bool ImGui_ImplDX11_CreateDeviceObjects();
IMGUI_IMPL_API void ImGui_ImplDX11_InvalidateDeviceObjects();
// [BETA] Selected render state data shared with callbacks.
// This is temporarily stored in GetPlatformIO().Renderer_RenderState during the ImGui_ImplDX11_RenderDrawData() call.
// (Please open an issue if you feel you need access to more data)
struct ImGui_ImplDX11_RenderState
{
ID3D11Device* Device;
ID3D11DeviceContext* DeviceContext;
ID3D11SamplerState* SamplerDefault;
};
#endif // #ifndef IMGUI_DISABLE
+940
View File
@@ -0,0 +1,940 @@
// dear imgui: Platform Backend for Windows (standard windows API for 32-bits AND 64-bits applications)
// This needs to be used along with a Renderer (e.g. DirectX11, OpenGL3, Vulkan..)
// Implemented features:
// [X] Platform: Clipboard support (for Win32 this is actually part of core dear imgui)
// [X] Platform: Mouse support. Can discriminate Mouse/TouchScreen/Pen.
// [X] Platform: Keyboard support. Since 1.87 we are using the io.AddKeyEvent() function. Pass ImGuiKey values to all key functions e.g. ImGui::IsKeyPressed(ImGuiKey_Space). [Legacy VK_* values are obsolete since 1.87 and not supported since 1.91.5]
// [X] Platform: Gamepad support. Enabled with 'io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad'.
// [X] Platform: Mouse cursor shape and visibility. Disable with 'io.ConfigFlags |= ImGuiConfigFlags_NoMouseCursorChange'.
// You can use unmodified imgui_impl_* files in your project. See examples/ folder for examples of using this.
// Prefer including the entire imgui/ repository into your project (either as a copy or as a submodule), and only build the backends you need.
// Learn about Dear ImGui:
// - FAQ https://dearimgui.com/faq
// - Getting Started https://dearimgui.com/getting-started
// - Documentation https://dearimgui.com/docs (same as your local docs/ folder).
// - Introduction, links and more at the top of imgui.cpp
// Configuration flags to add in your imconfig file:
//#define IMGUI_IMPL_WIN32_DISABLE_GAMEPAD // Disable gamepad support. This was meaningful before <1.81 but we now load XInput dynamically so the option is now less relevant.
// CHANGELOG
// (minor and older changes stripped away, please see git history for details)
// 2024-07-08: Inputs: Fixed ImGuiMod_Super being mapped to VK_APPS instead of VK_LWIN||VK_RWIN. (#7768)
// 2023-10-05: Inputs: Added support for extra ImGuiKey values: F13 to F24 function keys, app back/forward keys.
// 2023-09-25: Inputs: Synthesize key-down event on key-up for VK_SNAPSHOT / ImGuiKey_PrintScreen as Windows doesn't emit it (same behavior as GLFW/SDL).
// 2023-09-07: Inputs: Added support for keyboard codepage conversion for when application is compiled in MBCS mode and using a non-Unicode window.
// 2023-04-19: Added ImGui_ImplWin32_InitForOpenGL() to facilitate combining raw Win32/Winapi with OpenGL. (#3218)
// 2023-04-04: Inputs: Added support for io.AddMouseSourceEvent() to discriminate ImGuiMouseSource_Mouse/ImGuiMouseSource_TouchScreen/ImGuiMouseSource_Pen. (#2702)
// 2023-02-15: Inputs: Use WM_NCMOUSEMOVE / WM_NCMOUSELEAVE to track mouse position over non-client area (e.g. OS decorations) when app is not focused. (#6045, #6162)
// 2023-02-02: Inputs: Flipping WM_MOUSEHWHEEL (horizontal mouse-wheel) value to match other backends and offer consistent horizontal scrolling direction. (#4019, #6096, #1463)
// 2022-10-11: Using 'nullptr' instead of 'NULL' as per our switch to C++11.
// 2022-09-28: Inputs: Convert WM_CHAR values with MultiByteToWideChar() when window class was registered as MBCS (not Unicode).
// 2022-09-26: Inputs: Renamed ImGuiKey_ModXXX introduced in 1.87 to ImGuiMod_XXX (old names still supported).
// 2022-01-26: Inputs: replaced short-lived io.AddKeyModsEvent() (added two weeks ago) with io.AddKeyEvent() using ImGuiKey_ModXXX flags. Sorry for the confusion.
// 2021-01-20: Inputs: calling new io.AddKeyAnalogEvent() for gamepad support, instead of writing directly to io.NavInputs[].
// 2022-01-17: Inputs: calling new io.AddMousePosEvent(), io.AddMouseButtonEvent(), io.AddMouseWheelEvent() API (1.87+).
// 2022-01-17: Inputs: always update key mods next and before a key event (not in NewFrame) to fix input queue with very low framerates.
// 2022-01-12: Inputs: Update mouse inputs using WM_MOUSEMOVE/WM_MOUSELEAVE + fallback to provide it when focused but not hovered/captured. More standard and will allow us to pass it to future input queue API.
// 2022-01-12: Inputs: Maintain our own copy of MouseButtonsDown mask instead of using ImGui::IsAnyMouseDown() which will be obsoleted.
// 2022-01-10: Inputs: calling new io.AddKeyEvent(), io.AddKeyModsEvent() + io.SetKeyEventNativeData() API (1.87+). Support for full ImGuiKey range.
// 2021-12-16: Inputs: Fill VK_LCONTROL/VK_RCONTROL/VK_LSHIFT/VK_RSHIFT/VK_LMENU/VK_RMENU for completeness.
// 2021-08-17: Calling io.AddFocusEvent() on WM_SETFOCUS/WM_KILLFOCUS messages.
// 2021-08-02: Inputs: Fixed keyboard modifiers being reported when host window doesn't have focus.
// 2021-07-29: Inputs: MousePos is correctly reported when the host platform window is hovered but not focused (using TrackMouseEvent() to receive WM_MOUSELEAVE events).
// 2021-06-29: Reorganized backend to pull data from a single structure to facilitate usage with multiple-contexts (all g_XXXX access changed to bd->XXXX).
// 2021-06-08: Fixed ImGui_ImplWin32_EnableDpiAwareness() and ImGui_ImplWin32_GetDpiScaleForMonitor() to handle Windows 8.1/10 features without a manifest (per-monitor DPI, and properly calls SetProcessDpiAwareness() on 8.1).
// 2021-03-23: Inputs: Clearing keyboard down array when losing focus (WM_KILLFOCUS).
// 2021-02-18: Added ImGui_ImplWin32_EnableAlphaCompositing(). Non Visual Studio users will need to link with dwmapi.lib (MinGW/gcc: use -ldwmapi).
// 2021-02-17: Fixed ImGui_ImplWin32_EnableDpiAwareness() attempting to get SetProcessDpiAwareness from shcore.dll on Windows 8 whereas it is only supported on Windows 8.1.
// 2021-01-25: Inputs: Dynamically loading XInput DLL.
// 2020-12-04: Misc: Fixed setting of io.DisplaySize to invalid/uninitialized data when after hwnd has been closed.
// 2020-03-03: Inputs: Calling AddInputCharacterUTF16() to support surrogate pairs leading to codepoint >= 0x10000 (for more complete CJK inputs)
// 2020-02-17: Added ImGui_ImplWin32_EnableDpiAwareness(), ImGui_ImplWin32_GetDpiScaleForHwnd(), ImGui_ImplWin32_GetDpiScaleForMonitor() helper functions.
// 2020-01-14: Inputs: Added support for #define IMGUI_IMPL_WIN32_DISABLE_GAMEPAD/IMGUI_IMPL_WIN32_DISABLE_LINKING_XINPUT.
// 2019-12-05: Inputs: Added support for ImGuiMouseCursor_NotAllowed mouse cursor.
// 2019-05-11: Inputs: Don't filter value from WM_CHAR before calling AddInputCharacter().
// 2019-01-17: Misc: Using GetForegroundWindow()+IsChild() instead of GetActiveWindow() to be compatible with windows created in a different thread or parent.
// 2019-01-17: Inputs: Added support for mouse buttons 4 and 5 via WM_XBUTTON* messages.
// 2019-01-15: Inputs: Added support for XInput gamepads (if ImGuiConfigFlags_NavEnableGamepad is set by user application).
// 2018-11-30: Misc: Setting up io.BackendPlatformName so it can be displayed in the About Window.
// 2018-06-29: Inputs: Added support for the ImGuiMouseCursor_Hand cursor.
// 2018-06-10: Inputs: Fixed handling of mouse wheel messages to support fine position messages (typically sent by track-pads).
// 2018-06-08: Misc: Extracted imgui_impl_win32.cpp/.h away from the old combined DX9/DX10/DX11/DX12 examples.
// 2018-03-20: Misc: Setup io.BackendFlags ImGuiBackendFlags_HasMouseCursors and ImGuiBackendFlags_HasSetMousePos flags + honor ImGuiConfigFlags_NoMouseCursorChange flag.
// 2018-02-20: Inputs: Added support for mouse cursors (ImGui::GetMouseCursor() value and WM_SETCURSOR message handling).
// 2018-02-06: Inputs: Added mapping for ImGuiKey_Space.
// 2018-02-06: Inputs: Honoring the io.WantSetMousePos by repositioning the mouse (when using navigation and ImGuiConfigFlags_NavMoveMouse is set).
// 2018-02-06: Misc: Removed call to ImGui::Shutdown() which is not available from 1.60 WIP, user needs to call CreateContext/DestroyContext themselves.
// 2018-01-20: Inputs: Added Horizontal Mouse Wheel support.
// 2018-01-08: Inputs: Added mapping for ImGuiKey_Insert.
// 2018-01-05: Inputs: Added WM_LBUTTONDBLCLK double-click handlers for window classes with the CS_DBLCLKS flag.
// 2017-10-23: Inputs: Added WM_SYSKEYDOWN / WM_SYSKEYUP handlers so e.g. the VK_MENU key can be read.
// 2017-10-23: Inputs: Using Win32 ::SetCapture/::GetCapture() to retrieve mouse positions outside the client area when dragging.
// 2016-11-12: Inputs: Only call Win32 ::SetCursor(nullptr) when io.MouseDrawCursor is set.
#include "imgui.h"
#ifndef IMGUI_DISABLE
#include "imgui_impl_win32.h"
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#include <windowsx.h> // GET_X_LPARAM(), GET_Y_LPARAM()
#include <tchar.h>
#include <dwmapi.h>
// Using XInput for gamepad (will load DLL dynamically)
#ifndef IMGUI_IMPL_WIN32_DISABLE_GAMEPAD
#include <xinput.h>
typedef DWORD(WINAPI* PFN_XInputGetCapabilities)(DWORD, DWORD, XINPUT_CAPABILITIES*);
typedef DWORD(WINAPI* PFN_XInputGetState)(DWORD, XINPUT_STATE*);
#endif
// Clang/GCC warnings with -Weverything
#if defined(__clang__)
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wcast-function-type" // warning: cast between incompatible function types (for loader)
#endif
#if defined(__GNUC__)
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wpragmas" // warning: unknown option after '#pragma GCC diagnostic' kind
#pragma GCC diagnostic ignored "-Wcast-function-type" // warning: cast between incompatible function types (for loader)
#endif
struct ImGui_ImplWin32_Data
{
HWND hWnd;
HWND MouseHwnd;
int MouseTrackedArea; // 0: not tracked, 1: client area, 2: non-client area
int MouseButtonsDown;
INT64 Time;
INT64 TicksPerSecond;
ImGuiMouseCursor LastMouseCursor;
UINT32 KeyboardCodePage;
#ifndef IMGUI_IMPL_WIN32_DISABLE_GAMEPAD
bool HasGamepad;
bool WantUpdateHasGamepad;
HMODULE XInputDLL;
PFN_XInputGetCapabilities XInputGetCapabilities;
PFN_XInputGetState XInputGetState;
#endif
ImGui_ImplWin32_Data() { memset((void*)this, 0, sizeof(*this)); }
};
// Backend data stored in io.BackendPlatformUserData to allow support for multiple Dear ImGui contexts
// It is STRONGLY preferred that you use docking branch with multi-viewports (== single Dear ImGui context + multiple windows) instead of multiple Dear ImGui contexts.
// FIXME: multi-context support is not well tested and probably dysfunctional in this backend.
// FIXME: some shared resources (mouse cursor shape, gamepad) are mishandled when using multi-context.
static ImGui_ImplWin32_Data* ImGui_ImplWin32_GetBackendData()
{
return ImGui::GetCurrentContext() ? (ImGui_ImplWin32_Data*)ImGui::GetIO().BackendPlatformUserData : nullptr;
}
static ImGui_ImplWin32_Data* ImGui_ImplWin32_GetBackendData(ImGuiIO& io)
{
return (ImGui_ImplWin32_Data*)io.BackendPlatformUserData;
}
// Functions
static void ImGui_ImplWin32_UpdateKeyboardCodePage(ImGuiIO& io)
{
// Retrieve keyboard code page, required for handling of non-Unicode Windows.
ImGui_ImplWin32_Data* bd = ImGui_ImplWin32_GetBackendData(io);
HKL keyboard_layout = ::GetKeyboardLayout(0);
LCID keyboard_lcid = MAKELCID(HIWORD(keyboard_layout), SORT_DEFAULT);
if (::GetLocaleInfoA(keyboard_lcid, (LOCALE_RETURN_NUMBER | LOCALE_IDEFAULTANSICODEPAGE), (LPSTR)&bd->KeyboardCodePage, sizeof(bd->KeyboardCodePage)) == 0)
bd->KeyboardCodePage = CP_ACP; // Fallback to default ANSI code page when fails.
}
static bool ImGui_ImplWin32_InitEx(void* hwnd, bool platform_has_own_dc)
{
ImGuiIO& io = ImGui::GetIO();
IMGUI_CHECKVERSION();
IM_ASSERT(io.BackendPlatformUserData == nullptr && "Already initialized a platform backend!");
INT64 perf_frequency, perf_counter;
if (!::QueryPerformanceFrequency((LARGE_INTEGER*)&perf_frequency))
return false;
if (!::QueryPerformanceCounter((LARGE_INTEGER*)&perf_counter))
return false;
// Setup backend capabilities flags
ImGui_ImplWin32_Data* bd = IM_NEW(ImGui_ImplWin32_Data)();
io.BackendPlatformUserData = (void*)bd;
io.BackendPlatformName = "imgui_impl_win32";
io.BackendFlags |= ImGuiBackendFlags_HasMouseCursors; // We can honor GetMouseCursor() values (optional)
io.BackendFlags |= ImGuiBackendFlags_HasSetMousePos; // We can honor io.WantSetMousePos requests (optional, rarely used)
bd->hWnd = (HWND)hwnd;
bd->TicksPerSecond = perf_frequency;
bd->Time = perf_counter;
bd->LastMouseCursor = ImGuiMouseCursor_COUNT;
ImGui_ImplWin32_UpdateKeyboardCodePage(io);
// Set platform dependent data in viewport
ImGuiViewport* main_viewport = ImGui::GetMainViewport();
main_viewport->PlatformHandle = main_viewport->PlatformHandleRaw = (void*)bd->hWnd;
IM_UNUSED(platform_has_own_dc); // Used in 'docking' branch
// Dynamically load XInput library
#ifndef IMGUI_IMPL_WIN32_DISABLE_GAMEPAD
bd->WantUpdateHasGamepad = true;
const char* xinput_dll_names[] =
{
"xinput1_4.dll", // Windows 8+
"xinput1_3.dll", // DirectX SDK
"xinput9_1_0.dll", // Windows Vista, Windows 7
"xinput1_2.dll", // DirectX SDK
"xinput1_1.dll" // DirectX SDK
};
for (int n = 0; n < IM_ARRAYSIZE(xinput_dll_names); n++)
if (HMODULE dll = ::LoadLibraryA(xinput_dll_names[n]))
{
bd->XInputDLL = dll;
bd->XInputGetCapabilities = (PFN_XInputGetCapabilities)::GetProcAddress(dll, "XInputGetCapabilities");
bd->XInputGetState = (PFN_XInputGetState)::GetProcAddress(dll, "XInputGetState");
break;
}
#endif // IMGUI_IMPL_WIN32_DISABLE_GAMEPAD
return true;
}
IMGUI_IMPL_API bool ImGui_ImplWin32_Init(void* hwnd)
{
return ImGui_ImplWin32_InitEx(hwnd, false);
}
IMGUI_IMPL_API bool ImGui_ImplWin32_InitForOpenGL(void* hwnd)
{
// OpenGL needs CS_OWNDC
return ImGui_ImplWin32_InitEx(hwnd, true);
}
void ImGui_ImplWin32_Shutdown()
{
ImGui_ImplWin32_Data* bd = ImGui_ImplWin32_GetBackendData();
IM_ASSERT(bd != nullptr && "No platform backend to shutdown, or already shutdown?");
ImGuiIO& io = ImGui::GetIO();
// Unload XInput library
#ifndef IMGUI_IMPL_WIN32_DISABLE_GAMEPAD
if (bd->XInputDLL)
::FreeLibrary(bd->XInputDLL);
#endif // IMGUI_IMPL_WIN32_DISABLE_GAMEPAD
io.BackendPlatformName = nullptr;
io.BackendPlatformUserData = nullptr;
io.BackendFlags &= ~(ImGuiBackendFlags_HasMouseCursors | ImGuiBackendFlags_HasSetMousePos | ImGuiBackendFlags_HasGamepad);
IM_DELETE(bd);
}
static bool ImGui_ImplWin32_UpdateMouseCursor(ImGuiIO& io, ImGuiMouseCursor imgui_cursor)
{
if (io.ConfigFlags & ImGuiConfigFlags_NoMouseCursorChange)
return false;
if (imgui_cursor == ImGuiMouseCursor_None || io.MouseDrawCursor)
{
// Hide OS mouse cursor if imgui is drawing it or if it wants no cursor
::SetCursor(nullptr);
}
else
{
// Show OS mouse cursor
LPTSTR win32_cursor = IDC_ARROW;
switch (imgui_cursor)
{
case ImGuiMouseCursor_Arrow: win32_cursor = IDC_ARROW; break;
case ImGuiMouseCursor_TextInput: win32_cursor = IDC_IBEAM; break;
case ImGuiMouseCursor_ResizeAll: win32_cursor = IDC_SIZEALL; break;
case ImGuiMouseCursor_ResizeEW: win32_cursor = IDC_SIZEWE; break;
case ImGuiMouseCursor_ResizeNS: win32_cursor = IDC_SIZENS; break;
case ImGuiMouseCursor_ResizeNESW: win32_cursor = IDC_SIZENESW; break;
case ImGuiMouseCursor_ResizeNWSE: win32_cursor = IDC_SIZENWSE; break;
case ImGuiMouseCursor_Hand: win32_cursor = IDC_HAND; break;
case ImGuiMouseCursor_NotAllowed: win32_cursor = IDC_NO; break;
}
::SetCursor(::LoadCursor(nullptr, win32_cursor));
}
return true;
}
static bool IsVkDown(int vk)
{
return (::GetKeyState(vk) & 0x8000) != 0;
}
static void ImGui_ImplWin32_AddKeyEvent(ImGuiIO& io, ImGuiKey key, bool down, int native_keycode, int native_scancode = -1)
{
io.AddKeyEvent(key, down);
io.SetKeyEventNativeData(key, native_keycode, native_scancode); // To support legacy indexing (<1.87 user code)
IM_UNUSED(native_scancode);
}
static void ImGui_ImplWin32_ProcessKeyEventsWorkarounds(ImGuiIO& io)
{
// Left & right Shift keys: when both are pressed together, Windows tend to not generate the WM_KEYUP event for the first released one.
if (ImGui::IsKeyDown(ImGuiKey_LeftShift) && !IsVkDown(VK_LSHIFT))
ImGui_ImplWin32_AddKeyEvent(io, ImGuiKey_LeftShift, false, VK_LSHIFT);
if (ImGui::IsKeyDown(ImGuiKey_RightShift) && !IsVkDown(VK_RSHIFT))
ImGui_ImplWin32_AddKeyEvent(io, ImGuiKey_RightShift, false, VK_RSHIFT);
// Sometimes WM_KEYUP for Win key is not passed down to the app (e.g. for Win+V on some setups, according to GLFW).
if (ImGui::IsKeyDown(ImGuiKey_LeftSuper) && !IsVkDown(VK_LWIN))
ImGui_ImplWin32_AddKeyEvent(io, ImGuiKey_LeftSuper, false, VK_LWIN);
if (ImGui::IsKeyDown(ImGuiKey_RightSuper) && !IsVkDown(VK_RWIN))
ImGui_ImplWin32_AddKeyEvent(io, ImGuiKey_RightSuper, false, VK_RWIN);
}
static void ImGui_ImplWin32_UpdateKeyModifiers(ImGuiIO& io)
{
io.AddKeyEvent(ImGuiMod_Ctrl, IsVkDown(VK_CONTROL));
io.AddKeyEvent(ImGuiMod_Shift, IsVkDown(VK_SHIFT));
io.AddKeyEvent(ImGuiMod_Alt, IsVkDown(VK_MENU));
io.AddKeyEvent(ImGuiMod_Super, IsVkDown(VK_LWIN) || IsVkDown(VK_RWIN));
}
static void ImGui_ImplWin32_UpdateMouseData(ImGuiIO& io)
{
ImGui_ImplWin32_Data* bd = ImGui_ImplWin32_GetBackendData(io);
IM_ASSERT(bd->hWnd != 0);
HWND focused_window = ::GetForegroundWindow();
const bool is_app_focused = (focused_window == bd->hWnd);
if (is_app_focused)
{
// (Optional) Set OS mouse position from Dear ImGui if requested (rarely used, only when io.ConfigNavMoveSetMousePos is enabled by user)
if (io.WantSetMousePos)
{
POINT pos = { (int)io.MousePos.x, (int)io.MousePos.y };
if (::ClientToScreen(bd->hWnd, &pos))
::SetCursorPos(pos.x, pos.y);
}
// (Optional) Fallback to provide mouse position when focused (WM_MOUSEMOVE already provides this when hovered or captured)
// This also fills a short gap when clicking non-client area: WM_NCMOUSELEAVE -> modal OS move -> gap -> WM_NCMOUSEMOVE
if (!io.WantSetMousePos && bd->MouseTrackedArea == 0)
{
POINT pos;
if (::GetCursorPos(&pos) && ::ScreenToClient(bd->hWnd, &pos))
io.AddMousePosEvent((float)pos.x, (float)pos.y);
}
}
}
// Gamepad navigation mapping
static void ImGui_ImplWin32_UpdateGamepads(ImGuiIO& io)
{
#ifndef IMGUI_IMPL_WIN32_DISABLE_GAMEPAD
ImGui_ImplWin32_Data* bd = ImGui_ImplWin32_GetBackendData(io);
//if ((io.ConfigFlags & ImGuiConfigFlags_NavEnableGamepad) == 0) // FIXME: Technically feeding gamepad shouldn't depend on this now that they are regular inputs.
// return;
// Calling XInputGetState() every frame on disconnected gamepads is unfortunately too slow.
// Instead we refresh gamepad availability by calling XInputGetCapabilities() _only_ after receiving WM_DEVICECHANGE.
if (bd->WantUpdateHasGamepad)
{
XINPUT_CAPABILITIES caps = {};
bd->HasGamepad = bd->XInputGetCapabilities ? (bd->XInputGetCapabilities(0, XINPUT_FLAG_GAMEPAD, &caps) == ERROR_SUCCESS) : false;
bd->WantUpdateHasGamepad = false;
}
io.BackendFlags &= ~ImGuiBackendFlags_HasGamepad;
XINPUT_STATE xinput_state;
XINPUT_GAMEPAD& gamepad = xinput_state.Gamepad;
if (!bd->HasGamepad || bd->XInputGetState == nullptr || bd->XInputGetState(0, &xinput_state) != ERROR_SUCCESS)
return;
io.BackendFlags |= ImGuiBackendFlags_HasGamepad;
#define IM_SATURATE(V) (V < 0.0f ? 0.0f : V > 1.0f ? 1.0f : V)
#define MAP_BUTTON(KEY_NO, BUTTON_ENUM) { io.AddKeyEvent(KEY_NO, (gamepad.wButtons & BUTTON_ENUM) != 0); }
#define MAP_ANALOG(KEY_NO, VALUE, V0, V1) { float vn = (float)(VALUE - V0) / (float)(V1 - V0); io.AddKeyAnalogEvent(KEY_NO, vn > 0.10f, IM_SATURATE(vn)); }
MAP_BUTTON(ImGuiKey_GamepadStart, XINPUT_GAMEPAD_START);
MAP_BUTTON(ImGuiKey_GamepadBack, XINPUT_GAMEPAD_BACK);
MAP_BUTTON(ImGuiKey_GamepadFaceLeft, XINPUT_GAMEPAD_X);
MAP_BUTTON(ImGuiKey_GamepadFaceRight, XINPUT_GAMEPAD_B);
MAP_BUTTON(ImGuiKey_GamepadFaceUp, XINPUT_GAMEPAD_Y);
MAP_BUTTON(ImGuiKey_GamepadFaceDown, XINPUT_GAMEPAD_A);
MAP_BUTTON(ImGuiKey_GamepadDpadLeft, XINPUT_GAMEPAD_DPAD_LEFT);
MAP_BUTTON(ImGuiKey_GamepadDpadRight, XINPUT_GAMEPAD_DPAD_RIGHT);
MAP_BUTTON(ImGuiKey_GamepadDpadUp, XINPUT_GAMEPAD_DPAD_UP);
MAP_BUTTON(ImGuiKey_GamepadDpadDown, XINPUT_GAMEPAD_DPAD_DOWN);
MAP_BUTTON(ImGuiKey_GamepadL1, XINPUT_GAMEPAD_LEFT_SHOULDER);
MAP_BUTTON(ImGuiKey_GamepadR1, XINPUT_GAMEPAD_RIGHT_SHOULDER);
MAP_ANALOG(ImGuiKey_GamepadL2, gamepad.bLeftTrigger, XINPUT_GAMEPAD_TRIGGER_THRESHOLD, 255);
MAP_ANALOG(ImGuiKey_GamepadR2, gamepad.bRightTrigger, XINPUT_GAMEPAD_TRIGGER_THRESHOLD, 255);
MAP_BUTTON(ImGuiKey_GamepadL3, XINPUT_GAMEPAD_LEFT_THUMB);
MAP_BUTTON(ImGuiKey_GamepadR3, XINPUT_GAMEPAD_RIGHT_THUMB);
MAP_ANALOG(ImGuiKey_GamepadLStickLeft, gamepad.sThumbLX, -XINPUT_GAMEPAD_LEFT_THUMB_DEADZONE, -32768);
MAP_ANALOG(ImGuiKey_GamepadLStickRight, gamepad.sThumbLX, +XINPUT_GAMEPAD_LEFT_THUMB_DEADZONE, +32767);
MAP_ANALOG(ImGuiKey_GamepadLStickUp, gamepad.sThumbLY, +XINPUT_GAMEPAD_LEFT_THUMB_DEADZONE, +32767);
MAP_ANALOG(ImGuiKey_GamepadLStickDown, gamepad.sThumbLY, -XINPUT_GAMEPAD_LEFT_THUMB_DEADZONE, -32768);
MAP_ANALOG(ImGuiKey_GamepadRStickLeft, gamepad.sThumbRX, -XINPUT_GAMEPAD_LEFT_THUMB_DEADZONE, -32768);
MAP_ANALOG(ImGuiKey_GamepadRStickRight, gamepad.sThumbRX, +XINPUT_GAMEPAD_LEFT_THUMB_DEADZONE, +32767);
MAP_ANALOG(ImGuiKey_GamepadRStickUp, gamepad.sThumbRY, +XINPUT_GAMEPAD_LEFT_THUMB_DEADZONE, +32767);
MAP_ANALOG(ImGuiKey_GamepadRStickDown, gamepad.sThumbRY, -XINPUT_GAMEPAD_LEFT_THUMB_DEADZONE, -32768);
#undef MAP_BUTTON
#undef MAP_ANALOG
#else // #ifndef IMGUI_IMPL_WIN32_DISABLE_GAMEPAD
IM_UNUSED(io);
#endif
}
void ImGui_ImplWin32_NewFrame()
{
ImGui_ImplWin32_Data* bd = ImGui_ImplWin32_GetBackendData();
IM_ASSERT(bd != nullptr && "Context or backend not initialized? Did you call ImGui_ImplWin32_Init()?");
ImGuiIO& io = ImGui::GetIO();
// Setup display size (every frame to accommodate for window resizing)
RECT rect = { 0, 0, 0, 0 };
::GetClientRect(bd->hWnd, &rect);
io.DisplaySize = ImVec2((float)(rect.right - rect.left), (float)(rect.bottom - rect.top));
// Setup time step
INT64 current_time = 0;
::QueryPerformanceCounter((LARGE_INTEGER*)&current_time);
io.DeltaTime = (float)(current_time - bd->Time) / bd->TicksPerSecond;
bd->Time = current_time;
// Update OS mouse position
ImGui_ImplWin32_UpdateMouseData(io);
// Process workarounds for known Windows key handling issues
ImGui_ImplWin32_ProcessKeyEventsWorkarounds(io);
// Update OS mouse cursor with the cursor requested by imgui
ImGuiMouseCursor mouse_cursor = io.MouseDrawCursor ? ImGuiMouseCursor_None : ImGui::GetMouseCursor();
if (bd->LastMouseCursor != mouse_cursor)
{
bd->LastMouseCursor = mouse_cursor;
ImGui_ImplWin32_UpdateMouseCursor(io, mouse_cursor);
}
// Update game controllers (if enabled and available)
ImGui_ImplWin32_UpdateGamepads(io);
}
// Map VK_xxx to ImGuiKey_xxx.
// Not static to allow third-party code to use that if they want to (but undocumented)
ImGuiKey ImGui_ImplWin32_KeyEventToImGuiKey(WPARAM wParam, LPARAM lParam);
ImGuiKey ImGui_ImplWin32_KeyEventToImGuiKey(WPARAM wParam, LPARAM lParam)
{
// There is no distinct VK_xxx for keypad enter, instead it is VK_RETURN + KF_EXTENDED.
if ((wParam == VK_RETURN) && (HIWORD(lParam) & KF_EXTENDED))
return ImGuiKey_KeypadEnter;
switch (wParam)
{
case VK_TAB: return ImGuiKey_Tab;
case VK_LEFT: return ImGuiKey_LeftArrow;
case VK_RIGHT: return ImGuiKey_RightArrow;
case VK_UP: return ImGuiKey_UpArrow;
case VK_DOWN: return ImGuiKey_DownArrow;
case VK_PRIOR: return ImGuiKey_PageUp;
case VK_NEXT: return ImGuiKey_PageDown;
case VK_HOME: return ImGuiKey_Home;
case VK_END: return ImGuiKey_End;
case VK_INSERT: return ImGuiKey_Insert;
case VK_DELETE: return ImGuiKey_Delete;
case VK_BACK: return ImGuiKey_Backspace;
case VK_SPACE: return ImGuiKey_Space;
case VK_RETURN: return ImGuiKey_Enter;
case VK_ESCAPE: return ImGuiKey_Escape;
case VK_OEM_7: return ImGuiKey_Apostrophe;
case VK_OEM_COMMA: return ImGuiKey_Comma;
case VK_OEM_MINUS: return ImGuiKey_Minus;
case VK_OEM_PERIOD: return ImGuiKey_Period;
case VK_OEM_2: return ImGuiKey_Slash;
case VK_OEM_1: return ImGuiKey_Semicolon;
case VK_OEM_PLUS: return ImGuiKey_Equal;
case VK_OEM_4: return ImGuiKey_LeftBracket;
case VK_OEM_5: return ImGuiKey_Backslash;
case VK_OEM_6: return ImGuiKey_RightBracket;
case VK_OEM_3: return ImGuiKey_GraveAccent;
case VK_CAPITAL: return ImGuiKey_CapsLock;
case VK_SCROLL: return ImGuiKey_ScrollLock;
case VK_NUMLOCK: return ImGuiKey_NumLock;
case VK_SNAPSHOT: return ImGuiKey_PrintScreen;
case VK_PAUSE: return ImGuiKey_Pause;
case VK_NUMPAD0: return ImGuiKey_Keypad0;
case VK_NUMPAD1: return ImGuiKey_Keypad1;
case VK_NUMPAD2: return ImGuiKey_Keypad2;
case VK_NUMPAD3: return ImGuiKey_Keypad3;
case VK_NUMPAD4: return ImGuiKey_Keypad4;
case VK_NUMPAD5: return ImGuiKey_Keypad5;
case VK_NUMPAD6: return ImGuiKey_Keypad6;
case VK_NUMPAD7: return ImGuiKey_Keypad7;
case VK_NUMPAD8: return ImGuiKey_Keypad8;
case VK_NUMPAD9: return ImGuiKey_Keypad9;
case VK_DECIMAL: return ImGuiKey_KeypadDecimal;
case VK_DIVIDE: return ImGuiKey_KeypadDivide;
case VK_MULTIPLY: return ImGuiKey_KeypadMultiply;
case VK_SUBTRACT: return ImGuiKey_KeypadSubtract;
case VK_ADD: return ImGuiKey_KeypadAdd;
case VK_LSHIFT: return ImGuiKey_LeftShift;
case VK_LCONTROL: return ImGuiKey_LeftCtrl;
case VK_LMENU: return ImGuiKey_LeftAlt;
case VK_LWIN: return ImGuiKey_LeftSuper;
case VK_RSHIFT: return ImGuiKey_RightShift;
case VK_RCONTROL: return ImGuiKey_RightCtrl;
case VK_RMENU: return ImGuiKey_RightAlt;
case VK_RWIN: return ImGuiKey_RightSuper;
case VK_APPS: return ImGuiKey_Menu;
case '0': return ImGuiKey_0;
case '1': return ImGuiKey_1;
case '2': return ImGuiKey_2;
case '3': return ImGuiKey_3;
case '4': return ImGuiKey_4;
case '5': return ImGuiKey_5;
case '6': return ImGuiKey_6;
case '7': return ImGuiKey_7;
case '8': return ImGuiKey_8;
case '9': return ImGuiKey_9;
case 'A': return ImGuiKey_A;
case 'B': return ImGuiKey_B;
case 'C': return ImGuiKey_C;
case 'D': return ImGuiKey_D;
case 'E': return ImGuiKey_E;
case 'F': return ImGuiKey_F;
case 'G': return ImGuiKey_G;
case 'H': return ImGuiKey_H;
case 'I': return ImGuiKey_I;
case 'J': return ImGuiKey_J;
case 'K': return ImGuiKey_K;
case 'L': return ImGuiKey_L;
case 'M': return ImGuiKey_M;
case 'N': return ImGuiKey_N;
case 'O': return ImGuiKey_O;
case 'P': return ImGuiKey_P;
case 'Q': return ImGuiKey_Q;
case 'R': return ImGuiKey_R;
case 'S': return ImGuiKey_S;
case 'T': return ImGuiKey_T;
case 'U': return ImGuiKey_U;
case 'V': return ImGuiKey_V;
case 'W': return ImGuiKey_W;
case 'X': return ImGuiKey_X;
case 'Y': return ImGuiKey_Y;
case 'Z': return ImGuiKey_Z;
case VK_F1: return ImGuiKey_F1;
case VK_F2: return ImGuiKey_F2;
case VK_F3: return ImGuiKey_F3;
case VK_F4: return ImGuiKey_F4;
case VK_F5: return ImGuiKey_F5;
case VK_F6: return ImGuiKey_F6;
case VK_F7: return ImGuiKey_F7;
case VK_F8: return ImGuiKey_F8;
case VK_F9: return ImGuiKey_F9;
case VK_F10: return ImGuiKey_F10;
case VK_F11: return ImGuiKey_F11;
case VK_F12: return ImGuiKey_F12;
case VK_F13: return ImGuiKey_F13;
case VK_F14: return ImGuiKey_F14;
case VK_F15: return ImGuiKey_F15;
case VK_F16: return ImGuiKey_F16;
case VK_F17: return ImGuiKey_F17;
case VK_F18: return ImGuiKey_F18;
case VK_F19: return ImGuiKey_F19;
case VK_F20: return ImGuiKey_F20;
case VK_F21: return ImGuiKey_F21;
case VK_F22: return ImGuiKey_F22;
case VK_F23: return ImGuiKey_F23;
case VK_F24: return ImGuiKey_F24;
case VK_BROWSER_BACK: return ImGuiKey_AppBack;
case VK_BROWSER_FORWARD: return ImGuiKey_AppForward;
default: return ImGuiKey_None;
}
}
// Allow compilation with old Windows SDK. MinGW doesn't have default _WIN32_WINNT/WINVER versions.
#ifndef WM_MOUSEHWHEEL
#define WM_MOUSEHWHEEL 0x020E
#endif
#ifndef DBT_DEVNODES_CHANGED
#define DBT_DEVNODES_CHANGED 0x0007
#endif
// Helper to obtain the source of mouse messages.
// See https://learn.microsoft.com/en-us/windows/win32/tablet/system-events-and-mouse-messages
// Prefer to call this at the top of the message handler to avoid the possibility of other Win32 calls interfering with this.
static ImGuiMouseSource ImGui_ImplWin32_GetMouseSourceFromMessageExtraInfo()
{
LPARAM extra_info = ::GetMessageExtraInfo();
if ((extra_info & 0xFFFFFF80) == 0xFF515700)
return ImGuiMouseSource_Pen;
if ((extra_info & 0xFFFFFF80) == 0xFF515780)
return ImGuiMouseSource_TouchScreen;
return ImGuiMouseSource_Mouse;
}
// Win32 message handler (process Win32 mouse/keyboard inputs, etc.)
// Call from your application's message handler. Keep calling your message handler unless this function returns TRUE.
// When implementing your own backend, you can read the io.WantCaptureMouse, io.WantCaptureKeyboard flags to tell if Dear ImGui wants to use your inputs.
// - When io.WantCaptureMouse is true, do not dispatch mouse input data to your main application, or clear/overwrite your copy of the mouse data.
// - When io.WantCaptureKeyboard is true, do not dispatch keyboard input data to your main application, or clear/overwrite your copy of the keyboard data.
// Generally you may always pass all inputs to Dear ImGui, and hide them from your application based on those two flags.
// PS: We treat DBLCLK messages as regular mouse down messages, so this code will work on windows classes that have the CS_DBLCLKS flag set. Our own example app code doesn't set this flag.
// Copy either line into your .cpp file to forward declare the function:
extern IMGUI_IMPL_API LRESULT ImGui_ImplWin32_WndProcHandler(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam); // Use ImGui::GetCurrentContext()
extern IMGUI_IMPL_API LRESULT ImGui_ImplWin32_WndProcHandlerEx(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam, ImGuiIO& io); // Doesn't use ImGui::GetCurrentContext()
IMGUI_IMPL_API LRESULT ImGui_ImplWin32_WndProcHandler(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
// Most backends don't have silent checks like this one, but we need it because WndProc are called early in CreateWindow().
// We silently allow both context or just only backend data to be nullptr.
if (ImGui::GetCurrentContext() == NULL)
return 0;
return ImGui_ImplWin32_WndProcHandlerEx(hwnd, msg, wParam, lParam, ImGui::GetIO());
}
// This version is in theory thread-safe in the sense that no path should access ImGui::GetCurrentContext().
IMGUI_IMPL_API LRESULT ImGui_ImplWin32_WndProcHandlerEx(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam, ImGuiIO& io)
{
ImGui_ImplWin32_Data* bd = ImGui_ImplWin32_GetBackendData(io);
if (bd == NULL)
return 0;
switch (msg)
{
case WM_MOUSEMOVE:
case WM_NCMOUSEMOVE:
{
// We need to call TrackMouseEvent in order to receive WM_MOUSELEAVE events
ImGuiMouseSource mouse_source = ImGui_ImplWin32_GetMouseSourceFromMessageExtraInfo();
const int area = (msg == WM_MOUSEMOVE) ? 1 : 2;
bd->MouseHwnd = hwnd;
if (bd->MouseTrackedArea != area)
{
TRACKMOUSEEVENT tme_cancel = { sizeof(tme_cancel), TME_CANCEL, hwnd, 0 };
TRACKMOUSEEVENT tme_track = { sizeof(tme_track), (DWORD)((area == 2) ? (TME_LEAVE | TME_NONCLIENT) : TME_LEAVE), hwnd, 0 };
if (bd->MouseTrackedArea != 0)
::TrackMouseEvent(&tme_cancel);
::TrackMouseEvent(&tme_track);
bd->MouseTrackedArea = area;
}
POINT mouse_pos = { (LONG)GET_X_LPARAM(lParam), (LONG)GET_Y_LPARAM(lParam) };
if (msg == WM_NCMOUSEMOVE && ::ScreenToClient(hwnd, &mouse_pos) == FALSE) // WM_NCMOUSEMOVE are provided in absolute coordinates.
return 0;
io.AddMouseSourceEvent(mouse_source);
io.AddMousePosEvent((float)mouse_pos.x, (float)mouse_pos.y);
return 0;
}
case WM_MOUSELEAVE:
case WM_NCMOUSELEAVE:
{
const int area = (msg == WM_MOUSELEAVE) ? 1 : 2;
if (bd->MouseTrackedArea == area)
{
if (bd->MouseHwnd == hwnd)
bd->MouseHwnd = nullptr;
bd->MouseTrackedArea = 0;
io.AddMousePosEvent(-FLT_MAX, -FLT_MAX);
}
return 0;
}
case WM_DESTROY:
if (bd->MouseHwnd == hwnd && bd->MouseTrackedArea != 0)
{
TRACKMOUSEEVENT tme_cancel = { sizeof(tme_cancel), TME_CANCEL, hwnd, 0 };
::TrackMouseEvent(&tme_cancel);
bd->MouseHwnd = nullptr;
bd->MouseTrackedArea = 0;
io.AddMousePosEvent(-FLT_MAX, -FLT_MAX);
}
return 0;
case WM_LBUTTONDOWN: case WM_LBUTTONDBLCLK:
case WM_RBUTTONDOWN: case WM_RBUTTONDBLCLK:
case WM_MBUTTONDOWN: case WM_MBUTTONDBLCLK:
case WM_XBUTTONDOWN: case WM_XBUTTONDBLCLK:
{
ImGuiMouseSource mouse_source = ImGui_ImplWin32_GetMouseSourceFromMessageExtraInfo();
int button = 0;
if (msg == WM_LBUTTONDOWN || msg == WM_LBUTTONDBLCLK) { button = 0; }
if (msg == WM_RBUTTONDOWN || msg == WM_RBUTTONDBLCLK) { button = 1; }
if (msg == WM_MBUTTONDOWN || msg == WM_MBUTTONDBLCLK) { button = 2; }
if (msg == WM_XBUTTONDOWN || msg == WM_XBUTTONDBLCLK) { button = (GET_XBUTTON_WPARAM(wParam) == XBUTTON1) ? 3 : 4; }
if (bd->MouseButtonsDown == 0 && ::GetCapture() == nullptr)
::SetCapture(hwnd); // Allow us to read mouse coordinates when dragging mouse outside of our window bounds.
bd->MouseButtonsDown |= 1 << button;
io.AddMouseSourceEvent(mouse_source);
io.AddMouseButtonEvent(button, true);
return 0;
}
case WM_LBUTTONUP:
case WM_RBUTTONUP:
case WM_MBUTTONUP:
case WM_XBUTTONUP:
{
ImGuiMouseSource mouse_source = ImGui_ImplWin32_GetMouseSourceFromMessageExtraInfo();
int button = 0;
if (msg == WM_LBUTTONUP) { button = 0; }
if (msg == WM_RBUTTONUP) { button = 1; }
if (msg == WM_MBUTTONUP) { button = 2; }
if (msg == WM_XBUTTONUP) { button = (GET_XBUTTON_WPARAM(wParam) == XBUTTON1) ? 3 : 4; }
bd->MouseButtonsDown &= ~(1 << button);
if (bd->MouseButtonsDown == 0 && ::GetCapture() == hwnd)
::ReleaseCapture();
io.AddMouseSourceEvent(mouse_source);
io.AddMouseButtonEvent(button, false);
return 0;
}
case WM_MOUSEWHEEL:
io.AddMouseWheelEvent(0.0f, (float)GET_WHEEL_DELTA_WPARAM(wParam) / (float)WHEEL_DELTA);
return 0;
case WM_MOUSEHWHEEL:
io.AddMouseWheelEvent(-(float)GET_WHEEL_DELTA_WPARAM(wParam) / (float)WHEEL_DELTA, 0.0f);
return 0;
case WM_KEYDOWN:
case WM_KEYUP:
case WM_SYSKEYDOWN:
case WM_SYSKEYUP:
{
const bool is_key_down = (msg == WM_KEYDOWN || msg == WM_SYSKEYDOWN);
if (wParam < 256)
{
// Submit modifiers
ImGui_ImplWin32_UpdateKeyModifiers(io);
// Obtain virtual key code and convert to ImGuiKey
const ImGuiKey key = ImGui_ImplWin32_KeyEventToImGuiKey(wParam, lParam);
const int vk = (int)wParam;
const int scancode = (int)LOBYTE(HIWORD(lParam));
// Special behavior for VK_SNAPSHOT / ImGuiKey_PrintScreen as Windows doesn't emit the key down event.
if (key == ImGuiKey_PrintScreen && !is_key_down)
ImGui_ImplWin32_AddKeyEvent(io, key, true, vk, scancode);
// Submit key event
if (key != ImGuiKey_None)
ImGui_ImplWin32_AddKeyEvent(io, key, is_key_down, vk, scancode);
// Submit individual left/right modifier events
if (vk == VK_SHIFT)
{
// Important: Shift keys tend to get stuck when pressed together, missing key-up events are corrected in ImGui_ImplWin32_ProcessKeyEventsWorkarounds()
if (IsVkDown(VK_LSHIFT) == is_key_down) { ImGui_ImplWin32_AddKeyEvent(io, ImGuiKey_LeftShift, is_key_down, VK_LSHIFT, scancode); }
if (IsVkDown(VK_RSHIFT) == is_key_down) { ImGui_ImplWin32_AddKeyEvent(io, ImGuiKey_RightShift, is_key_down, VK_RSHIFT, scancode); }
}
else if (vk == VK_CONTROL)
{
if (IsVkDown(VK_LCONTROL) == is_key_down) { ImGui_ImplWin32_AddKeyEvent(io, ImGuiKey_LeftCtrl, is_key_down, VK_LCONTROL, scancode); }
if (IsVkDown(VK_RCONTROL) == is_key_down) { ImGui_ImplWin32_AddKeyEvent(io, ImGuiKey_RightCtrl, is_key_down, VK_RCONTROL, scancode); }
}
else if (vk == VK_MENU)
{
if (IsVkDown(VK_LMENU) == is_key_down) { ImGui_ImplWin32_AddKeyEvent(io, ImGuiKey_LeftAlt, is_key_down, VK_LMENU, scancode); }
if (IsVkDown(VK_RMENU) == is_key_down) { ImGui_ImplWin32_AddKeyEvent(io, ImGuiKey_RightAlt, is_key_down, VK_RMENU, scancode); }
}
}
return 0;
}
case WM_SETFOCUS:
case WM_KILLFOCUS:
io.AddFocusEvent(msg == WM_SETFOCUS);
return 0;
case WM_INPUTLANGCHANGE:
ImGui_ImplWin32_UpdateKeyboardCodePage(io);
return 0;
case WM_CHAR:
if (::IsWindowUnicode(hwnd))
{
// You can also use ToAscii()+GetKeyboardState() to retrieve characters.
if (wParam > 0 && wParam < 0x10000)
io.AddInputCharacterUTF16((unsigned short)wParam);
}
else
{
wchar_t wch = 0;
::MultiByteToWideChar(bd->KeyboardCodePage, MB_PRECOMPOSED, (char*)&wParam, 1, &wch, 1);
io.AddInputCharacter(wch);
}
return 0;
case WM_SETCURSOR:
// This is required to restore cursor when transitioning from e.g resize borders to client area.
if (LOWORD(lParam) == HTCLIENT && ImGui_ImplWin32_UpdateMouseCursor(io, bd->LastMouseCursor))
return 1;
return 0;
case WM_DEVICECHANGE:
#ifndef IMGUI_IMPL_WIN32_DISABLE_GAMEPAD
if ((UINT)wParam == DBT_DEVNODES_CHANGED)
bd->WantUpdateHasGamepad = true;
#endif
return 0;
}
return 0;
}
//--------------------------------------------------------------------------------------------------------
// DPI-related helpers (optional)
//--------------------------------------------------------------------------------------------------------
// - Use to enable DPI awareness without having to create an application manifest.
// - Your own app may already do this via a manifest or explicit calls. This is mostly useful for our examples/ apps.
// - In theory we could call simple functions from Windows SDK such as SetProcessDPIAware(), SetProcessDpiAwareness(), etc.
// but most of the functions provided by Microsoft require Windows 8.1/10+ SDK at compile time and Windows 8/10+ at runtime,
// neither we want to require the user to have. So we dynamically select and load those functions to avoid dependencies.
//---------------------------------------------------------------------------------------------------------
// This is the scheme successfully used by GLFW (from which we borrowed some of the code) and other apps aiming to be highly portable.
// ImGui_ImplWin32_EnableDpiAwareness() is just a helper called by main.cpp, we don't call it automatically.
// If you are trying to implement your own backend for your own engine, you may ignore that noise.
//---------------------------------------------------------------------------------------------------------
// Perform our own check with RtlVerifyVersionInfo() instead of using functions from <VersionHelpers.h> as they
// require a manifest to be functional for checks above 8.1. See https://github.com/ocornut/imgui/issues/4200
static BOOL _IsWindowsVersionOrGreater(WORD major, WORD minor, WORD)
{
typedef LONG(WINAPI* PFN_RtlVerifyVersionInfo)(OSVERSIONINFOEXW*, ULONG, ULONGLONG);
static PFN_RtlVerifyVersionInfo RtlVerifyVersionInfoFn = nullptr;
if (RtlVerifyVersionInfoFn == nullptr)
if (HMODULE ntdllModule = ::GetModuleHandleA("ntdll.dll"))
RtlVerifyVersionInfoFn = (PFN_RtlVerifyVersionInfo)GetProcAddress(ntdllModule, "RtlVerifyVersionInfo");
if (RtlVerifyVersionInfoFn == nullptr)
return FALSE;
RTL_OSVERSIONINFOEXW versionInfo = { };
ULONGLONG conditionMask = 0;
versionInfo.dwOSVersionInfoSize = sizeof(RTL_OSVERSIONINFOEXW);
versionInfo.dwMajorVersion = major;
versionInfo.dwMinorVersion = minor;
VER_SET_CONDITION(conditionMask, VER_MAJORVERSION, VER_GREATER_EQUAL);
VER_SET_CONDITION(conditionMask, VER_MINORVERSION, VER_GREATER_EQUAL);
return (RtlVerifyVersionInfoFn(&versionInfo, VER_MAJORVERSION | VER_MINORVERSION, conditionMask) == 0) ? TRUE : FALSE;
}
#define _IsWindowsVistaOrGreater() _IsWindowsVersionOrGreater(HIBYTE(0x0600), LOBYTE(0x0600), 0) // _WIN32_WINNT_VISTA
#define _IsWindows8OrGreater() _IsWindowsVersionOrGreater(HIBYTE(0x0602), LOBYTE(0x0602), 0) // _WIN32_WINNT_WIN8
#define _IsWindows8Point1OrGreater() _IsWindowsVersionOrGreater(HIBYTE(0x0603), LOBYTE(0x0603), 0) // _WIN32_WINNT_WINBLUE
#define _IsWindows10OrGreater() _IsWindowsVersionOrGreater(HIBYTE(0x0A00), LOBYTE(0x0A00), 0) // _WIN32_WINNT_WINTHRESHOLD / _WIN32_WINNT_WIN10
#ifndef DPI_ENUMS_DECLARED
typedef enum { PROCESS_DPI_UNAWARE = 0, PROCESS_SYSTEM_DPI_AWARE = 1, PROCESS_PER_MONITOR_DPI_AWARE = 2 } PROCESS_DPI_AWARENESS;
typedef enum { MDT_EFFECTIVE_DPI = 0, MDT_ANGULAR_DPI = 1, MDT_RAW_DPI = 2, MDT_DEFAULT = MDT_EFFECTIVE_DPI } MONITOR_DPI_TYPE;
#endif
#ifndef _DPI_AWARENESS_CONTEXTS_
DECLARE_HANDLE(DPI_AWARENESS_CONTEXT);
#define DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE (DPI_AWARENESS_CONTEXT)-3
#endif
#ifndef DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2
#define DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 (DPI_AWARENESS_CONTEXT)-4
#endif
typedef HRESULT(WINAPI* PFN_SetProcessDpiAwareness)(PROCESS_DPI_AWARENESS); // Shcore.lib + dll, Windows 8.1+
typedef HRESULT(WINAPI* PFN_GetDpiForMonitor)(HMONITOR, MONITOR_DPI_TYPE, UINT*, UINT*); // Shcore.lib + dll, Windows 8.1+
typedef DPI_AWARENESS_CONTEXT(WINAPI* PFN_SetThreadDpiAwarenessContext)(DPI_AWARENESS_CONTEXT); // User32.lib + dll, Windows 10 v1607+ (Creators Update)
// Helper function to enable DPI awareness without setting up a manifest
void ImGui_ImplWin32_EnableDpiAwareness()
{
if (_IsWindows10OrGreater())
{
static HINSTANCE user32_dll = ::LoadLibraryA("user32.dll"); // Reference counted per-process
if (PFN_SetThreadDpiAwarenessContext SetThreadDpiAwarenessContextFn = (PFN_SetThreadDpiAwarenessContext)::GetProcAddress(user32_dll, "SetThreadDpiAwarenessContext"))
{
SetThreadDpiAwarenessContextFn(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
return;
}
}
if (_IsWindows8Point1OrGreater())
{
static HINSTANCE shcore_dll = ::LoadLibraryA("shcore.dll"); // Reference counted per-process
if (PFN_SetProcessDpiAwareness SetProcessDpiAwarenessFn = (PFN_SetProcessDpiAwareness)::GetProcAddress(shcore_dll, "SetProcessDpiAwareness"))
{
SetProcessDpiAwarenessFn(PROCESS_PER_MONITOR_DPI_AWARE);
return;
}
}
#if _WIN32_WINNT >= 0x0600
::SetProcessDPIAware();
#endif
}
#if defined(_MSC_VER) && !defined(NOGDI)
#pragma comment(lib, "gdi32") // Link with gdi32.lib for GetDeviceCaps(). MinGW will require linking with '-lgdi32'
#endif
float ImGui_ImplWin32_GetDpiScaleForMonitor(void* monitor)
{
UINT xdpi = 96, ydpi = 96;
if (_IsWindows8Point1OrGreater())
{
static HINSTANCE shcore_dll = ::LoadLibraryA("shcore.dll"); // Reference counted per-process
static PFN_GetDpiForMonitor GetDpiForMonitorFn = nullptr;
if (GetDpiForMonitorFn == nullptr && shcore_dll != nullptr)
GetDpiForMonitorFn = (PFN_GetDpiForMonitor)::GetProcAddress(shcore_dll, "GetDpiForMonitor");
if (GetDpiForMonitorFn != nullptr)
{
GetDpiForMonitorFn((HMONITOR)monitor, MDT_EFFECTIVE_DPI, &xdpi, &ydpi);
IM_ASSERT(xdpi == ydpi); // Please contact me if you hit this assert!
return xdpi / 96.0f;
}
}
#ifndef NOGDI
const HDC dc = ::GetDC(nullptr);
xdpi = ::GetDeviceCaps(dc, LOGPIXELSX);
ydpi = ::GetDeviceCaps(dc, LOGPIXELSY);
IM_ASSERT(xdpi == ydpi); // Please contact me if you hit this assert!
::ReleaseDC(nullptr, dc);
#endif
return xdpi / 96.0f;
}
float ImGui_ImplWin32_GetDpiScaleForHwnd(void* hwnd)
{
HMONITOR monitor = ::MonitorFromWindow((HWND)hwnd, MONITOR_DEFAULTTONEAREST);
return ImGui_ImplWin32_GetDpiScaleForMonitor(monitor);
}
//---------------------------------------------------------------------------------------------------------
// Transparency related helpers (optional)
//--------------------------------------------------------------------------------------------------------
#if defined(_MSC_VER)
#pragma comment(lib, "dwmapi") // Link with dwmapi.lib. MinGW will require linking with '-ldwmapi'
#endif
// [experimental]
// Borrowed from GLFW's function updateFramebufferTransparency() in src/win32_window.c
// (the Dwm* functions are Vista era functions but we are borrowing logic from GLFW)
void ImGui_ImplWin32_EnableAlphaCompositing(void* hwnd)
{
if (!_IsWindowsVistaOrGreater())
return;
BOOL composition;
if (FAILED(::DwmIsCompositionEnabled(&composition)) || !composition)
return;
BOOL opaque;
DWORD color;
if (_IsWindows8OrGreater() || (SUCCEEDED(::DwmGetColorizationColor(&color, &opaque)) && !opaque))
{
HRGN region = ::CreateRectRgn(0, 0, -1, -1);
DWM_BLURBEHIND bb = {};
bb.dwFlags = DWM_BB_ENABLE | DWM_BB_BLURREGION;
bb.hRgnBlur = region;
bb.fEnable = TRUE;
::DwmEnableBlurBehindWindow((HWND)hwnd, &bb);
::DeleteObject(region);
}
else
{
DWM_BLURBEHIND bb = {};
bb.dwFlags = DWM_BB_ENABLE;
::DwmEnableBlurBehindWindow((HWND)hwnd, &bb);
}
}
//---------------------------------------------------------------------------------------------------------
#if defined(__GNUC__)
#pragma GCC diagnostic pop
#endif
#if defined(__clang__)
#pragma clang diagnostic pop
#endif
#endif // #ifndef IMGUI_DISABLE
+53
View File
@@ -0,0 +1,53 @@
// dear imgui: Platform Backend for Windows (standard windows API for 32-bits AND 64-bits applications)
// This needs to be used along with a Renderer (e.g. DirectX11, OpenGL3, Vulkan..)
// Implemented features:
// [X] Platform: Clipboard support (for Win32 this is actually part of core dear imgui)
// [X] Platform: Mouse support. Can discriminate Mouse/TouchScreen/Pen.
// [X] Platform: Keyboard support. Since 1.87 we are using the io.AddKeyEvent() function. Pass ImGuiKey values to all key functions e.g. ImGui::IsKeyPressed(ImGuiKey_Space). [Legacy VK_* values are obsolete since 1.87 and not supported since 1.91.5]
// [X] Platform: Gamepad support. Enabled with 'io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad'.
// [X] Platform: Mouse cursor shape and visibility. Disable with 'io.ConfigFlags |= ImGuiConfigFlags_NoMouseCursorChange'.
// You can use unmodified imgui_impl_* files in your project. See examples/ folder for examples of using this.
// Prefer including the entire imgui/ repository into your project (either as a copy or as a submodule), and only build the backends you need.
// Learn about Dear ImGui:
// - FAQ https://dearimgui.com/faq
// - Getting Started https://dearimgui.com/getting-started
// - Documentation https://dearimgui.com/docs (same as your local docs/ folder).
// - Introduction, links and more at the top of imgui.cpp
#pragma once
#include "imgui.h" // IMGUI_IMPL_API
#ifndef IMGUI_DISABLE
// Follow "Getting Started" link and check examples/ folder to learn about using backends!
IMGUI_IMPL_API bool ImGui_ImplWin32_Init(void* hwnd);
IMGUI_IMPL_API bool ImGui_ImplWin32_InitForOpenGL(void* hwnd);
IMGUI_IMPL_API void ImGui_ImplWin32_Shutdown();
IMGUI_IMPL_API void ImGui_ImplWin32_NewFrame();
// Win32 message handler your application need to call.
// - Intentionally commented out in a '#if 0' block to avoid dragging dependencies on <windows.h> from this helper.
// - You should COPY the line below into your .cpp code to forward declare the function and then you can call it.
// - Call from your application's message handler. Keep calling your message handler unless this function returns TRUE.
#if 0
extern IMGUI_IMPL_API LRESULT ImGui_ImplWin32_WndProcHandler(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam);
#endif
// DPI-related helpers (optional)
// - Use to enable DPI awareness without having to create an application manifest.
// - Your own app may already do this via a manifest or explicit calls. This is mostly useful for our examples/ apps.
// - In theory we could call simple functions from Windows SDK such as SetProcessDPIAware(), SetProcessDpiAwareness(), etc.
// but most of the functions provided by Microsoft require Windows 8.1/10+ SDK at compile time and Windows 8/10+ at runtime,
// neither we want to require the user to have. So we dynamically select and load those functions to avoid dependencies.
IMGUI_IMPL_API void ImGui_ImplWin32_EnableDpiAwareness();
IMGUI_IMPL_API float ImGui_ImplWin32_GetDpiScaleForHwnd(void* hwnd); // HWND hwnd
IMGUI_IMPL_API float ImGui_ImplWin32_GetDpiScaleForMonitor(void* monitor); // HMONITOR monitor
// Transparency related helpers (optional) [experimental]
// - Use to enable alpha compositing transparency with the desktop.
// - Use together with e.g. clearing your framebuffer with zero-alpha.
IMGUI_IMPL_API void ImGui_ImplWin32_EnableAlphaCompositing(void* hwnd); // HWND hwnd
#endif // #ifndef IMGUI_DISABLE
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+627
View File
@@ -0,0 +1,627 @@
// [DEAR IMGUI]
// This is a slightly modified version of stb_rect_pack.h 1.01.
// Grep for [DEAR IMGUI] to find the changes.
//
// stb_rect_pack.h - v1.01 - public domain - rectangle packing
// Sean Barrett 2014
//
// Useful for e.g. packing rectangular textures into an atlas.
// Does not do rotation.
//
// Before #including,
//
// #define STB_RECT_PACK_IMPLEMENTATION
//
// in the file that you want to have the implementation.
//
// Not necessarily the awesomest packing method, but better than
// the totally naive one in stb_truetype (which is primarily what
// this is meant to replace).
//
// Has only had a few tests run, may have issues.
//
// More docs to come.
//
// No memory allocations; uses qsort() and assert() from stdlib.
// Can override those by defining STBRP_SORT and STBRP_ASSERT.
//
// This library currently uses the Skyline Bottom-Left algorithm.
//
// Please note: better rectangle packers are welcome! Please
// implement them to the same API, but with a different init
// function.
//
// Credits
//
// Library
// Sean Barrett
// Minor features
// Martins Mozeiko
// github:IntellectualKitty
//
// Bugfixes / warning fixes
// Jeremy Jaussaud
// Fabian Giesen
//
// Version history:
//
// 1.01 (2021-07-11) always use large rect mode, expose STBRP__MAXVAL in public section
// 1.00 (2019-02-25) avoid small space waste; gracefully fail too-wide rectangles
// 0.99 (2019-02-07) warning fixes
// 0.11 (2017-03-03) return packing success/fail result
// 0.10 (2016-10-25) remove cast-away-const to avoid warnings
// 0.09 (2016-08-27) fix compiler warnings
// 0.08 (2015-09-13) really fix bug with empty rects (w=0 or h=0)
// 0.07 (2015-09-13) fix bug with empty rects (w=0 or h=0)
// 0.06 (2015-04-15) added STBRP_SORT to allow replacing qsort
// 0.05: added STBRP_ASSERT to allow replacing assert
// 0.04: fixed minor bug in STBRP_LARGE_RECTS support
// 0.01: initial release
//
// LICENSE
//
// See end of file for license information.
//////////////////////////////////////////////////////////////////////////////
//
// INCLUDE SECTION
//
#ifndef STB_INCLUDE_STB_RECT_PACK_H
#define STB_INCLUDE_STB_RECT_PACK_H
#define STB_RECT_PACK_VERSION 1
#ifdef STBRP_STATIC
#define STBRP_DEF static
#else
#define STBRP_DEF extern
#endif
#ifdef __cplusplus
extern "C" {
#endif
typedef struct stbrp_context stbrp_context;
typedef struct stbrp_node stbrp_node;
typedef struct stbrp_rect stbrp_rect;
typedef int stbrp_coord;
#define STBRP__MAXVAL 0x7fffffff
// Mostly for internal use, but this is the maximum supported coordinate value.
STBRP_DEF int stbrp_pack_rects (stbrp_context *context, stbrp_rect *rects, int num_rects);
// Assign packed locations to rectangles. The rectangles are of type
// 'stbrp_rect' defined below, stored in the array 'rects', and there
// are 'num_rects' many of them.
//
// Rectangles which are successfully packed have the 'was_packed' flag
// set to a non-zero value and 'x' and 'y' store the minimum location
// on each axis (i.e. bottom-left in cartesian coordinates, top-left
// if you imagine y increasing downwards). Rectangles which do not fit
// have the 'was_packed' flag set to 0.
//
// You should not try to access the 'rects' array from another thread
// while this function is running, as the function temporarily reorders
// the array while it executes.
//
// To pack into another rectangle, you need to call stbrp_init_target
// again. To continue packing into the same rectangle, you can call
// this function again. Calling this multiple times with multiple rect
// arrays will probably produce worse packing results than calling it
// a single time with the full rectangle array, but the option is
// available.
//
// The function returns 1 if all of the rectangles were successfully
// packed and 0 otherwise.
struct stbrp_rect
{
// reserved for your use:
int id;
// input:
stbrp_coord w, h;
// output:
stbrp_coord x, y;
int was_packed; // non-zero if valid packing
}; // 16 bytes, nominally
STBRP_DEF void stbrp_init_target (stbrp_context *context, int width, int height, stbrp_node *nodes, int num_nodes);
// Initialize a rectangle packer to:
// pack a rectangle that is 'width' by 'height' in dimensions
// using temporary storage provided by the array 'nodes', which is 'num_nodes' long
//
// You must call this function every time you start packing into a new target.
//
// There is no "shutdown" function. The 'nodes' memory must stay valid for
// the following stbrp_pack_rects() call (or calls), but can be freed after
// the call (or calls) finish.
//
// Note: to guarantee best results, either:
// 1. make sure 'num_nodes' >= 'width'
// or 2. call stbrp_allow_out_of_mem() defined below with 'allow_out_of_mem = 1'
//
// If you don't do either of the above things, widths will be quantized to multiples
// of small integers to guarantee the algorithm doesn't run out of temporary storage.
//
// If you do #2, then the non-quantized algorithm will be used, but the algorithm
// may run out of temporary storage and be unable to pack some rectangles.
STBRP_DEF void stbrp_setup_allow_out_of_mem (stbrp_context *context, int allow_out_of_mem);
// Optionally call this function after init but before doing any packing to
// change the handling of the out-of-temp-memory scenario, described above.
// If you call init again, this will be reset to the default (false).
STBRP_DEF void stbrp_setup_heuristic (stbrp_context *context, int heuristic);
// Optionally select which packing heuristic the library should use. Different
// heuristics will produce better/worse results for different data sets.
// If you call init again, this will be reset to the default.
enum
{
STBRP_HEURISTIC_Skyline_default=0,
STBRP_HEURISTIC_Skyline_BL_sortHeight = STBRP_HEURISTIC_Skyline_default,
STBRP_HEURISTIC_Skyline_BF_sortHeight
};
//////////////////////////////////////////////////////////////////////////////
//
// the details of the following structures don't matter to you, but they must
// be visible so you can handle the memory allocations for them
struct stbrp_node
{
stbrp_coord x,y;
stbrp_node *next;
};
struct stbrp_context
{
int width;
int height;
int align;
int init_mode;
int heuristic;
int num_nodes;
stbrp_node *active_head;
stbrp_node *free_head;
stbrp_node extra[2]; // we allocate two extra nodes so optimal user-node-count is 'width' not 'width+2'
};
#ifdef __cplusplus
}
#endif
#endif
//////////////////////////////////////////////////////////////////////////////
//
// IMPLEMENTATION SECTION
//
#ifdef STB_RECT_PACK_IMPLEMENTATION
#ifndef STBRP_SORT
#include <stdlib.h>
#define STBRP_SORT qsort
#endif
#ifndef STBRP_ASSERT
#include <assert.h>
#define STBRP_ASSERT assert
#endif
#ifdef _MSC_VER
#define STBRP__NOTUSED(v) (void)(v)
#define STBRP__CDECL __cdecl
#else
#define STBRP__NOTUSED(v) (void)sizeof(v)
#define STBRP__CDECL
#endif
enum
{
STBRP__INIT_skyline = 1
};
STBRP_DEF void stbrp_setup_heuristic(stbrp_context *context, int heuristic)
{
switch (context->init_mode) {
case STBRP__INIT_skyline:
STBRP_ASSERT(heuristic == STBRP_HEURISTIC_Skyline_BL_sortHeight || heuristic == STBRP_HEURISTIC_Skyline_BF_sortHeight);
context->heuristic = heuristic;
break;
default:
STBRP_ASSERT(0);
}
}
STBRP_DEF void stbrp_setup_allow_out_of_mem(stbrp_context *context, int allow_out_of_mem)
{
if (allow_out_of_mem)
// if it's ok to run out of memory, then don't bother aligning them;
// this gives better packing, but may fail due to OOM (even though
// the rectangles easily fit). @TODO a smarter approach would be to only
// quantize once we've hit OOM, then we could get rid of this parameter.
context->align = 1;
else {
// if it's not ok to run out of memory, then quantize the widths
// so that num_nodes is always enough nodes.
//
// I.e. num_nodes * align >= width
// align >= width / num_nodes
// align = ceil(width/num_nodes)
context->align = (context->width + context->num_nodes-1) / context->num_nodes;
}
}
STBRP_DEF void stbrp_init_target(stbrp_context *context, int width, int height, stbrp_node *nodes, int num_nodes)
{
int i;
for (i=0; i < num_nodes-1; ++i)
nodes[i].next = &nodes[i+1];
nodes[i].next = NULL;
context->init_mode = STBRP__INIT_skyline;
context->heuristic = STBRP_HEURISTIC_Skyline_default;
context->free_head = &nodes[0];
context->active_head = &context->extra[0];
context->width = width;
context->height = height;
context->num_nodes = num_nodes;
stbrp_setup_allow_out_of_mem(context, 0);
// node 0 is the full width, node 1 is the sentinel (lets us not store width explicitly)
context->extra[0].x = 0;
context->extra[0].y = 0;
context->extra[0].next = &context->extra[1];
context->extra[1].x = (stbrp_coord) width;
context->extra[1].y = (1<<30);
context->extra[1].next = NULL;
}
// find minimum y position if it starts at x1
static int stbrp__skyline_find_min_y(stbrp_context *c, stbrp_node *first, int x0, int width, int *pwaste)
{
stbrp_node *node = first;
int x1 = x0 + width;
int min_y, visited_width, waste_area;
STBRP__NOTUSED(c);
STBRP_ASSERT(first->x <= x0);
#if 0
// skip in case we're past the node
while (node->next->x <= x0)
++node;
#else
STBRP_ASSERT(node->next->x > x0); // we ended up handling this in the caller for efficiency
#endif
STBRP_ASSERT(node->x <= x0);
min_y = 0;
waste_area = 0;
visited_width = 0;
while (node->x < x1) {
if (node->y > min_y) {
// raise min_y higher.
// we've accounted for all waste up to min_y,
// but we'll now add more waste for everything we've visted
waste_area += visited_width * (node->y - min_y);
min_y = node->y;
// the first time through, visited_width might be reduced
if (node->x < x0)
visited_width += node->next->x - x0;
else
visited_width += node->next->x - node->x;
} else {
// add waste area
int under_width = node->next->x - node->x;
if (under_width + visited_width > width)
under_width = width - visited_width;
waste_area += under_width * (min_y - node->y);
visited_width += under_width;
}
node = node->next;
}
*pwaste = waste_area;
return min_y;
}
typedef struct
{
int x,y;
stbrp_node **prev_link;
} stbrp__findresult;
static stbrp__findresult stbrp__skyline_find_best_pos(stbrp_context *c, int width, int height)
{
int best_waste = (1<<30), best_x, best_y = (1 << 30);
stbrp__findresult fr;
stbrp_node **prev, *node, *tail, **best = NULL;
// align to multiple of c->align
width = (width + c->align - 1);
width -= width % c->align;
STBRP_ASSERT(width % c->align == 0);
// if it can't possibly fit, bail immediately
if (width > c->width || height > c->height) {
fr.prev_link = NULL;
fr.x = fr.y = 0;
return fr;
}
node = c->active_head;
prev = &c->active_head;
while (node->x + width <= c->width) {
int y,waste;
y = stbrp__skyline_find_min_y(c, node, node->x, width, &waste);
if (c->heuristic == STBRP_HEURISTIC_Skyline_BL_sortHeight) { // actually just want to test BL
// bottom left
if (y < best_y) {
best_y = y;
best = prev;
}
} else {
// best-fit
if (y + height <= c->height) {
// can only use it if it first vertically
if (y < best_y || (y == best_y && waste < best_waste)) {
best_y = y;
best_waste = waste;
best = prev;
}
}
}
prev = &node->next;
node = node->next;
}
best_x = (best == NULL) ? 0 : (*best)->x;
// if doing best-fit (BF), we also have to try aligning right edge to each node position
//
// e.g, if fitting
//
// ____________________
// |____________________|
//
// into
//
// | |
// | ____________|
// |____________|
//
// then right-aligned reduces waste, but bottom-left BL is always chooses left-aligned
//
// This makes BF take about 2x the time
if (c->heuristic == STBRP_HEURISTIC_Skyline_BF_sortHeight) {
tail = c->active_head;
node = c->active_head;
prev = &c->active_head;
// find first node that's admissible
while (tail->x < width)
tail = tail->next;
while (tail) {
int xpos = tail->x - width;
int y,waste;
STBRP_ASSERT(xpos >= 0);
// find the left position that matches this
while (node->next->x <= xpos) {
prev = &node->next;
node = node->next;
}
STBRP_ASSERT(node->next->x > xpos && node->x <= xpos);
y = stbrp__skyline_find_min_y(c, node, xpos, width, &waste);
if (y + height <= c->height) {
if (y <= best_y) {
if (y < best_y || waste < best_waste || (waste==best_waste && xpos < best_x)) {
best_x = xpos;
//STBRP_ASSERT(y <= best_y); [DEAR IMGUI]
best_y = y;
best_waste = waste;
best = prev;
}
}
}
tail = tail->next;
}
}
fr.prev_link = best;
fr.x = best_x;
fr.y = best_y;
return fr;
}
static stbrp__findresult stbrp__skyline_pack_rectangle(stbrp_context *context, int width, int height)
{
// find best position according to heuristic
stbrp__findresult res = stbrp__skyline_find_best_pos(context, width, height);
stbrp_node *node, *cur;
// bail if:
// 1. it failed
// 2. the best node doesn't fit (we don't always check this)
// 3. we're out of memory
if (res.prev_link == NULL || res.y + height > context->height || context->free_head == NULL) {
res.prev_link = NULL;
return res;
}
// on success, create new node
node = context->free_head;
node->x = (stbrp_coord) res.x;
node->y = (stbrp_coord) (res.y + height);
context->free_head = node->next;
// insert the new node into the right starting point, and
// let 'cur' point to the remaining nodes needing to be
// stiched back in
cur = *res.prev_link;
if (cur->x < res.x) {
// preserve the existing one, so start testing with the next one
stbrp_node *next = cur->next;
cur->next = node;
cur = next;
} else {
*res.prev_link = node;
}
// from here, traverse cur and free the nodes, until we get to one
// that shouldn't be freed
while (cur->next && cur->next->x <= res.x + width) {
stbrp_node *next = cur->next;
// move the current node to the free list
cur->next = context->free_head;
context->free_head = cur;
cur = next;
}
// stitch the list back in
node->next = cur;
if (cur->x < res.x + width)
cur->x = (stbrp_coord) (res.x + width);
#ifdef _DEBUG
cur = context->active_head;
while (cur->x < context->width) {
STBRP_ASSERT(cur->x < cur->next->x);
cur = cur->next;
}
STBRP_ASSERT(cur->next == NULL);
{
int count=0;
cur = context->active_head;
while (cur) {
cur = cur->next;
++count;
}
cur = context->free_head;
while (cur) {
cur = cur->next;
++count;
}
STBRP_ASSERT(count == context->num_nodes+2);
}
#endif
return res;
}
static int STBRP__CDECL rect_height_compare(const void *a, const void *b)
{
const stbrp_rect *p = (const stbrp_rect *) a;
const stbrp_rect *q = (const stbrp_rect *) b;
if (p->h > q->h)
return -1;
if (p->h < q->h)
return 1;
return (p->w > q->w) ? -1 : (p->w < q->w);
}
static int STBRP__CDECL rect_original_order(const void *a, const void *b)
{
const stbrp_rect *p = (const stbrp_rect *) a;
const stbrp_rect *q = (const stbrp_rect *) b;
return (p->was_packed < q->was_packed) ? -1 : (p->was_packed > q->was_packed);
}
STBRP_DEF int stbrp_pack_rects(stbrp_context *context, stbrp_rect *rects, int num_rects)
{
int i, all_rects_packed = 1;
// we use the 'was_packed' field internally to allow sorting/unsorting
for (i=0; i < num_rects; ++i) {
rects[i].was_packed = i;
}
// sort according to heuristic
STBRP_SORT(rects, num_rects, sizeof(rects[0]), rect_height_compare);
for (i=0; i < num_rects; ++i) {
if (rects[i].w == 0 || rects[i].h == 0) {
rects[i].x = rects[i].y = 0; // empty rect needs no space
} else {
stbrp__findresult fr = stbrp__skyline_pack_rectangle(context, rects[i].w, rects[i].h);
if (fr.prev_link) {
rects[i].x = (stbrp_coord) fr.x;
rects[i].y = (stbrp_coord) fr.y;
} else {
rects[i].x = rects[i].y = STBRP__MAXVAL;
}
}
}
// unsort
STBRP_SORT(rects, num_rects, sizeof(rects[0]), rect_original_order);
// set was_packed flags and all_rects_packed status
for (i=0; i < num_rects; ++i) {
rects[i].was_packed = !(rects[i].x == STBRP__MAXVAL && rects[i].y == STBRP__MAXVAL);
if (!rects[i].was_packed)
all_rects_packed = 0;
}
// return the all_rects_packed status
return all_rects_packed;
}
#endif
/*
------------------------------------------------------------------------------
This software is available under 2 licenses -- choose whichever you prefer.
------------------------------------------------------------------------------
ALTERNATIVE A - MIT License
Copyright (c) 2017 Sean Barrett
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
------------------------------------------------------------------------------
ALTERNATIVE B - Public Domain (www.unlicense.org)
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or distribute this
software, either in source code form or as a compiled binary, for any purpose,
commercial or non-commercial, and by any means.
In jurisdictions that recognize copyright laws, the author or authors of this
software dedicate any and all copyright interest in the software to the public
domain. We make this dedication for the benefit of the public at large and to
the detriment of our heirs and successors. We intend this dedication to be an
overt act of relinquishment in perpetuity of all present and future rights to
this software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
------------------------------------------------------------------------------
*/
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+128
View File
@@ -0,0 +1,128 @@
# bbport's changes to BBSC
This folder holds [BBSC](https://github.com/Classmg22/BBSC) 0.0.2 by CLS_AMG (GPL-2.0-or-later),
as published at commit 4f11faf (tag v0.0.2; 0.0.1 was commit 315f76c), with the changes below made
for the Linux port of bbport. Changed lines in BBSC's own files are marked `bbport:` in comments.
## 2026-10-15
- **Linux build.** `CMakeLists.txt` builds `libbbsc.so`, which bb-probe loads into the game.
- New files `emulator/mod/linux/`:
- `bbsc_mod_linux.cpp`, which replaces `bbsc_mod.cpp`;
- `bbsc_hooks_linux.cpp`, which replaces the Windows hook code;
- `bbsc_win32.h`, the Win32 and Winsock names the mod uses.
- Linux branches (`#ifdef`) in `emulator/mod/bbsc_relay.cpp`, `bbsc_channel.cpp` and
`bbsc_http.cpp`.
- **Logs and captures.** BBSC keeps 5 of each, and stops a capture at 64 MB.
- **Data folder.** BBSC's files go in the game's user folder, not the working directory.
- **Memory search on Linux.** `emulator/src/core/bbsc_memscan.cpp` reads the readable mappings
through `process_vm_readv`, and through `/proc/self/mem` where that fails: on AMD the game's
memory is a GPU dma-buf, and the item tables were never found there (no co-op kit).
- **F8 key.** The F8 unstuck key works on Linux through SDL (`emulator/src/core/bloodborne_re.cpp`).
- **Bell trace.** The responder bell trace also records the kit's Kin and Bloodlust Bells (4121,
11201), not only the vanilla bells they replace (`emulator/src/core/bloodborne_re.cpp`).
- **The co-op kit, late.** The search for the item tables, which only appear once a character is
loaded, no longer gives up after 3 minutes (`emulator/src/core/bloodborne_re.cpp`, StartBbscDuelBell).
- **Saves.** `BBSC_SAVE_DIR` is set to bbport's save folder (`savedata/<user>/<title id>/SPRJ0005`).
On a first start, with no saves yet, it is the folder the game will write (`BB_SAVEDATA_TITLE`,
from bbport's runtime), so the kit still comes.
- **Translations.** BBSC's texts follow the game's language:
- `emulator/src/core/bbsc_i18n.{h,cpp}` and `bbsc_translations.inc`, generated from
`translations/*.json` by `translations/gen_translations.py`;
- in `emulator/src/core/bloodborne_re.cpp`, BBSC's texts are passed through them.
## 2026-10-19
- **A host's placement goes to the sign it rings** (bbport issue #17). BBSC 0.0.1 sent a host's
placement to every player on the server, and a waiting guest followed the first host it heard
from. On a busy server (12 hosts on Seamless Hunt in one session) a guest followed a stranger:
its sign went to that stranger's map, its friend never found it, and a stranger could summon it
despite the password. BBSC's own `bbsc.dll` of 2026-10-17 (no source published yet) changed
the protocol; this is the same protocol, reimplemented from that binary
(`emulator/mod/bbsc_http.cpp`, `emulator/mod/bbsc_relay.cpp`):
- a guest keeps the `UserId` its sign is created under;
- a host ringing a sign (`/summon_messenger/request` with a `TargetUserId`) sends its placement
to that user only: relay frame `"SHAD"` 0x31 + `"<TargetUserId>|<placement>"`, and the
placement header to the broker;
- a guest takes only a placement sent to its own sign, and keeps it 90 s (20 s otherwise);
- placements sent to the whole server (0x30) are ignored, as BBSC's newer builds do;
- nothing is sent while polling for signs any more, so the "own sign out" check is gone.
## 2026-10-21
- **The guest locks onto the host that rang its sign** (BBSC 0.0.2, source published 2026-10-21,
`emulator/src/core/bloodborne_re.cpp`, BbscRangHost / TickBbscRelayHostLock). The 2026-10-19
placement is now the same as BBSC 0.0.2's on the wire, so Linux and Windows 0.0.2 players find
each other. Taken from 0.0.2 on top of it:
- a placement aimed at our sign notes its sender as the host that rang it (90 s);
- while joining or a guest, that host's packets are taken before the lock is made, so the join
load gets its world flags;
- the lock takes that host when it is on the relay; else the position lock is stricter (6 m
instead of 15, one host that close, 3 checks in a row), and "the only one hosting on the
server" is gone: it was a stranger whenever the real host was not on the relay;
- the hosts that rang are forgotten when the session ends.
BBSC 0.0.2's timeline log was not taken then (it is now, see "BBSC 0.0.2" below).
## 2026-10-21: BBSC 0.0.2
The rest of BBSC 0.0.2 (commit 4f11faf), merged with BBSC's own changes from 0.0.1 kept as they
are, and bbport's changes above kept on top of them.
- **Taken as published:** `CREDITS.txt`, `SUPPORTERS.txt`, `emulator/mod/mod.json` (version 0.0.2,
the new "Bells anywhere" option, the Discord option's text), `emulator/mod/bbsc_channel.cpp`,
`emulator/mod/bbsc_discord.cpp`, `emulator/mod/bbsc_mod.cpp` (both Windows only, not built
here), `emulator/src/core/bbsc_cinematic.{h,cpp}` (not built here), `bbsc_patch_tables.h`,
`bbsc_persistence.h` (the new default-on switches `SHADPS4_BBSC_AREA_LIST_CAP`,
`SHADPS4_BBSC_WORLD_SYNC_ALL`, `SHADPS4_BBSC_TIMELINE`, `SHADPS4_BBSC_BELLS_ANYWHERE`), the
shim's `np_signaling_bbsc.h` and `server/gameserver/`.
- **Taken with bbport's changes kept:** `emulator/src/core/bloodborne_re.{h,cpp}` (the watch
camera of a dead guest, the kit's bells anywhere, the host's time of night for the guest, the
area list cap against "Panic 265", the boss, kick and drop fixes, the kit from the game's own
tables, the frame rate and Discord data, the timeline), `emulator/mod/bbsc_http.cpp` and
`emulator/mod/bbsc_relay.cpp` (the relay's write queue).
- **The aimed placement and the host lock** (2026-10-19 and the first 2026-10-21 entry above) are
now BBSC's own code, under its names: `OnAimedHostPlacement`, `RelaySendAimedPlacement`,
`AimedPlacementType` (0x31), `own_user_id` / `placement_aimed`, and the claim told from the
search by URL (`/summon_messenger/request` vs `/get`). The wire format is unchanged. Gone with
them: bbport's own names (`OnDirectedPlacement`, `RelaySendPlacementTo`, `PlacementToType`) and
its reading of a `UserId` / `TargetUserId` sent as a string.
- **The co-op kit, late** (2026-10-15) stays: 0.0.2 first looks in the game's own tables for 3
minutes; the search of every copy behind it keeps going after 3 minutes as before.
- **Linux:**
- `emulator/mod/linux/bbsc_mod_linux.cpp`: version 0.0.2; the import slot reads and writes
0.0.2 calls `ReadSlotSafely` / `WriteSlotSafely` (through bbport's map of the game's memory);
the timeline's start lines; the timeline file goes next to the log
(`<user dir>/bbsc/logs/bbsc-timeline-*.txt`), and only the newest 5 are kept, as the logs.
- **Watch camera:** the F1 free camera stays out of the Linux build (every key, button and the
mouse also reach the game through bbport's pad, so flying it would move the hunter; L3+R3 is
bbport's menu). The watch camera of 0.0.2 only comes on by itself while our hunter is dead in
co-op, when those inputs do nothing in the game, so it is in: `bbsc_mod_linux.cpp` reads its
keys through SDL like the F8 key, on the game thread and only while it is on: Tab / E / left
click next hunter, Shift+Tab / Q previous, F fly freely and back; the controller's R1 / L1 and
sticks; arrow keys and W / S. F1 turns it off for the rest of that death. The mouse's movement
is not read (it is bbport's mouse look). Switches `SHADPS4_BBSC_CINEMATIC` and
`SHADPS4_BBSC_SPECTATE` as on Windows.
- `emulator/mod/bbsc_http.cpp`: 0.0.2's overrides file list (`HostOverridesFiles`) is Windows
only; on Linux the online module still applies the overrides, and the relay keeps its own list.
- `emulator/mod/bbsc_relay.cpp`: a send to a closed link fails instead of raising SIGPIPE
(`MSG_NOSIGNAL`), and the writer's 10 s send timeout is set as a `timeval`.
- `emulator/mod/linux/bbsc_win32.h`: `WSAGetLastError` and `SD_BOTH`, which the relay uses now.
- **Translations:** the new texts ("An item was added to your items." and the watch camera's) in
every language. `bbsc_i18n.cpp` also translates a text with numbers through its `{n}` /
`{count}` form ("Watching hunter 1 of 2.").
- **Not taken:**
- BBSC's Discord status (`bbsc_discord.cpp`): bbport has its own; the build and boss timer data
are gathered but not shown.
- the files of BBSC's own emulator build (`emulator/src/core/bbsc_relay_client.cpp`, the
emulator's network, signaling, pad and module files, `emulator/CMakeLists.txt`, the launcher's
`emulator_settings.h`) and the patch for BBport's Windows build (`bbport/`): bbport builds
none of them, and the game logic here does not need them.
## 2026-10-23
- **Documentation.** `README.md`, `server/README.md` (in place of `server/README.txt`) and
`server/gameserver/README.md` describe BBSC as this port builds, runs and hosts it.
- **Windows host package removed.** The `.cmd` scripts and `server/tools/*.ps1` set up BBSC's
Windows server package; this port hosts with `tools/shadnet-server`, `tools/bbsc-server` and
`docs/SERVER.md`.
- **SUPPORTERS.txt** keeps its heading and list, without the donation links.
+49
View File
@@ -0,0 +1,49 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# bbport: libbbsc.so, BBSC (Bloodborne Seamless Co-op, CLS_AMG) for the Linux port. The mod's
# own build (emulator/mod/build-mod.cmd) makes bbsc.dll for Windows; this one builds the same
# game logic (emulator/src/core/bloodborne_re.cpp with BBSC_MOD) with the mod's network files and
# the Linux counterparts of its platform files (emulator/mod/linux/). bb-probe loads it into the
# game's process (BB_BBSC_LIB) and calls bbsc_start().
cmake_minimum_required(VERSION 3.20)
project(bbsc CXX)
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release)
endif()
# Header-only, kept in third_party/ (xbyak 7.37.3, BSD-3-Clause; fmt 12.2.0, MIT) so the build does
# not depend on what the system has.
set(XBYAK_INCLUDE ${CMAKE_CURRENT_SOURCE_DIR}/third_party/xbyak)
set(FMT_INCLUDE ${CMAKE_CURRENT_SOURCE_DIR}/third_party/fmt/include)
add_library(bbsc SHARED
emulator/src/core/bloodborne_re.cpp
emulator/src/core/bbsc_memscan.cpp
emulator/src/core/bbsc_i18n.cpp
emulator/mod/bbsc_relay.cpp
emulator/mod/bbsc_channel.cpp
emulator/mod/bbsc_http.cpp
emulator/mod/linux/bbsc_mod_linux.cpp
emulator/mod/linux/bbsc_hooks_linux.cpp)
target_compile_definitions(bbsc PRIVATE BBSC_MOD FMT_HEADER_ONLY)
target_include_directories(bbsc PRIVATE emulator/mod emulator/mod/shim emulator/src
${XBYAK_INCLUDE} ${FMT_INCLUDE})
# Only bbsc_start is for bb-probe; the game's runtime symbols it calls (runtime_memory_vma_info)
# come from bb-probe itself, which exports them.
set_target_properties(bbsc PROPERTIES CXX_VISIBILITY_PRESET hidden VISIBILITY_INLINES_HIDDEN ON)
target_link_options(bbsc PRIVATE -Wl,-z,undefs)
find_package(Threads REQUIRED)
target_link_libraries(bbsc PRIVATE Threads::Threads)
# BBSC turns itself off without its license, notice and supporters list next to it (as the
# Windows mod folder has them).
add_custom_command(TARGET bbsc POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different ${CMAKE_CURRENT_SOURCE_DIR}/LICENSE
$<TARGET_FILE_DIR:bbsc>/LICENSE.txt
COMMAND ${CMAKE_COMMAND} -E copy_if_different ${CMAKE_CURRENT_SOURCE_DIR}/NOTICE.md
$<TARGET_FILE_DIR:bbsc>/NOTICE.txt
COMMAND ${CMAKE_COMMAND} -E copy_if_different ${CMAKE_CURRENT_SOURCE_DIR}/SUPPORTERS.txt
$<TARGET_FILE_DIR:bbsc>/SUPPORTERS.txt)
+38
View File
@@ -0,0 +1,38 @@
BBSC - Bloodborne Seamless Co-op
Credits
MADE BY
CLS_AMG
TESTED BY
fra98_ (framilano)
Hardwater
Onesadeye
aboood888848
Fahad
Mob
lelial
J2mF
Kukar3x
SPECIAL THANKS
framilano (fra98_) - Bloodborne Nucleus Co-op Handler
split-screen Bloodborne on one PC; BBSC runs in it
https://github.com/framilano/BloodborneNucleusCoopHandler
Fahad (fhody125) - No Dream For The Hunter
the game server and ss.info groundwork BBSC's servers grew from,
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)
shadp2p by Wozzardman - the peer-to-peer online fork BBSC is built on
ONLINE
shadNet team - shadNet, the official shadPS4 online service (sessions, matchmaking)
The Hunter's Dream team - the public Bloodborne game server
BLOODBORNE
Bloodborne is developed by FromSoftware and published by Sony Interactive Entertainment.
BBSC is a free fan-made project. It is not affiliated with or endorsed by FromSoftware or Sony.
You need your own copy of the game. No game files are included.
+339
View File
@@ -0,0 +1,339 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, write to the Free Software Foundation, Inc.,
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Ty Coon>, 1 April 1989
Ty Coon, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.
@@ -41,8 +41,6 @@ Thanks
------
- framilano (fra98_) - Bloodborne Nucleus Co-op Handler (split-screen Bloodborne on one PC).
- Fahad (fhody125) - No Dream For The Hunter, the game server and ss.info groundwork.
- The shadNet team (shadNet, the official shadPS4 online service) and The Hunter's Dream team
(the public Bloodborne game server).
- Testers: fra98_ (framilano), Hardwater, Onesadeye, aboood888848, Fahad, Mob, lelial.
+63
View File
@@ -0,0 +1,63 @@
# BBSC: Bloodborne Seamless Co-op
[BBSC](https://github.com/Classmg22/BBSC) by CLS_AMG keeps a co-op session together through deaths,
lamps, area changes, bosses and the Hunter's Dream, for up to five hunters who each keep their own
character. This folder holds BBSC 0.0.2 built for this port: the port's runtime loads it into the
game as `libbbsc.so`.
## What it does
- The session survives deaths, lamps, areas, bosses and trips to the Hunter's Dream; guests follow
the host and come back with the group.
- Up to five hunters: the host and four guests.
- Everyone keeps their own levels, echoes and items; the host's boss kills and shortcuts can be
shared with the guests' worlds.
- Enemies get stronger for every guest.
- A guest who dies watches a partner until the group comes back, sees the host's time of night,
and can ring the co-op bells anywhere.
- Its own bells, with no Insight cost: the **Hunt Bell** (call others), the **Kin Bell** (join
someone), the **Bloodlust Bell** (invade), the **Duel Bell** (PvP on or off) and the **Parting
Blank** (end the session).
## Using it
In the launcher's **Multiplayer → Online** tab, turn on BBSC in the **Seamless co-op (BBSC)** group;
**BBSC settings** holds its options. Every player in the session needs BBSC, the same version. How much of it works
depends on the server (Seamless Hunt, a group's own server, or The Hunter's Dream): see
[docs/ONLINE.md](../docs/ONLINE.md#seamless-co-op-bbsc). To host a server for your group, see
[server/](server/README.md).
## What is in this folder
| Path | Contents |
|---|---|
| `emulator/src/core/` | BBSC's game code (`bloodborne_re.cpp`: the session, the bells, world sync) |
| `emulator/mod/` | The mod around it: relay, network channel, HTTP, settings (`mod.json`) |
| `emulator/mod/linux/` | The Linux side, written for this port: start-up, hooks, the Win32 names the mod uses |
| `server/` | Hosting a server: BBSC's game server and shadNet's settings |
| `translations/` | BBSC's texts in the game's languages |
| `third_party/` | fmt and xbyak |
| `CHANGES-bbport.md` | Every change this port made to BBSC, by date |
`build.sh` builds it into `out/bbsc/libbbsc.so`; on its own:
```sh
cmake -S bbsc -B out/bbsc -G Ninja -DCMAKE_BUILD_TYPE=Release
ninja -C out/bbsc
```
`LICENSE.txt`, `NOTICE.txt` and `SUPPORTERS.txt` are copied next to `libbbsc.so`, and BBSC stays off
without them, as it does in its own builds.
## Reporting problems
In this repository's [issues](https://github.com/ayozetr/bloodborne_linux/issues), with the game's
log from the launcher's **Log** page. Problems that also happen in BBSC's own builds belong in
[BBSC's repository](https://github.com/Classmg22/BBSC).
## License
GPL-2.0-or-later (`LICENSE`, `NOTICE.md`); the third-party parts keep their own licenses. BBSC's
credits are in `CREDITS.txt`, and this port's in [docs/CREDITS.md](../docs/CREDITS.md).
Bloodborne is by FromSoftware and Sony Interactive Entertainment; BBSC is a fan project, not
affiliated with either.
+21
View File
@@ -0,0 +1,21 @@
BBSC SUPPORTERS
===============
BBSC is free for everyone, and every build and feature reaches everyone on the same day.
These people support the time that goes into it. Thank you.
WORKSHOP
--------
aboood888848
Prohorov Ilja
HUNTER
------
.ζ͜长𐌏𐌕𐌏ᚱ𐌆
LAMPLIGHT
---------
(no one yet)
+371
View File
@@ -0,0 +1,371 @@
// SPDX-FileCopyrightText: Copyright 2026 CLS_AMG
// SPDX-License-Identifier: GPL-2.0-or-later
// BBSC mod: the P2P channel - BBSC's own packets over the game's own player-to-player connection,
// for servers that run no BBSC relay (the public ones: The Hunter's Dream + shadNet).
//
// The players of a session already exchange P2P datagrams (the game's socket, vport 40). The
// emulator's P2P transport (official shadPS4: core/net/p2p_transport.cpp; the shadp2p fork:
// network/p2p_sockets.cpp) carries a source and destination vport in every frame and hands each
// frame to the socket bound to that vport, so a second socket on a vport of our own sees only
// BBSC's frames and the game never sees them.
//
// - Peers: the game's import slots for sceNetSendto/sceNetRecvfrom are wrapped; every address
// the game sends to or receives from on a P2P vport is a player of our session (kept while
// the game talks to it, dropped 20 s after). sceNpSignalingGetConnectionStatus is wrapped too
// (ACTIVE connections give the peer's address).
// - Socket: our own DGRAM_P2P socket on ChannelVport, made through the emulator's sceNet
// functions (the originals from the same slots), non-blocking, read by our own thread.
// - Frames: the relay's frames unchanged (the "SHAD" 0x40 envelope with sender + session,
// bbsc_relay.cpp), one datagram each; the receive side is the relay's Dispatch.
//
// Import slots, CUSA03173 01.09 (tools\eboot-imports.py, BBSC offsets):
// 0x053E4B00 sceNetSocket 0x053E4B38 sceNetBind 0x053E4B68 sceNetSetsockopt
// 0x053E4F48 sceNetSendto 0x053E4F58 sceNetRecvfrom
// 0x053E52D0 sceNpSignalingGetConnectionStatus
// Switch SHADPS4_BBSC_P2P_CHANNEL (Mods tab, on). Datagrams can be lost: every BBSC packet was
// made for the emulator's UDP signaling first and is re-sent or repeated by its sender.
#ifdef _WIN32
#include <windows.h>
#else
#include "linux/bbsc_win32.h" // bbport
#endif
#include <atomic>
#include <cstdlib>
#include <cstring>
#include <map>
#include <mutex>
#include <string>
#include <vector>
#include "common/logging/log.h"
#include "common/types.h"
namespace BbscMod {
u64 EbootAddress(); // bbsc_mod.cpp
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
namespace {
constexpr u64 EbootSize = 0x056D4000ULL;
constexpr u64 SlotSocket = 0x053E4B00;
constexpr u64 SlotSocketClose = 0x053E4B08;
constexpr u64 SlotBind = 0x053E4B38;
constexpr u64 SlotSetsockopt = 0x053E4B68;
constexpr u64 SlotSendto = 0x053E4F48;
constexpr u64 SlotRecvfrom = 0x053E4F58;
constexpr u64 SlotConnectionStatus = 0x053E52D0;
constexpr u16 ChannelVport = 3173; // below 32768 (official shadPS4 reserves the rest); game uses 40
constexpr s32 OrbisAfInet = 2;
constexpr s32 OrbisSockDgramP2P = 6;
constexpr s32 OrbisSolSocket = 0xFFFF;
constexpr s32 OrbisSoNbio = 0x1200;
constexpr s32 OrbisMsgDontwait = 0x80;
constexpr u32 SignalingConnActive = 2;
constexpr s64 PeerKeepMs = 20'000;
constexpr size_t MaxDatagram = 2048;
#pragma pack(push, 1)
struct OrbisSockaddrIn {
u8 sin_len;
u8 sin_family;
u16 sin_port; // network order (P2P datagrams: the peer's UDP port)
u32 sin_addr; // network order
u16 sin_vport; // network order
char sin_zero[6];
};
#pragma pack(pop)
static_assert(sizeof(OrbisSockaddrIn) == 16);
using SocketFn = s32 PS4_SYSV_ABI (*)(const char*, s32, s32, s32);
using SocketCloseFn = s32 PS4_SYSV_ABI (*)(s32);
using BindFn = s32 PS4_SYSV_ABI (*)(s32, const void*, u32);
using SetsockoptFn = s32 PS4_SYSV_ABI (*)(s32, s32, s32, const void*, u32);
using SendtoFn = s32 PS4_SYSV_ABI (*)(s32, const void*, u64, s32, const void*, u32);
using RecvfromFn = s32 PS4_SYSV_ABI (*)(s32, void*, u64, s32, void*, u32*);
using ConnectionStatusFn = s32 PS4_SYSV_ABI (*)(s32, s32, u32*, u32*, u16*);
SendtoFn original_sendto = nullptr;
RecvfromFn original_recvfrom = nullptr;
ConnectionStatusFn original_connection_status = nullptr;
struct Peer {
s64 seen_ms = 0;
bool logged = false;
};
std::mutex peers_mutex;
std::map<u64, Peer> peers; // key: addr << 16 | port (both network order)
std::atomic<s32> channel_socket{-1};
std::atomic<bool> enabled{false};
std::atomic<u32> sent_count{0};
std::atomic<u32> received_count{0};
s64 NowMs() {
return static_cast<s64>(GetTickCount64());
}
std::string AddressText(u32 addr, u16 port) {
const auto* b = reinterpret_cast<const u8*>(&addr);
return fmt::format("{}.{}.{}.x:{}", b[0], b[1], b[2], static_cast<u16>((port >> 8) | (port << 8)));
}
void NotePeer(u32 addr, u16 port, const char* how) {
if (addr == 0 || port == 0) {
return;
}
const u64 key = (static_cast<u64>(addr) << 16) | port;
std::scoped_lock lock{peers_mutex};
Peer& peer = peers[key];
const bool fresh = !peer.logged || NowMs() - peer.seen_ms > PeerKeepMs;
peer.seen_ms = NowMs();
if (fresh) {
peer.logged = true;
LOG_INFO(Mod, "P2P channel: player at {} ({})", AddressText(addr, port), how);
}
}
void NoteGameAddress(const void* addr, u32 len, const char* how) {
if (!enabled || addr == nullptr || len < sizeof(OrbisSockaddrIn)) {
return;
}
OrbisSockaddrIn in{};
std::memcpy(&in, addr, sizeof(in));
// A P2P address has a vport; plain UDP/TCP sockets of the game leave it 0.
if (in.sin_family != OrbisAfInet || in.sin_vport == 0 ||
in.sin_vport == static_cast<u16>((ChannelVport >> 8) | (ChannelVport << 8))) {
return;
}
NotePeer(in.sin_addr, in.sin_port, how);
}
s32 PS4_SYSV_ABI Sendto(s32 s, const void* buf, u64 len, s32 flags, const void* addr, u32 addrlen) {
const s32 r = original_sendto(s, buf, len, flags, addr, addrlen);
if (r > 0 && s != channel_socket) {
NoteGameAddress(addr, addrlen, "game sends to it");
}
return r;
}
s32 PS4_SYSV_ABI Recvfrom(s32 s, void* buf, u64 len, s32 flags, void* addr, u32* addrlen) {
const s32 r = original_recvfrom(s, buf, len, flags, addr, addrlen);
if (r > 0 && addrlen != nullptr && s != channel_socket) {
NoteGameAddress(addr, *addrlen, "game hears from it");
}
return r;
}
s32 PS4_SYSV_ABI ConnectionStatus(s32 ctx, s32 conn, u32* status, u32* addr, u16* port) {
// The game may pass no address pointers: ask with our own, hand back what it asked for.
u32 own_addr = 0;
u16 own_port = 0;
const s32 r = original_connection_status(ctx, conn, status, addr ? addr : &own_addr,
port ? port : &own_port);
if (r == 0 && status != nullptr && *status == SignalingConnActive) {
NotePeer(addr ? *addr : own_addr, port ? *port : own_port, "signaling connection active");
}
return r;
}
template <typename Fn>
bool Swap(u64 slot_offset, Fn wrapper, Fn& original, const char* name) {
const u64 slot = BbscMod::EbootAddress() + slot_offset;
for (int i = 0; i < 60'000; ++i) {
u64 value = 0;
if (BbscMod::ReadSlotSafely(slot, &value) && value != 0 &&
(value < BbscMod::EbootAddress() || value >= BbscMod::EbootAddress() + EbootSize)) {
original = reinterpret_cast<Fn>(value);
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;
}
Sleep(1);
}
return false;
}
template <typename Fn>
Fn ReadSlot(u64 slot_offset) {
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);
}
// Made on the first peer: the emulator starts its P2P transport when the game needs it.
// Made on the first peer: the emulator starts its P2P transport when the game needs it. The slots
// are read here, not at start-up (review 2026-10-11): the official shadPS4 links the imports again
// after BBSC starts, and these slots are not wrapped, so they always hold the emulator's current
// functions.
bool OpenSocket() {
const auto open = ReadSlot<SocketFn>(SlotSocket);
const auto bind = ReadSlot<BindFn>(SlotBind);
const auto setsockopt = ReadSlot<SetsockoptFn>(SlotSetsockopt);
const auto close = ReadSlot<SocketCloseFn>(SlotSocketClose);
if (open == nullptr || bind == nullptr || setsockopt == nullptr) {
LOG_WARNING(Mod, "P2P channel: the game's socket imports are not filled - retrying");
return false;
}
const s32 s = open("BBSC channel", OrbisAfInet, OrbisSockDgramP2P, 0);
if (s < 0) {
LOG_WARNING(Mod, "P2P channel: sceNetSocket failed ({:#x}) - retrying", static_cast<u32>(s));
return false;
}
OrbisSockaddrIn bind_to{};
bind_to.sin_len = sizeof(bind_to);
bind_to.sin_family = OrbisAfInet;
bind_to.sin_vport = static_cast<u16>((ChannelVport >> 8) | (ChannelVport << 8));
if (const s32 r = bind(s, &bind_to, sizeof(bind_to)); r < 0) {
LOG_WARNING(Mod, "P2P channel: bind to vport {} failed ({:#x}) - retrying", ChannelVport,
static_cast<u32>(r));
if (close != nullptr) {
close(s);
}
return false;
}
const s32 on = 1;
setsockopt(s, OrbisSolSocket, OrbisSoNbio, &on, sizeof(on));
channel_socket = s;
LOG_INFO(Mod, "P2P channel: socket {} on vport {} - BBSC packets go to the players of the "
"session directly",
s, ChannelVport);
return true;
}
DWORD WINAPI ChannelThread(void*) {
std::vector<u8> buffer(MaxDatagram);
s64 next_open_ms = 0;
int failures = 0;
s64 next_report_ms = NowMs() + 60'000;
for (;;) {
const s32 s = channel_socket;
if (s < 0) {
bool any = false;
{
std::scoped_lock lock{peers_mutex};
any = !peers.empty();
}
if (any && NowMs() >= next_open_ms && failures < 10 && !OpenSocket()) {
next_open_ms = NowMs() + 5'000;
if (++failures == 10) {
LOG_ERROR(Mod, "P2P channel: no socket after 10 tries - channel off for this run");
}
}
Sleep(100);
continue;
}
bool got_any = false;
for (int i = 0; i < 64; ++i) {
OrbisSockaddrIn from{};
u32 from_len = sizeof(from);
const s32 r = original_recvfrom(s, buffer.data(), buffer.size(), OrbisMsgDontwait,
&from, &from_len);
if (r <= 0) {
break;
}
got_any = true;
++received_count;
NotePeer(from.sin_addr, from.sin_port, "BBSC packet from it");
BbscMod::RelayDispatch(std::vector<u8>(buffer.begin(), buffer.begin() + r));
}
if (NowMs() >= next_report_ms) {
next_report_ms = NowMs() + 60'000;
const u32 sent = sent_count.exchange(0);
const u32 received = received_count.exchange(0);
if (sent != 0 || received != 0) {
LOG_INFO(Mod, "P2P channel: last minute {} packet(s) sent, {} received", sent,
received);
}
}
Sleep(got_any ? 1 : 5);
}
}
} // namespace
namespace BbscMod {
bool P2PChannelEnabled() {
return enabled;
}
// One relay frame (envelope included, no length prefix) to every live peer. Returns the peer count.
u32 P2PChannelSend(const void* data, u32 size) {
const s32 s = channel_socket;
if (!enabled || s < 0 || size > MaxDatagram) {
return 0;
}
std::vector<u64> live;
{
std::scoped_lock lock{peers_mutex};
const s64 now = NowMs();
for (auto it = peers.begin(); it != peers.end();) {
if (now - it->second.seen_ms > PeerKeepMs) {
it = peers.erase(it);
} else {
live.push_back(it->first);
++it;
}
}
}
u32 count = 0;
for (const u64 key : live) {
OrbisSockaddrIn to{};
to.sin_len = sizeof(to);
to.sin_family = OrbisAfInet;
to.sin_addr = static_cast<u32>(key >> 16);
to.sin_port = static_cast<u16>(key);
to.sin_vport = static_cast<u16>((ChannelVport >> 8) | (ChannelVport << 8));
if (original_sendto(s, data, size, OrbisMsgDontwait, &to, sizeof(to)) > 0) {
++count;
}
}
sent_count += count;
return count;
}
u32 P2PChannelPeerCount() {
std::scoped_lock lock{peers_mutex};
const s64 now = NowMs();
u32 count = 0;
for (const auto& [key, peer] : peers) {
count += now - peer.seen_ms <= PeerKeepMs ? 1 : 0;
}
return count;
}
void StartP2PChannel() {
if (const char* env = std::getenv("SHADPS4_BBSC_P2P_CHANNEL");
env != nullptr && (env[0] == '0' || env[0] == 'f' || env[0] == 'F')) {
LOG_INFO(Mod, "P2P channel: off (switch)");
return;
}
if (!Swap<SendtoFn>(SlotSendto, &Sendto, original_sendto, "Sendto") ||
!Swap<RecvfromFn>(SlotRecvfrom, &Recvfrom, original_recvfrom, "Recvfrom")) {
LOG_ERROR(Mod, "P2P channel: an import slot was never filled - channel off");
return;
}
Swap<ConnectionStatusFn>(SlotConnectionStatus, &ConnectionStatus, original_connection_status,
"ConnectionStatus");
enabled = true;
if (HANDLE thread = CreateThread(nullptr, 0, ChannelThread, nullptr, 0, nullptr)) {
CloseHandle(thread);
}
LOG_INFO(Mod, "P2P channel: ready (vport {}), waiting for the players of a session",
ChannelVport);
}
} // namespace BbscMod
+287
View File
@@ -0,0 +1,287 @@
// SPDX-FileCopyrightText: Copyright 2026 CLS_AMG
// SPDX-License-Identifier: GPL-2.0-or-later
// 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 "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");
}
std::string Json(const std::string& text) {
std::string out = "\"";
for (const char c : text) {
if (c == '"' || c == '\\') {
out += '\\';
out += c;
} else if (static_cast<unsigned char>(c) < 0x20) {
out += ' ';
} else {
out += c;
}
}
return out + '"';
}
class DiscordPipe {
public:
~DiscordPipe() {
Close();
}
bool Connect() {
for (int i = 0; i < 10; ++i) {
const std::wstring name = L"\\\\.\\pipe\\discord-ipc-" + std::to_wstring(i);
pipe = CreateFileW(name.c_str(), GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_EXISTING,
0, nullptr);
if (pipe != INVALID_HANDLE_VALUE) {
break;
}
}
if (pipe == INVALID_HANDLE_VALUE) {
return false;
}
if (!Send(0, fmt::format("{{\"v\":1,\"client_id\":\"{}\"}}", DiscordApplicationId))) {
Close();
return false;
}
return Drain(true);
}
bool Send(std::uint32_t opcode, const std::string& json) {
if (pipe == INVALID_HANDLE_VALUE) {
return false;
}
std::string frame(8, '\0');
const auto length = static_cast<std::uint32_t>(json.size());
std::memcpy(frame.data(), &opcode, 4);
std::memcpy(frame.data() + 4, &length, 4);
frame += json;
DWORD written = 0;
return WriteFile(pipe, frame.data(), static_cast<DWORD>(frame.size()), &written, nullptr) &&
written == frame.size();
}
// Reads whatever Discord sent (answers, READY) so the pipe never fills. wait = block for one
// frame (the handshake answer). False = the pipe is gone or Discord closed it (opcode 2).
bool Drain(bool wait) {
for (;;) {
DWORD available = 0;
if (!PeekNamedPipe(pipe, nullptr, 0, nullptr, &available, nullptr)) {
Close();
return false;
}
if (available < 8 && !wait) {
return true;
}
std::uint32_t header[2]{};
DWORD got = 0;
if (!ReadFile(pipe, header, 8, &got, nullptr) || got != 8) {
Close();
return false;
}
std::string body(header[1], '\0');
if (header[1] != 0 &&
(!ReadFile(pipe, body.data(), header[1], &got, nullptr) || got != header[1])) {
Close();
return false;
}
if (header[0] == 2) { // CLOSE: wrong application ID or Discord shutting down
LOG_INFO(Debug, "Discord status: Discord closed the connection: {}", body);
Close();
return false;
}
wait = false;
}
}
bool Connected() const {
return pipe != INVALID_HANDLE_VALUE;
}
void Close() {
if (pipe != INVALID_HANDLE_VALUE) {
CloseHandle(pipe);
pipe = INVALID_HANDLE_VALUE;
}
}
private:
HANDLE pipe = INVALID_HANDLE_VALUE;
};
std::string ActivityJson(const Core::Bloodborne::BbscPresence& p, std::int64_t game_start_s) {
std::string details;
std::string state;
std::string large_text = "Bloodborne Seamless Co-op";
std::int64_t start = game_start_s;
if (!p.in_game) {
details = "At the title screen";
} else if (!p.boss.empty()) {
// 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;
} else {
details = "In " + p.area;
}
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()) {
activity += ",\"state\":" + Json(state);
}
activity += fmt::format(",\"timestamps\":{{\"start\":{}}}", start);
activity += ",\"assets\":{\"large_image\":\"bbsc\",\"large_text\":" + Json(large_text) + "}";
if (p.in_game && p.role != 0) {
activity += fmt::format(",\"party\":{{\"id\":\"bbsc-{}\",\"size\":[{},{}]}}",
GetCurrentProcessId(), p.hunters, p.max_hunters);
}
activity += "}";
return activity;
}
DWORD WINAPI DiscordThread(LPVOID) {
const std::int64_t game_start_s = std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch())
.count();
DiscordPipe discord;
std::string sent;
std::uint64_t nonce = 0;
auto next_try = std::chrono::steady_clock::now();
bool logged_connect = false;
for (;;) {
std::this_thread::sleep_for(std::chrono::seconds(2));
if (!discord.Connected()) {
if (std::chrono::steady_clock::now() < next_try) {
continue;
}
if (!discord.Connect()) {
next_try = std::chrono::steady_clock::now() + std::chrono::seconds(20);
continue;
}
if (!logged_connect) {
LOG_INFO(Debug, "Discord status: connected to Discord");
logged_connect = true;
}
sent.clear();
}
if (!discord.Drain(false)) {
continue;
}
const std::string activity = ActivityJson(Core::Bloodborne::GetBbscPresence(), game_start_s);
if (activity == sent) {
continue;
}
const std::string command = fmt::format(
"{{\"cmd\":\"SET_ACTIVITY\",\"args\":{{\"pid\":{},\"activity\":{}}},\"nonce\":\"{}\"}}",
GetCurrentProcessId(), activity, ++nonce);
if (discord.Send(1, command)) {
sent = activity;
} else {
discord.Close();
}
}
}
} // namespace
namespace Core::Bbsc {
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()) {
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);
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
+260
View File
@@ -0,0 +1,260 @@
// SPDX-FileCopyrightText: Copyright 2026 CLS_AMG
// SPDX-License-Identifier: GPL-2.0-or-later
// BBSC mod: Core::InstallGuestCodeHook for the DLL. The same trampoline as the emulator's
// (core/cpu_patches.cpp): save every register and YMM into a GuestRegisterSnapshot, call the C++
// hook with it, restore the (possibly changed) snapshot, run the copied instructions, jump back.
//
// Where the trampolines live: the emulator maps an 8 MB read/write/execute area right after every
// module's image for its own patches (core/module.cpp, TrampolineSize) and fills it from the
// start. The DLL takes the last 1 MB of the eboot's area - inside the 2 GB reach of a 5-byte jmp
// from any game instruction, and checked to be mapped, executable and still all zero before use.
// The image is wherever bbsc_mod.cpp found it (EbootAddress). If that area is not there or not
// free (another emulator build), the DLL maps its own 1 MB within reach after (or before) the image.
//
// Not done here (the emulator checks them with its instruction decoder): that the copied bytes are
// whole instructions without RIP-relative operands. Every site BBSC hooks is a fixed, byte-checked
// instruction sequence that the emulator build has already installed the same way.
#include <windows.h>
#include <cstddef>
#include <cstring>
#include <mutex>
#include <xbyak/xbyak.h>
#include "core/cpu_patches.h"
namespace BbscMod {
void LogPlain(const char* text);
u64 EbootAddress();
}
namespace Core {
namespace {
using namespace Xbyak::util;
constexpr u64 EbootAlignedSize = 0x056D4000ULL;
constexpr u64 EmulatorTrampolineSize = 8ULL << 20;
constexpr u64 OurSize = 1ULL << 20;
constexpr u64 JumpReach = 0x7000'0000ULL; // inside a rel32 jump's 2 GB from anywhere in the image
std::mutex hook_mutex;
Xbyak::CodeGenerator* generator = nullptr;
bool area_checked = false;
bool area_ok = false;
// The last 1 MB of the emulator's trampoline area after the image, when it is mapped RWX and unused.
bool EmulatorAreaFree(u64 start) {
MEMORY_BASIC_INFORMATION info{};
if (VirtualQuery(reinterpret_cast<void*>(start), &info, sizeof(info)) == 0 ||
info.State != MEM_COMMIT ||
(info.Protect & (PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY)) == 0 ||
reinterpret_cast<u64>(info.BaseAddress) + info.RegionSize < start + OurSize) {
return false;
}
const auto* bytes = reinterpret_cast<const u8*>(start);
for (u64 i = 0; i < OurSize; ++i) {
if (bytes[i] != 0) {
return false;
}
}
return true;
}
// Our own 1 MB, 64 KB-aligned, within jump reach of the whole image.
u64 MapOwnArea(u64 image) {
const u64 image_end = image + EbootAlignedSize + EmulatorTrampolineSize;
for (u64 at = (image_end + 0xFFFF) & ~0xFFFFULL; at < image + JumpReach; at += 0x10000) {
if (void* got = VirtualAlloc(reinterpret_cast<void*>(at), OurSize, MEM_RESERVE | MEM_COMMIT,
PAGE_EXECUTE_READWRITE)) {
return reinterpret_cast<u64>(got);
}
}
const u64 low = image > JumpReach - EbootAlignedSize ? image + EbootAlignedSize - JumpReach
: 0x10000;
for (u64 at = (image - OurSize) & ~0xFFFFULL; at >= low && at < image; at -= 0x10000) {
if (void* got = VirtualAlloc(reinterpret_cast<void*>(at), OurSize, MEM_RESERVE | MEM_COMMIT,
PAGE_EXECUTE_READWRITE)) {
return reinterpret_cast<u64>(got);
}
}
return 0;
}
bool CheckArea() {
if (area_checked) {
return area_ok;
}
area_checked = true;
const u64 image = BbscMod::EbootAddress();
if (image == 0) {
BbscMod::LogPlain("Hook area: the game image was not found - no hooks");
return false;
}
u64 start = image + EbootAlignedSize + EmulatorTrampolineSize - OurSize;
if (!EmulatorAreaFree(start)) {
start = MapOwnArea(image);
if (start == 0) {
BbscMod::LogPlain("Hook area: no free memory within reach of the game image - no hooks");
return false;
}
BbscMod::LogPlain("Hook area: the emulator's area after the image is not usable - BBSC "
"mapped its own");
}
generator = new Xbyak::CodeGenerator(OurSize, reinterpret_cast<void*>(start));
area_ok = true;
return true;
}
bool WriteJump(u8* site, const void* target, size_t length) {
DWORD old = 0;
if (!VirtualProtect(site, length, PAGE_EXECUTE_READWRITE, &old)) {
return false;
}
std::array<u8, 32> bytes{};
const s64 rel = reinterpret_cast<s64>(target) - reinterpret_cast<s64>(site + 5);
bytes[0] = 0xE9;
const s32 rel32 = static_cast<s32>(rel);
std::memcpy(bytes.data() + 1, &rel32, 4);
for (size_t i = 5; i < length; ++i) {
bytes[i] = 0x90;
}
std::memcpy(site, bytes.data(), length);
DWORD ignored = 0;
VirtualProtect(site, length, old, &ignored);
FlushInstructionCache(GetCurrentProcess(), site, length);
return true;
}
} // namespace
bool InstallGuestCodeHook(void* address, std::span<const u8> expected_instructions, u64 tag,
GuestCodeHook hook, const u8* skip_flag) {
constexpr size_t NearJumpBytes = 5;
constexpr size_t GuestRedZoneBytes = 128;
constexpr size_t SnapshotOffset = 0;
constexpr size_t ScratchR11Offset = sizeof(GuestRegisterSnapshot);
constexpr size_t ScratchRflagsOffset = ScratchR11Offset + sizeof(u64);
constexpr size_t FrameReserve =
((ScratchRflagsOffset + sizeof(u64) + GuestRedZoneBytes) + 15) & ~size_t{15};
auto* code = static_cast<u8*>(address);
if (hook == nullptr || expected_instructions.size() < NearJumpBytes ||
expected_instructions.size() > 32) {
return false;
}
std::scoped_lock lock{hook_mutex};
if (!CheckArea()) {
return false;
}
if (std::memcmp(expected_instructions.data(), code, expected_instructions.size()) != 0) {
return false;
}
auto& trampoline = *generator;
const size_t trampoline_offset = trampoline.getSize();
const auto* trampoline_start = trampoline.getCurr();
const auto snapshot = [](size_t field_offset) {
return qword[rsp + SnapshotOffset + field_offset];
};
try {
trampoline.lea(rsp, ptr[rsp - FrameReserve]);
trampoline.mov(qword[rsp + ScratchR11Offset], r11);
trampoline.pushfq();
trampoline.pop(r11);
trampoline.mov(qword[rsp + ScratchRflagsOffset], r11);
trampoline.mov(r11, rsp);
trampoline.and_(rsp, -16);
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, rax)), rax);
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, rbx)), rbx);
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, rcx)), rcx);
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, rdx)), rdx);
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, rsi)), rsi);
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, rdi)), rdi);
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, rbp)), rbp);
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, r8)), r8);
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, r9)), r9);
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, r10)), r10);
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, r12)), r12);
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, r13)), r13);
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, r14)), r14);
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, r15)), r15);
trampoline.mov(rax, qword[r11 + ScratchR11Offset]);
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, r11)), rax);
trampoline.mov(rax, qword[r11 + ScratchRflagsOffset]);
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, rflags)), rax);
trampoline.lea(rax, ptr[r11 + FrameReserve]);
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, rsp)), rax);
trampoline.stmxcsr(dword[rsp + SnapshotOffset + offsetof(GuestRegisterSnapshot, mxcsr)]);
for (size_t index = 0; index < 16; ++index) {
const size_t offset = SnapshotOffset + offsetof(GuestRegisterSnapshot, ymm) +
index * sizeof(std::array<u8, 32>);
trampoline.vmovdqu(yword[rsp + offset], Xbyak::Ymm(static_cast<int>(index)));
}
// The hook is Windows code: 32 bytes of shadow space and the MS x64 arguments are not
// what a PS4_SYSV_ABI function expects, so it is called the SysV way (rdi, rsi), exactly
// as the emulator does. A 32-byte gap below the snapshot keeps either convention safe.
trampoline.mov(r12, rsp);
trampoline.sub(rsp, 32);
trampoline.mov(rdi, tag);
trampoline.lea(rsi, ptr[r12 + SnapshotOffset]);
trampoline.mov(rax, reinterpret_cast<u64>(hook));
trampoline.call(rax);
trampoline.mov(rsp, r12);
for (size_t index = 0; index < 16; ++index) {
const size_t offset = SnapshotOffset + offsetof(GuestRegisterSnapshot, ymm) +
index * sizeof(std::array<u8, 32>);
trampoline.vmovdqu(Xbyak::Ymm(static_cast<int>(index)), yword[rsp + offset]);
}
trampoline.ldmxcsr(dword[rsp + SnapshotOffset + offsetof(GuestRegisterSnapshot, mxcsr)]);
trampoline.mov(rbx, snapshot(offsetof(GuestRegisterSnapshot, rbx)));
trampoline.mov(rcx, snapshot(offsetof(GuestRegisterSnapshot, rcx)));
trampoline.mov(rdx, snapshot(offsetof(GuestRegisterSnapshot, rdx)));
trampoline.mov(rsi, snapshot(offsetof(GuestRegisterSnapshot, rsi)));
trampoline.mov(rdi, snapshot(offsetof(GuestRegisterSnapshot, rdi)));
trampoline.mov(rbp, snapshot(offsetof(GuestRegisterSnapshot, rbp)));
trampoline.mov(r8, snapshot(offsetof(GuestRegisterSnapshot, r8)));
trampoline.mov(r9, snapshot(offsetof(GuestRegisterSnapshot, r9)));
trampoline.mov(r10, snapshot(offsetof(GuestRegisterSnapshot, r10)));
trampoline.mov(r11, snapshot(offsetof(GuestRegisterSnapshot, r11)));
trampoline.mov(r12, snapshot(offsetof(GuestRegisterSnapshot, r12)));
trampoline.mov(r13, snapshot(offsetof(GuestRegisterSnapshot, r13)));
trampoline.mov(r14, snapshot(offsetof(GuestRegisterSnapshot, r14)));
trampoline.mov(r15, snapshot(offsetof(GuestRegisterSnapshot, r15)));
trampoline.mov(rax, snapshot(offsetof(GuestRegisterSnapshot, rflags)));
trampoline.push(rax);
trampoline.popfq();
trampoline.mov(rax, snapshot(offsetof(GuestRegisterSnapshot, rax)));
trampoline.mov(rsp, snapshot(offsetof(GuestRegisterSnapshot, rsp)));
Xbyak::Label run_original;
if (skip_flag != nullptr) {
// Function entry only: a plain ret skips the whole function (see cpu_patches.h).
trampoline.push(rax);
trampoline.mov(rax, reinterpret_cast<u64>(skip_flag));
trampoline.cmp(byte[rax], 0);
trampoline.mov(byte[rax], 0);
trampoline.pop(rax);
trampoline.je(run_original, Xbyak::CodeGenerator::LabelType::T_NEAR);
trampoline.ret();
}
trampoline.L(run_original);
trampoline.db(expected_instructions.data(), expected_instructions.size());
trampoline.jmp(code + expected_instructions.size(), Xbyak::CodeGenerator::LabelType::T_NEAR);
} catch (const Xbyak::Error& error) {
trampoline.setSize(trampoline_offset);
BbscMod::LogPlain(error.what());
return false;
}
FlushInstructionCache(GetCurrentProcess(), trampoline_start,
trampoline.getSize() - trampoline_offset);
return WriteJump(code, trampoline_start, expected_instructions.size());
}
} // namespace Core
+853
View File
@@ -0,0 +1,853 @@
// SPDX-FileCopyrightText: Copyright 2026 CLS_AMG
// SPDX-License-Identifier: GPL-2.0-or-later
// BBSC mod: the game's HTTP calls. In the emulator build, core/libraries/network/http.cpp
// changes Bloodborne's summon traffic on its way out (Wozzardman's seamless route plus BBSC's
// connect-first and stale-record cleanup). A mod cannot reach that code, so the DLL does it one
// step earlier: it swaps the eboot's import slots (GOT entries the emulator filled at load) of
// the libSceHttp functions the game calls for its own wrappers, which change the request and pass
// it on to the emulator's function.
//
// Import slots, CUSA03173 01.09 (tools/gamedata/eboot_imports.py, BBSC offsets):
// 0x053E5618 sceHttpCreateConnectionWithURL 0x053E5620 sceHttpCreateRequestWithURL
// 0x053E55E0 sceHttpAddRequestHeader 0x053E5628 sceHttpSetRequestContentLength
// 0x053E5670 sceHttpSendRequest 0x053E5648 sceHttpDeleteRequest
// 0x053E5668 sceHttpReadData
//
// What the wrappers do:
// - URLs: the entries of host_overrides.json (same file and format as the emulator's) are
// applied here too, so a stock emulator reaches the BBSC game server and summon broker.
// - /summon_messenger/*: header X-ShadPS4-Bloodborne-Seamless: 1 (the broker's seamless mode).
// - summon 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::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).
// - a claim the broker delivered with a sign that never became a join, and a session that
// ended, re-arm that delete: the next sign starts clean (see "Stuck claims" below).
// Not done: the emulator's 3 s "fast sign" re-posts. They replaced the game's own reply; extra
// posts from here would take the broker's claim themselves and the game would never join.
#ifdef _WIN32
#include <windows.h>
#include <winhttp.h>
#else
#include "linux/bbsc_win32.h" // bbport
#endif
#ifndef _WIN32
#include "core/memory.h" // bbport: SessionState
#endif
#include <array>
#include <atomic>
#include <cctype>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <map>
#include <thread>
#include <mutex>
#include <sstream>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
#include "common/logging/log.h"
#include "common/types.h"
#include "core/bloodborne_re.h"
// 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 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 {
constexpr u64 EbootSize = 0x056D4000ULL;
constexpr u64 SlotCreateConnection = 0x053E5618;
constexpr u64 SlotCreateRequest = 0x053E5620;
constexpr u64 SlotAddHeader = 0x053E55E0;
constexpr u64 SlotSetContentLength = 0x053E5628;
constexpr u64 SlotSend = 0x053E5670;
constexpr u64 SlotDelete = 0x053E5648;
constexpr u64 SlotReadData = 0x053E5668;
// A claim delivered with our sign is followed by a join (session state 4) within seconds when it
// works; one that has not after this long is a dead claim.
constexpr s64 ClaimJoinWaitMs = 20'000;
constexpr s64 PlacementStaleMs = 20'000;
// 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);
using AddHeaderFn = int PS4_SYSV_ABI (*)(int, const char*, const char*, s32);
using SetContentLengthFn = int PS4_SYSV_ABI (*)(int, u64);
using SendFn = int PS4_SYSV_ABI (*)(int, const void*, u64);
using DeleteFn = int PS4_SYSV_ABI (*)(int);
using ReadDataFn = int PS4_SYSV_ABI (*)(int, void*, u64);
CreateConnectionFn original_create_connection = nullptr;
CreateRequestFn original_create_request = nullptr;
AddHeaderFn original_add_header = nullptr;
SetContentLengthFn original_set_content_length = nullptr;
SendFn original_send = nullptr;
DeleteFn original_delete = nullptr;
ReadDataFn original_read_data = nullptr;
std::mutex state_mutex;
std::map<int, std::string> request_urls; // reqId -> URL the request really goes to
std::vector<std::pair<std::string, std::string>> overrides; // URL prefix -> replacement
std::atomic<bool> stale_cleared{false};
std::atomic<s64> placement_received_ms{0};
// 5-player test 2026-10-12: every player polls /summon_messenger/get, waiting guests too, so every
// guest sent its own placement as "a host looking for signs". The other waiting guests switched
// their sign's map between the real host (Dream) and a guest in Central Yharnam, and the broker
// hid every sign outside the searcher's map (Preparing) - nobody past the 3rd was summoned.
// Only a player without a sign of its own (or a host, state 3) sends the placement now, and a
// waiting guest follows one host until that host goes quiet.
std::atomic<s64> own_sign_created_ms{0};
std::atomic<s64> own_sign_deleted_ms{0};
constexpr s64 OwnSignMs = 60'000; // the game posts its sign again about every 30 s
std::atomic<u32> placement_sender{0};
std::atomic<s64> claim_delivered_ms{0}; // our sign's reply carried a host's claim (0 = none open)
std::atomic<bool> was_in_session{false};
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());
}
// ---- host_overrides.json ------------------------------------------------------------------------
#ifdef _WIN32
std::string ReadFile(const std::wstring& path) {
std::ifstream in(path, std::ios::binary);
std::stringstream text;
text << in.rdbuf();
return text.str();
}
// {"prefix": "replacement", ...} - flat string pairs, as JOIN-HOST writes them.
void LoadOverrides() {
wchar_t exe[MAX_PATH]{};
GetModuleFileNameW(nullptr, exe, MAX_PATH);
std::wstring dir{exe};
const std::wstring exe_name = dir.substr(dir.find_last_of(L"\\/") + 1);
dir = dir.substr(0, dir.find_last_of(L"\\/") + 1);
// bbhost sends the game's URLs to its own server ([online] host) itself. shadPS4's
// host_overrides.json on the same PC pointed them at another server (2026-10-12: game API on
// the shadPS4 server, NP sessions on bbhost's - nothing matched). BBSC_URL_OVERRIDES=0 does the
// same for any host.
const char* switch_env = std::getenv("BBSC_URL_OVERRIDES");
if (_wcsnicmp(exe_name.c_str(), L"bbhost", 6) == 0 || (switch_env && switch_env[0] == '0')) {
LOG_INFO(Mod, "HTTP: URL overrides off ({}) - the host sends the game's URLs itself",
switch_env && switch_env[0] == '0' ? "BBSC_URL_OVERRIDES=0" : "bbhost");
return;
}
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)) {
const size_t end = json.find('"', at + 1);
if (end == std::string::npos) {
break;
}
strings.push_back(json.substr(at + 1, end - at - 1));
at = end + 1;
}
for (size_t i = 0; i + 1 < strings.size(); i += 2) {
overrides.emplace_back(strings[i], strings[i + 1]);
}
if (!overrides.empty()) {
LOG_INFO(Mod, "HTTP: {} URL override(s) from {}", 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");
}
#else
// bbport: the online module applies the user folder's host_overrides itself, to every URL the game
// sends; applying them here as well would rewrite them twice.
void LoadOverrides() {
LOG_INFO(Mod, "HTTP: URL overrides left to bbport's online module");
}
#endif
// 0.0.0.0 is a server's "listen on everything", never an address to connect to. A game server
// started with it as its shadnet address (seen 2026-10-01 under Nucleus Co-op with
// NoDreamForHunter's server) puts it into ss.info, and every summon sign call then fails with
// "Could not establish connection". It can only have meant this PC.
std::string FixAnyAddress(std::string text) {
for (const char* scheme : {"http://0.0.0.0", "https://0.0.0.0"}) {
const std::string prefix = scheme;
if (text.rfind(prefix, 0) == 0 &&
(text.size() == prefix.size() || text[prefix.size()] == ':' ||
text[prefix.size()] == '/')) {
static bool logged = false;
if (!logged) {
logged = true;
LOG_WARNING(Mod, "HTTP: the server list sends the game to 0.0.0.0 ({}) - using "
"127.0.0.1 instead. Start the game server with a real shadnet "
"address.",
text);
}
return text.replace(prefix.size() - 7, 7, "127.0.0.1");
}
}
return text;
}
std::string Override(const char* url) {
std::string text = FixAnyAddress(url != nullptr ? url : "");
for (const auto& [prefix, replacement] : overrides) {
if (text.rfind(prefix, 0) == 0) {
return replacement + text.substr(prefix.size());
}
}
return text;
}
// ---- Summon paths -------------------------------------------------------------------------------
bool Contains(const std::string& url, const char* part) {
return url.find(part) != std::string::npos;
}
bool IsSummon(const std::string& url) {
return Contains(url, "/summon_messenger/");
}
bool IsSummonCreate(const std::string& url) {
return Contains(url, "/summon_messenger/create");
}
bool IsSummonDelete(const std::string& url) {
return Contains(url, "/summon_messenger/delete");
}
bool IsSummonClaim(const std::string& url) {
return Contains(url, "/summon_messenger/request");
}
bool IsSummonSearch(const std::string& url) {
return Contains(url, "/summon_messenger/get");
}
// Public servers (2026-10-09): The Hunter's Dream (and the other community servers) let mods in as
// long as the game's API calls are not changed. On them BBSC adds no headers, rewrites no sign and
// sends no deletes - the seamless broker extras exist only on a BBSC host's shadnet-p2p server.
bool IsPublicServer(const std::string& url) {
static constexpr std::array<const char*, 4> Hosts{"thehuntersdream.com", "nodreamforthehunter.com",
"shadps4.net", "scej-network.jp"};
const size_t scheme = url.find("://");
const size_t start = scheme == std::string::npos ? 0 : scheme + 3;
const std::string host = url.substr(start, url.find_first_of(":/", start) - start);
for (const char* known : Hosts) {
const size_t len = std::strlen(known);
if (host.size() >= len && host.compare(host.size() - len, len, known) == 0) {
return true;
}
}
return false;
}
// "AreaId":<digits> -> the host's map, when connect-first has one. Returns true if changed.
bool RewriteAreaId(std::string& body) {
const auto host_map = Core::Bloodborne::GetBbscConnectFirstAreaId();
if (!host_map) {
return false;
}
const std::string key = "\"AreaId\":";
const size_t at = body.find(key);
if (at == std::string::npos) {
return false;
}
const size_t begin = at + key.size();
size_t end = begin;
while (end < body.size() && body[end] >= '0' && body[end] <= '9') {
++end;
}
const std::string original = body.substr(begin, end - begin);
const std::string replacement = std::to_string(*host_map);
if (end == begin || original == replacement) {
return false;
}
body.replace(begin, end - begin, replacement);
LOG_INFO(Mod, "HTTP: connect-first - sign AreaId {} -> {}", original, replacement);
return true;
}
std::string JsonString(const std::string& body, const char* key) {
const std::string needle = std::string("\"") + key + "\":\"";
const size_t at = body.find(needle);
if (at == std::string::npos) {
return {};
}
const size_t begin = at + needle.size();
const size_t end = body.find('"', begin);
return end == std::string::npos ? std::string{} : body.substr(begin, end - begin);
}
std::string JsonNumber(const std::string& body, const char* key) {
const std::string needle = std::string("\"") + key + "\":";
const size_t at = body.find(needle);
if (at == std::string::npos) {
return {};
}
size_t end = at + needle.size();
while (end < body.size() && (std::isdigit(static_cast<unsigned char>(body[end])) || body[end] == '-')) {
++end;
}
return body.substr(at + needle.size(), end - at - needle.size());
}
#ifdef _WIN32
// One plain POST with WinHTTP (the stale-record delete). Returns the status code, 0 on failure.
DWORD Post(const std::string& url, const std::string& body) {
std::wstring wide(url.begin(), url.end());
URL_COMPONENTS parts{sizeof(parts)};
wchar_t host[256]{};
wchar_t path[2048]{};
parts.lpszHostName = host;
parts.dwHostNameLength = 256;
parts.lpszUrlPath = path;
parts.dwUrlPathLength = 2048;
if (!WinHttpCrackUrl(wide.c_str(), 0, 0, &parts)) {
return 0;
}
DWORD status = 0;
HINTERNET session = WinHttpOpen(L"BBSC", WINHTTP_ACCESS_TYPE_NO_PROXY, nullptr, nullptr, 0);
HINTERNET connect = session ? WinHttpConnect(session, host, parts.nPort, 0) : nullptr;
HINTERNET request =
connect ? WinHttpOpenRequest(connect, L"POST", path, nullptr, nullptr, nullptr,
parts.nScheme == INTERNET_SCHEME_HTTPS ? WINHTTP_FLAG_SECURE : 0)
: nullptr;
if (request != nullptr) {
WinHttpSetTimeouts(request, 1000, 1000, 1000, 1500);
const wchar_t* headers =
L"Content-Type: application/json\r\nX-ShadPS4-Bloodborne-Seamless: 1\r\n";
if (WinHttpSendRequest(request, headers, static_cast<DWORD>(-1),
const_cast<char*>(body.data()), static_cast<DWORD>(body.size()),
static_cast<DWORD>(body.size()), 0) &&
WinHttpReceiveResponse(request, nullptr)) {
DWORD size = sizeof(status);
WinHttpQueryHeaders(request, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
nullptr, &status, &size, nullptr);
}
}
for (HINTERNET h : {request, connect, session}) {
if (h != nullptr) {
WinHttpCloseHandle(h);
}
}
return status;
}
#else
// bbport: one plain HTTP POST over a socket (the stale-record delete goes to the summon broker,
// shadNet's WebAPI, over http). Returns the status code, 0 on failure; https is not done here.
DWORD Post(const std::string& url, const std::string& body) {
constexpr std::string_view Scheme = "http://";
if (url.rfind(Scheme, 0) != 0) {
return 0;
}
const std::string rest = url.substr(Scheme.size());
const size_t slash = rest.find('/');
const std::string authority = rest.substr(0, slash);
const std::string path = slash == std::string::npos ? "/" : rest.substr(slash);
std::string host = authority, port = "80";
if (const size_t colon = authority.rfind(':'); colon != std::string::npos) {
host = authority.substr(0, colon);
port = authority.substr(colon + 1);
}
addrinfo hints{};
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
addrinfo* found = nullptr;
if (getaddrinfo(host.c_str(), port.c_str(), &hints, &found) != 0 || found == nullptr) {
return 0;
}
const SOCKET s = socket(found->ai_family, found->ai_socktype, found->ai_protocol);
const timeval timeout{1, 500'000};
if (s != INVALID_SOCKET) {
setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
}
const bool connected =
s != INVALID_SOCKET && connect(s, found->ai_addr, found->ai_addrlen) == 0;
freeaddrinfo(found);
if (!connected) {
if (s != INVALID_SOCKET) {
closesocket(s);
}
return 0;
}
const std::string request = "POST " + path + " HTTP/1.0\r\nHost: " + authority +
"\r\nContent-Type: application/json\r\n"
"X-ShadPS4-Bloodborne-Seamless: 1\r\nContent-Length: " +
std::to_string(body.size()) + "\r\nConnection: close\r\n\r\n" + body;
DWORD status = 0;
if (send(s, request.data(), request.size(), MSG_NOSIGNAL) == static_cast<ssize_t>(request.size())) {
char reply[64]{};
const ssize_t got = recv(s, reply, sizeof(reply) - 1, 0);
// "HTTP/1.x NNN ..."
if (got > 12 && std::strncmp(reply, "HTTP/", 5) == 0) {
status = static_cast<DWORD>(std::atoi(reply + 9));
}
}
closesocket(s);
return status;
}
#endif
// The broker keeps Preparing/Claimed/Delivered records 15 minutes and Bloodborne reuses its
// SessionId/UserId after a restart, so the first seamless sign of this run removes any old one.
void ClearStaleRecord(const std::string& url, const std::string& body) {
if (stale_cleared.exchange(true)) {
return;
}
const std::string session = JsonString(body, "SessionId");
const std::string user = JsonNumber(body, "UserId");
if (session.empty() || user.empty()) {
return;
}
std::string removal_url = url;
removal_url.replace(removal_url.find("/summon_messenger/create"),
std::strlen("/summon_messenger/create"), "/summon_messenger/delete");
const std::string removal = "{\"MessageId\":\"SummonDataRemoveRequest\",\"SessionId\":\"" +
session + "\",\"UserId\":" + user + ",\"Force\":true}";
// It must finish before the new sign is created (the delete names the same session), so it
// stays on this thread - the game's - with short timeouts (2026-10-08: up to 14 s before).
const DWORD status = Post(removal_url, removal);
LOG_INFO(Mod, "HTTP: stale summon cleanup for session {} user {}: status {}", session, user,
status);
BBSC_TIMELINE("JOIN", "HTTP: stale summon cleanup for session {} user {}: status {}", session, user,
status);
}
// ---- Stuck claims (2026-10-06) -------------------------------------------------------------------
// 4-player Nucleus test: one guest never joined and another waited minutes. In seamless mode the
// broker keeps a claimed sign 15 minutes (Preparing/Claimed/Delivered) and, every time that guest
// posts a sign again, hands back the OLD claim - from a host room that is gone or a join the game
// already gave up on. The host's searches list the same record, and its new claims are refused as
// conflicts. Nothing frees it but the 15-minute expiry or a forced delete, and the forced delete
// only ran once per game start. Now it runs again (on the next sign) when:
// - the broker delivered a claim and no join followed within ClaimJoinWaitMs, or
// - we were in a session (joining, guest or host) and are back to posting signs.
s32 SessionState() {
const u64 manager = *reinterpret_cast<const u64*>(BbscMod::EbootAddress() + 0x05540290);
#ifndef _WIN32
// bbport: the game's memory map instead of VirtualQuery.
if (manager >= 0x10000 && Core::Memory::Instance()->IsValidMapping(manager + 0x124, 4)) {
return *reinterpret_cast<const s32*>(manager + 0x124);
}
return -1;
#else
MEMORY_BASIC_INFORMATION info{};
constexpr DWORD Readable = PAGE_READONLY | PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READ |
PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY;
if (manager >= 0x10000 && VirtualQuery(reinterpret_cast<void*>(manager + 0x124), &info,
sizeof(info)) != 0 &&
info.State == MEM_COMMIT && (info.Protect & Readable) != 0 &&
(info.Protect & (PAGE_GUARD | PAGE_NOACCESS)) == 0) {
return *reinterpret_cast<const s32*>(manager + 0x124);
}
return -1;
#endif
}
void WatchSessionForStaleClaims() {
const s32 state = SessionState();
if (state == 3 || state == 4 || state == 6) {
was_in_session = true;
if (claim_delivered_ms.exchange(0) != 0) {
LOG_INFO(Mod, "HTTP: the delivered claim became a join (session state {})", state);
BBSC_TIMELINE("JOIN", "HTTP: the delivered claim became a join (session state {})", state);
}
return;
}
if (state != 0) {
return;
}
if (was_in_session.exchange(false)) {
stale_cleared = false;
LOG_INFO(Mod, "HTTP: session over - the next sign first clears our old broker record");
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) {
claim_delivered_ms = 0;
stale_cleared = false;
LOG_WARNING(Mod, "HTTP: a host's claim was delivered {} s ago and never became a join - "
"the next sign first clears it",
(NowMs() - delivered) / 1000);
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);
}
}
// ---- The wrappers -------------------------------------------------------------------------------
int PS4_SYSV_ABI CreateConnectionWithURL(int template_id, const char* url, bool keepalive) {
const std::string target = Override(url);
return original_create_connection(template_id, target.c_str(), keepalive);
}
int PS4_SYSV_ABI CreateRequestWithURL(int connection, s32 method, const char* url, u64 length) {
const std::string target = Override(url);
const int request = original_create_request(connection, method, target.c_str(), length);
if (request > 0) {
std::scoped_lock lock{state_mutex};
request_urls[request] = target;
}
return request;
}
// A slot's value when it holds an emulator function (outside the eboot), else 0.
u64 CurrentSlot(u64 slot_offset) {
const u64 base = BbscMod::EbootAddress();
u64 value = 0;
if (!BbscMod::ReadSlotSafely(base + slot_offset, &value)) {
return 0;
}
return value != 0 && (value < base || value >= base + EbootSize) ? value : 0;
}
int PS4_SYSV_ABI SendRequest(int request, const void* data, u64 size) {
std::string url;
{
std::scoped_lock lock{state_mutex};
if (const auto it = request_urls.find(request); it != request_urls.end()) {
url = it->second;
}
}
WatchSessionForStaleClaims();
// 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);
}
if (IsPublicServer(url)) {
static std::atomic<bool> logged{false};
if (!logged.exchange(true)) {
LOG_INFO(Mod, "HTTP: summon requests go to a public server - sent unchanged (no "
"seamless headers, connect-first or cleanup)");
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);
}
// Not wrapped, so the slots always hold the emulator's current functions: read them again at
// use (review 2026-10-11 - the official shadPS4 links the imports again after start-up).
if (const u64 add = CurrentSlot(SlotAddHeader); add != 0) {
original_add_header = reinterpret_cast<AddHeaderFn>(add);
}
if (const u64 length = CurrentSlot(SlotSetContentLength); length != 0) {
original_set_content_length = reinterpret_cast<SetContentLengthFn>(length);
}
if (original_add_header == nullptr || original_set_content_length == nullptr) {
return original_send(request, data, size); // (review 2026-10-08) the slots never filled
}
original_add_header(request, "X-ShadPS4-Bloodborne-Seamless", "1", 0);
if (IsSummonDelete(url)) {
own_sign_deleted_ms = NowMs();
}
if (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::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();
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();
}
if (RewriteAreaId(body)) {
original_set_content_length(request, body.size());
// The emulator copies the body during the call, so a local buffer is fine.
return original_send(request, body.data(), body.size());
}
}
return original_send(request, data, size);
}
// A sign's reply is a bare {"ResKind":0,"MessageId":"SummonDataCreateResponse"} unless the broker
// delivers a host's claim with it, which adds the claim's members (the host's UserId among them).
int PS4_SYSV_ABI ReadData(int request, void* data, u64 size) {
const int read = original_read_data(request, data, size);
if (read > 0 && data != nullptr) {
std::string url;
{
std::scoped_lock lock{state_mutex};
if (const auto it = request_urls.find(request); it != request_urls.end()) {
url = it->second;
}
}
// Public servers (2026-10-10: a host's summon never reached the guest on The Hunter's
// Dream): every summon reply that is not a bare create answer goes into the log.
if (IsSummon(url) && IsPublicServer(url)) {
const std::string_view reply(static_cast<const char*>(data), static_cast<size_t>(read));
const size_t q = url.find('?');
const size_t slash = url.rfind('/', q);
const std::string what = url.substr(slash + 1, q == std::string::npos ? q : q - slash - 1);
const bool bare = reply.find("\"SummonDataCreateResponse\"") != std::string_view::npos &&
reply.size() <= 60;
static std::atomic<int> bare_logged{0};
if (what == "get") {
// Sign lists hold other players' signs: only their size.
LOG_INFO(Mod, "HTTP: summon reply (get, {} bytes)", read);
} else if (!bare || bare_logged.fetch_add(1) < 2) {
std::string text(reply.substr(0, 400));
for (char& c : text) {
if (static_cast<unsigned char>(c) < 0x20) {
c = '.';
}
}
LOG_INFO(Mod, "HTTP: summon reply ({}, {} bytes): {}", what, read, text);
}
}
if (IsSummonCreate(url)) {
const std::string_view reply(static_cast<const char*>(data), static_cast<size_t>(read));
if (reply.find("\"UserId\"") != std::string_view::npos &&
claim_delivered_ms.exchange(NowMs()) == 0) {
LOG_INFO(Mod, "HTTP: a host's claim came with our sign ({} bytes) - waiting for the "
"join",
read);
BBSC_TIMELINE("JOIN", "HTTP: a host's claim came with our sign ({} bytes) - waiting for the "
"join",
read);
}
}
}
return read;
}
int PS4_SYSV_ABI DeleteRequest(int request) {
{
std::scoped_lock lock{state_mutex};
request_urls.erase(request);
}
return original_delete(request);
}
// Waits until the emulator has filled the slot (a host address, outside the eboot), then swaps
// it. The original goes into `original` BEFORE the slot points at the wrapper (review 2026-10-08:
// the game could call the wrapper before the original was stored). False if the slot never filled.
template <typename Fn>
bool Swap(u64 slot_offset, Fn wrapper, Fn& original, const char* name) {
const u64 slot = BbscMod::EbootAddress() + slot_offset;
for (int i = 0; i < 60'000; ++i) {
u64 value = 0;
if (BbscMod::ReadSlotSafely(slot, &value) && value != 0 &&
(value < BbscMod::EbootAddress() || value >= BbscMod::EbootAddress() + EbootSize)) {
original = reinterpret_cast<Fn>(value);
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;
}
Sleep(1);
}
return false;
}
// The two slots read, not swapped: waits for them as Swap does (they are filled at the same time).
u64 WaitForSlot(u64 slot_offset) {
const u64 slot = BbscMod::EbootAddress() + slot_offset;
for (int i = 0; i < 60'000; ++i) {
u64 value = 0;
if (BbscMod::ReadSlotSafely(slot, &value) && value != 0 &&
(value < BbscMod::EbootAddress() || value >= BbscMod::EbootAddress() + EbootSize)) {
return value;
}
Sleep(1);
}
return 0;
}
} // namespace
namespace BbscMod {
#ifdef _WIN32
// 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;
}
#endif
// The relay asks before connecting (bbsc_relay.cpp): public servers run no BBSC relay.
bool IsPublicServerAddress(const std::string& url_or_host) {
return IsPublicServer(url_or_host);
}
// A host's placement from the relay: what the broker's reply header carried in the emulator
// build. Used only by a player who is not in a session yet (a guest waiting to be summoned).
void OnHostPlacement(const std::string& placement, u32 sender) {
// 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
}
// 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)) {
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);
}
}
void InstallHttpWrappers() {
LoadOverrides();
original_add_header = reinterpret_cast<AddHeaderFn>(WaitForSlot(SlotAddHeader));
original_set_content_length =
reinterpret_cast<SetContentLengthFn>(WaitForSlot(SlotSetContentLength));
Swap<CreateConnectionFn>(SlotCreateConnection, &CreateConnectionWithURL,
original_create_connection, "CreateConnectionWithURL");
Swap<CreateRequestFn>(SlotCreateRequest, &CreateRequestWithURL, original_create_request,
"CreateRequestWithURL");
Swap<SendFn>(SlotSend, &SendRequest, original_send, "SendRequest");
Swap<DeleteFn>(SlotDelete, &DeleteRequest, original_delete, "DeleteRequest");
Swap<ReadDataFn>(SlotReadData, &ReadData, original_read_data, "ReadData");
const bool ok = original_create_connection && original_create_request && original_add_header &&
original_set_content_length && original_send && original_delete &&
original_read_data;
LOG_INFO(Mod, "HTTP: game's HTTP calls {} (URL overrides, seamless summon, connect-first, stale "
"cleanup)",
ok ? "wrapped" : "NOT wrapped - an import slot was never filled");
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
+394
View File
@@ -0,0 +1,394 @@
// SPDX-FileCopyrightText: Copyright 2026 CLS_AMG
// SPDX-License-Identifier: GPL-2.0-or-later
// bbsc-launch.exe - starts shadPS4 with the BBSC mod DLL loaded into it.
//
// bbsc-launch.exe [--emu <shadPS4.exe>] [--dll <bbsc.dll>] [--window left|right] [--choose]
// [--cwd <folder>] [shadPS4 arguments...]
//
// --cwd: the folder the emulator starts in (default: the emulator's own folder). shadPS4 uses the
// "user" folder of that folder when there is one (portable), else %APPDATA%\shadPS4 - a portable
// Qt launcher keeps "user" next to the launcher, not next to the emulator version it runs.
//
// Double-clicked with no arguments: the first time it asks (Windows file picker) for
// Bloodborne's eboot.bin and, if there is no shadPS4.exe next to it, for the emulator; the
// choices are kept in bbsc-launch.ini next to it. --choose asks again.
//
// Defaults: shadPS4.exe and bbsc.dll next to this launcher. Every other argument is passed to
// shadPS4 unchanged (e.g. --game <path to eboot.bin>). The emulator is created suspended, the DLL
// is loaded into it by a remote LoadLibraryW, and only then is the emulator let go - so the mod is
// in place before the game's first instruction. The launcher waits for the emulator to close and
// returns its exit code.
#include <windows.h>
#include <commdlg.h>
#include <cstdio>
#include <string>
#include <utility>
#include <vector>
namespace {
std::wstring HereFile(const wchar_t* name) {
wchar_t path[MAX_PATH]{};
GetModuleFileNameW(nullptr, path, MAX_PATH);
std::wstring dir{path};
const size_t slash = dir.find_last_of(L"\\/");
return (slash == std::wstring::npos ? L"" : dir.substr(0, slash + 1)) + name;
}
std::wstring Quote(const std::wstring& arg) {
if (!arg.empty() && arg.find_first_of(L" \t\"") == std::wstring::npos) {
return arg;
}
std::wstring out = L"\"";
size_t backslashes = 0;
for (const wchar_t c : arg) {
if (c == L'\\') {
++backslashes;
} else if (c == L'"') {
out.append(backslashes * 2 + 1, L'\\');
out += c;
backslashes = 0;
continue;
} else {
backslashes = 0;
}
out += c;
}
out.append(backslashes * 2, L'\\');
return out + L"\"";
}
struct FindWindowArgs {
DWORD pid;
HWND found;
};
BOOL CALLBACK FindProcessWindow(HWND window, LPARAM param) {
auto* args = reinterpret_cast<FindWindowArgs*>(param);
DWORD pid = 0;
GetWindowThreadProcessId(window, &pid);
if (pid == args->pid && IsWindowVisible(window) && GetWindow(window, GW_OWNER) == nullptr &&
GetWindowTextLengthW(window) > 0) {
args->found = window;
return FALSE;
}
return TRUE;
}
// --window left|right: puts the emulator's window on that half of the main screen once it
// appears, and keeps it there for the first seconds (the emulator sizes its window while starting).
void PlaceWindow(DWORD pid, const std::wstring& side) {
RECT work{};
SystemParametersInfoW(SPI_GETWORKAREA, 0, &work, 0);
// left / right: half the screen; tl / tr / bl / br: a quarter (4-window tests, 2026-10-07).
const bool quarter = side == L"tl" || side == L"tr" || side == L"bl" || side == L"br";
const int half = (work.right - work.left) / 2;
const bool right = side == L"right" || side == L"tr" || side == L"br";
const bool bottom = side == L"bl" || side == L"br";
const int full_height = work.bottom - work.top;
const int x = right ? work.left + half : work.left;
const int y = bottom ? work.top + full_height / 2 : work.top;
const int height = quarter ? full_height / 2 : full_height;
DWORD placed_at = 0;
for (int i = 0; i < 480; ++i) { // up to 2 minutes for the window to appear
FindWindowArgs args{pid, nullptr};
EnumWindows(FindProcessWindow, reinterpret_cast<LPARAM>(&args));
if (args.found != nullptr) {
RECT now{};
GetWindowRect(args.found, &now);
if (now.left != x || now.top != y || now.right - now.left != half ||
now.bottom - now.top != height) {
ShowWindow(args.found, SW_RESTORE);
SetWindowPos(args.found, nullptr, x, y, half, height,
SWP_NOZORDER | SWP_NOACTIVATE);
}
if (placed_at == 0) {
placed_at = GetTickCount();
} else if (GetTickCount() - placed_at > 15'000) {
return;
}
}
Sleep(250);
}
}
DWORD WINAPI PlaceWindowThread(void* param) {
auto* args = static_cast<std::pair<DWORD, std::wstring>*>(param);
PlaceWindow(args->first, args->second);
delete args;
return 0;
}
// ---- Remembered choices: bbsc-launch.ini next to the launcher (game=..., emulator=...) ---------
std::wstring IniPath() {
return HereFile(L"bbsc-launch.ini");
}
std::wstring LoadSetting(const wchar_t* key) {
wchar_t value[MAX_PATH * 2]{};
GetPrivateProfileStringW(L"bbsc", key, L"", value, MAX_PATH * 2, IniPath().c_str());
return value;
}
void SaveSetting(const wchar_t* key, const std::wstring& value) {
WritePrivateProfileStringW(L"bbsc", key, value.c_str(), IniPath().c_str());
}
bool FileExists(const std::wstring& path) {
const DWORD attributes = GetFileAttributesW(path.c_str());
return !path.empty() && attributes != INVALID_FILE_ATTRIBUTES &&
(attributes & FILE_ATTRIBUTE_DIRECTORY) == 0;
}
void Message(const std::wstring& text, UINT icon = MB_ICONINFORMATION) {
MessageBoxW(nullptr, text.c_str(), L"BBSC", MB_OK | icon | MB_SETFOREGROUND);
}
// A Windows "open file" window. Empty if the player cancelled.
std::wstring PickFile(const wchar_t* title, const wchar_t* filter) {
wchar_t path[MAX_PATH]{};
OPENFILENAMEW dialog{sizeof(dialog)};
dialog.lpstrTitle = title;
dialog.lpstrFilter = filter;
dialog.lpstrFile = path;
dialog.nMaxFile = MAX_PATH;
dialog.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR;
return GetOpenFileNameW(&dialog) ? std::wstring{path} : std::wstring{};
}
// The folder of eboot.bin should hold sce_sys\param.sfo naming CUSA03173.
bool IsBloodborne(const std::wstring& eboot) {
const std::wstring sfo =
eboot.substr(0, eboot.find_last_of(L"\\/") + 1) + L"sce_sys\\param.sfo";
HANDLE file = CreateFileW(sfo.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, 0,
nullptr);
if (file == INVALID_HANDLE_VALUE) {
return false;
}
std::string data(64 * 1024, '\0');
DWORD read = 0;
ReadFile(file, data.data(), static_cast<DWORD>(data.size()), &read, nullptr);
CloseHandle(file);
data.resize(read);
return data.find("CUSA03173") != std::string::npos;
}
std::wstring ChooseGame() {
Message(L"Please pick your Bloodborne game file.\n\n"
L"It is the file eboot.bin in your Bloodborne folder (the one named CUSA03173).\n"
L"You only have to do this once.");
for (;;) {
const std::wstring picked =
PickFile(L"BBSC - pick Bloodborne's eboot.bin", L"eboot.bin\0eboot.bin\0All files\0*.*\0");
if (picked.empty()) {
return {};
}
if (IsBloodborne(picked)) {
SaveSetting(L"game", picked);
return picked;
}
if (MessageBoxW(nullptr,
L"That folder does not look like Bloodborne CUSA03173 (no sce_sys\\param.sfo "
L"with CUSA03173 next to it).\n\nUse it anyway?",
L"BBSC", MB_YESNO | MB_ICONWARNING | MB_SETFOREGROUND) == IDYES) {
SaveSetting(L"game", picked);
return picked;
}
}
}
std::wstring ChooseEmulator() {
Message(L"Please pick the shadPS4.exe you play BBSC with.\n\nYou only have to do this once.");
const std::wstring picked =
PickFile(L"BBSC - pick shadPS4.exe", L"shadPS4.exe\0*.exe\0All files\0*.*\0");
if (!picked.empty()) {
SaveSetting(L"emulator", picked);
}
return picked;
}
void DefaultSwitch(const wchar_t* name, const wchar_t* value) {
if (GetEnvironmentVariableW(name, nullptr, 0) == 0) {
SetEnvironmentVariableW(name, value);
}
}
int Fail(const wchar_t* what, HANDLE process = nullptr) {
const DWORD error = GetLastError();
Message(std::wstring(what) + L"\n\n(Windows error " + std::to_wstring(error) + L")",
MB_ICONERROR);
if (process != nullptr) {
TerminateProcess(process, 1);
}
return 1;
}
} // namespace
int wmain(int argc, wchar_t** argv) {
std::wstring emulator;
std::wstring dll = HereFile(L"bbsc.dll");
std::wstring window_side;
std::wstring start_dir;
bool choose = false;
bool no_mod = false; // --no-mod: start the emulator as it is (comparison runs)
bool has_game = false;
std::vector<std::wstring> passthrough;
for (int i = 1; i < argc; ++i) {
const std::wstring arg = argv[i];
if (arg == L"--choose") {
choose = true;
} else if (arg == L"--no-mod") {
no_mod = true;
} else if (arg == L"--emu" && i + 1 < argc) {
emulator = argv[++i];
} else if (arg == L"--dll" && i + 1 < argc) {
dll = argv[++i];
} else if (arg == L"--cwd" && i + 1 < argc) {
start_dir = argv[++i];
} else if (arg == L"--window" && i + 1 < argc) {
window_side = argv[++i];
} else {
has_game = has_game || arg == L"--game" || arg == L"-g";
passthrough.push_back(arg);
}
}
// The emulator: --emu, else shadPS4.exe next to the launcher, else the one picked before,
// else ask. The game: --game, else the eboot.bin picked before, else ask.
// BBSC_EMULATOR: another program to start instead (the bbport Windows build sets its game exe
// through BB_PROBE, so bbsc-launch stands in for bb-probe.exe and starts it from here).
if (emulator.empty()) {
wchar_t from_env[MAX_PATH]{};
if (GetEnvironmentVariableW(L"BBSC_EMULATOR", from_env, MAX_PATH) > 0) {
emulator = from_env;
}
}
// bbport (Windows): bb-probe.exe gets the prepared game image from bbport's run.py - no
// --game, and it runs in the folder run.py started it in (paths relative to the port).
const std::wstring emulator_name =
emulator.substr(emulator.find_last_of(L"\\/") == std::wstring::npos
? 0
: emulator.find_last_of(L"\\/") + 1);
const bool bbport = _wcsicmp(emulator_name.c_str(), L"bb-probe.exe") == 0;
has_game = has_game || bbport;
if (emulator.empty()) {
emulator = HereFile(L"shadPS4.exe");
if (!FileExists(emulator)) {
emulator = choose ? std::wstring{} : LoadSetting(L"emulator");
if (!FileExists(emulator)) {
emulator = ChooseEmulator();
if (emulator.empty()) {
return 1;
}
}
}
}
// Only shadPS4 takes --game; other hosts (bbport's bb-probe, bbhost) get their own arguments,
// and keep our output handles (their launchers log what the game writes).
bool foreign = false;
{
const size_t cut = emulator.find_last_of(L"\\/");
const std::wstring name = emulator.substr(cut == std::wstring::npos ? 0 : cut + 1);
foreign = _wcsnicmp(name.c_str(), L"shadps4", 7) != 0;
has_game = has_game || foreign;
}
if (!has_game) {
std::wstring game = choose ? std::wstring{} : LoadSetting(L"game");
if (!FileExists(game)) {
game = ChooseGame();
if (game.empty()) {
return 1;
}
}
passthrough.push_back(L"--game");
passthrough.push_back(game);
}
// The BBSC game build does all of this itself; with the mod it must not (the mod does it).
// Set only if the player did not set them; a build without BBSC ignores them.
if (!no_mod) {
DefaultSwitch(L"SHADPS4_BBSC_PERSIST", L"0");
DefaultSwitch(L"SHADPS4_BLOODBORNE_SEAMLESS_COOP", L"0");
DefaultSwitch(L"SHADPS4_BLOODBORNE_RE_TRACE", L"0");
}
wchar_t full_dll[MAX_PATH]{};
if (GetFullPathNameW(dll.c_str(), MAX_PATH, full_dll, nullptr) == 0 ||
(!no_mod && GetFileAttributesW(full_dll) == INVALID_FILE_ATTRIBUTES)) {
return Fail((L"mod DLL not found: " + dll).c_str());
}
if (GetFileAttributesW(emulator.c_str()) == INVALID_FILE_ATTRIBUTES) {
return Fail((L"emulator not found: " + emulator).c_str());
}
std::wstring command = Quote(emulator);
for (const auto& arg : passthrough) {
command += L" " + Quote(arg);
}
std::wstring working_dir = emulator;
const size_t slash = working_dir.find_last_of(L"\\/");
working_dir = slash == std::wstring::npos ? L"." : working_dir.substr(0, slash);
if (!start_dir.empty()) {
working_dir = start_dir;
} else if (bbport) {
wchar_t current[MAX_PATH]{};
GetCurrentDirectoryW(MAX_PATH, current);
working_dir = current;
}
STARTUPINFOW startup{sizeof(startup)};
if (foreign) {
startup.dwFlags |= STARTF_USESTDHANDLES;
startup.hStdInput = GetStdHandle(STD_INPUT_HANDLE);
startup.hStdOutput = GetStdHandle(STD_OUTPUT_HANDLE);
startup.hStdError = GetStdHandle(STD_ERROR_HANDLE);
}
PROCESS_INFORMATION info{};
// bbport: its launcher sends bb-probe's output to user\last_run.log through our handles.
if (!CreateProcessW(emulator.c_str(), command.data(), nullptr, nullptr, foreign ? TRUE : FALSE,
CREATE_SUSPENDED,
nullptr, working_dir.c_str(), &startup, &info)) {
return Fail(L"could not start the emulator");
}
// Load the DLL in the suspended emulator: its first thread runs the loader's process setup.
if (!no_mod) {
const size_t bytes = (wcslen(full_dll) + 1) * sizeof(wchar_t);
void* remote = VirtualAllocEx(info.hProcess, nullptr, bytes, MEM_COMMIT | MEM_RESERVE,
PAGE_READWRITE);
if (remote == nullptr || !WriteProcessMemory(info.hProcess, remote, full_dll, bytes, nullptr)) {
return Fail(L"could not pass the DLL path to the emulator", info.hProcess);
}
auto* load_library = reinterpret_cast<LPTHREAD_START_ROUTINE>(
GetProcAddress(GetModuleHandleW(L"kernel32.dll"), "LoadLibraryW"));
HANDLE loader = CreateRemoteThread(info.hProcess, nullptr, 0, load_library, remote, 0, nullptr);
if (loader == nullptr) {
return Fail(L"could not load the mod into the emulator", info.hProcess);
}
WaitForSingleObject(loader, 30'000);
DWORD loaded = 0;
GetExitCodeThread(loader, &loaded);
CloseHandle(loader);
VirtualFreeEx(info.hProcess, remote, 0, MEM_RELEASE);
if (loaded == 0) {
return Fail(L"the emulator refused the mod DLL (LoadLibraryW returned 0)", info.hProcess);
}
} // !no_mod
ResumeThread(info.hThread);
CloseHandle(info.hThread);
if (window_side == L"left" || window_side == L"right" || window_side == L"tl" ||
window_side == L"tr" || window_side == L"bl" || window_side == L"br") {
auto* args = new std::pair<DWORD, std::wstring>(info.dwProcessId, window_side);
if (HANDLE placer = CreateThread(nullptr, 0, PlaceWindowThread, args, 0, nullptr)) {
CloseHandle(placer);
}
}
WaitForSingleObject(info.hProcess, INFINITE);
DWORD code = 0;
GetExitCodeProcess(info.hProcess, &code);
CloseHandle(info.hProcess);
return static_cast<int>(code);
}
File diff suppressed because it is too large. Load diff
+570
View File
@@ -0,0 +1,570 @@
// SPDX-FileCopyrightText: Copyright 2026 CLS_AMG
// SPDX-License-Identifier: GPL-2.0-or-later
// BBSC mod: the new home of BBSC's own packets (host position / follow, world flags, Dream state).
// In the emulator build they rode on the emulator's P2P signaling. A mod cannot reach that, so
// they go through the BBSC game server instead, which every host already runs and every player's
// game already talks to: one TCP connection to its relay port (20444), frames of
// [u32 little-endian length][payload], and the server hands every frame to every other player
// connected to it. The payloads are the signaling packets unchanged ("SHAD" + type 0x20 follow,
// 0x22 world flags), so the receiving code is the same.
//
// No relay (public servers, or ours not reachable): the same frames go over the game's own P2P
// connection instead (bbsc_channel.cpp), and come back into Dispatch from there.
//
// Server address: BBSC_RELAY=host[:port], else the game server entry of host_overrides.json
// (portable install: <emulator>\user\, else %APPDATA%\shadPS4\) - the address JOIN-HOST wrote.
#ifdef _WIN32
#include <winsock2.h>
#include <ws2tcpip.h>
#include <windows.h>
#else
#include "linux/bbsc_win32.h" // bbport
#endif
#include <atomic>
#include <condition_variable>
#include <cstdlib>
#include <deque>
#include <cstring>
#include <fstream>
#include <mutex>
#include <sstream>
#include <string>
#include <vector>
#include "common/types.h"
#include "core/bloodborne_re.h"
#include "core/libraries/np/np_signaling/np_signaling_bbsc.h"
#include "core/libraries/np/np_signaling/np_signaling_helpers.h"
namespace BbscMod {
void LogPlain(const char* text);
void OnHostPlacement(const std::string& placement, u32 sender);
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();
}
namespace {
constexpr u16 DefaultRelayPort = 20444;
constexpr u8 FollowType = 0x20;
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
constexpr u8 ProgressType = 0x26; // "SHAD" 0x26 + u8 count + count x u32 the host's boss flags
constexpr u8 HostBossType = 0x27; // "SHAD" 0x27 + s32 the boss on the host's boss bar (-1 none)
constexpr u8 PeersType = 0x7F; // server -> client: "N other players are connected", 1 byte count
// Every frame since 2026-10-06: "SHAD" 0x40 + u32 sender + u32 session (the host's sender id, 0 = in
// no session) + the packet above. The server sends each frame to everyone connected to it, so the
// receiver keeps only its own session's packets (see "Relay sessions" in bloodborne_re.cpp).
// Frames without it come from an older BBSC and are dropped.
constexpr u8 EnvelopeType = 0x40;
constexpr size_t EnvelopeSize = 13;
#pragma pack(push, 1)
struct FollowPacket {
u8 magic[4] = {'S', 'H', 'A', 'D'};
u8 type = FollowType;
u8 kind = 0;
u32 packed_map = 0;
float x = 0.0f;
float y = 0.0f;
float z = 0.0f;
float heading = 0.0f;
u32 sequence = 0;
s32 lamp_point = -1;
};
#pragma pack(pop)
static_assert(sizeof(FollowPacket) == 0x22);
std::mutex send_mutex;
std::mutex dispatch_mutex; // the relay thread and the P2P channel's thread both deliver frames
SOCKET relay = INVALID_SOCKET;
// 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
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
}
#ifdef _WIN32
std::string ReadFile(const std::wstring& path) {
#else
std::string ReadFile(const std::string& path) {
#endif
std::ifstream in(path, std::ios::binary);
std::stringstream text;
text << in.rdbuf();
return text.str();
}
// "http://26.1.2.3:20443" as the value of the ss4.scej-network.jp key -> "26.1.2.3".
std::string HostFromOverrides(const std::string& json) {
const size_t key = json.find("ss4.scej-network.jp");
if (key == std::string::npos) {
return {};
}
const size_t colon = json.find(':', json.find('"', key + 1) + 1);
const size_t open = json.find('"', colon);
const size_t close = json.find('"', open + 1);
if (colon == std::string::npos || open == std::string::npos || close == std::string::npos) {
return {};
}
std::string url = json.substr(open + 1, close - open - 1);
if (const size_t scheme = url.find("://"); scheme != std::string::npos) {
url = url.substr(scheme + 3);
}
return url.substr(0, url.find_first_of(":/"));
}
bool FindServer(std::string& host, u16& port) {
port = DefaultRelayPort;
if (const char* env = std::getenv("BBSC_RELAY"); env != nullptr && env[0] != '\0') {
std::string value = env;
if (const size_t colon = value.find(':'); colon != std::string::npos) {
port = static_cast<u16>(std::atoi(value.c_str() + colon + 1));
value.resize(colon);
}
host = value;
return true;
}
#ifdef _WIN32
for (const auto& path : BbscMod::HostOverridesFiles()) {
#else
// bbport: the overrides file run.sh hands the online module (SHADPS4_HTTP_HOST_OVERRIDES_JSON,
// scripts/online.py), else the ones in the user folder.
const char* user = std::getenv("BB_USER_DIR");
const std::string dir = std::string{user != nullptr && *user != '\0' ? user : "user"} + "/";
std::vector<std::string> candidates{dir + "host_overrides.custom.json", dir + "host_overrides.json"};
if (const char* given = std::getenv("SHADPS4_HTTP_HOST_OVERRIDES_JSON"); given && *given) {
candidates.insert(candidates.begin(), given);
}
for (const auto& path : candidates) {
#endif
host = HostFromOverrides(ReadFile(path));
if (!host.empty()) {
return true;
}
}
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;
size -= got;
}
return true;
}
// Our session as the relay sees it: hosting -> ourselves, a guest (or joining) -> our host.
u32 SessionKey() {
const s32 state = Core::Bloodborne::GetBbscRelaySessionState();
if (state == 3) {
return own_id;
}
if (state == 4 || state == 6) {
return Core::Bloodborne::GetBbscRelayHost();
}
return 0;
}
// Returns how many players it went to: the relay's count, or (no relay connected - public servers)
// the players the P2P channel sent it to (bbsc_channel.cpp).
u32 SendFrame(const void* data, u32 size) {
std::scoped_lock lock{send_mutex};
const u32 key = SessionKey();
const u32 wrapped = static_cast<u32>(EnvelopeSize) + size;
std::vector<char> frame(4 + wrapped);
std::memcpy(frame.data(), &wrapped, 4);
std::memcpy(frame.data() + 4, "SHAD", 4);
frame[8] = static_cast<char>(EnvelopeType);
std::memcpy(frame.data() + 9, &own_id, 4);
std::memcpy(frame.data() + 13, &key, 4);
std::memcpy(frame.data() + 4 + EnvelopeSize, data, size);
if (relay == INVALID_SOCKET) {
return BbscMod::P2PChannelSend(frame.data() + 4, wrapped);
}
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");
}
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) {
#ifdef _WIN32
const int sent = send(s, at, left, 0);
#else
// bbport: a link the server closed fails the send instead of raising SIGPIPE, which
// would end the game's process.
const int sent = static_cast<int>(send(s, at, left, MSG_NOSIGNAL));
#endif
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];
return;
}
if (wrapped.size() < 5 || std::memcmp(wrapped.data(), "SHAD", 4) != 0) {
return;
}
if (wrapped[4] != EnvelopeType || wrapped.size() < EnvelopeSize + 5) {
if (!logged_old_sender.exchange(true)) {
Log("a player on this server runs an older BBSC (packets without a sender) - their "
"packets are ignored until they update");
}
return;
}
u32 sender = 0;
u32 session = 0;
std::memcpy(&sender, wrapped.data() + 5, 4);
std::memcpy(&session, wrapped.data() + 9, 4);
const std::vector<u8> frame(wrapped.begin() + EnvelopeSize, wrapped.end());
if (sender == own_id || std::memcmp(frame.data(), "SHAD", 4) != 0) {
return;
}
const s32 state = Core::Bloodborne::GetBbscRelaySessionState();
const bool guest = state == 4 || state == 6;
const bool sender_hosting = session != 0 && session == sender;
// Host-only packets are taken from our host alone.
const auto from_our_host = [&] {
return guest && sender_hosting && sender == Core::Bloodborne::GetBbscRelayHost();
};
if (frame[4] == FollowType && frame.size() >= 0x1E) {
FollowPacket follow{};
std::memcpy(&follow, frame.data(), std::min(frame.size(), sizeof(follow)));
if (sender_hosting) {
Core::Bloodborne::NoteBbscRelayHost(sender, follow.packed_map, follow.x, follow.y,
follow.z);
}
if (from_our_host()) {
Core::Bloodborne::OnBbscHostFollowPacket(
follow.packed_map, follow.x, follow.y, follow.z, follow.heading, follow.sequence,
frame.size() >= sizeof(follow) ? follow.lamp_point : -1);
}
} else if (frame[4] == LifeType && frame.size() >= 14) {
// Only the players of our own session (same host).
if (session == 0 || session != SessionKey()) {
return;
}
u32 player = 0;
u32 sequence = 0;
std::memcpy(&player, frame.data() + 5, 4);
std::memcpy(&sequence, frame.data() + 10, 4);
Core::Bloodborne::OnBbscLifePacket(player, frame[9], sequence);
} else if (frame[4] == ScaleType && frame.size() >= 15) {
if (from_our_host()) {
std::uint16_t percent[5]{};
std::memcpy(percent, frame.data() + 5, sizeof(percent));
Core::Bloodborne::OnBbscScalePacket(percent);
}
} else if (frame[4] == PvpType && frame.size() >= 11) {
// Kind 0 is the host's state (for its guests), kind 1 a guest asking its host.
const bool ours = frame[5] == 0 ? from_our_host() : state == 3 && session == own_id;
if (ours) {
Core::Bloodborne::OnBbscPvpPacket(frame[5], frame[6]);
}
} else if (frame[4] == ProgressType && frame.size() >= 6) {
// The host's boss flags for shared progress: u8 count + count x u32.
const size_t count = std::min<size_t>(frame[5], (frame.size() - 6) / 4);
if (from_our_host() && count != 0) {
std::vector<std::uint32_t> flags(count);
std::memcpy(flags.data(), frame.data() + 6, count * 4);
Core::Bloodborne::OnBbscProgressPacket(flags.data(), flags.size());
}
} else if (frame[4] == HostBossType && frame.size() >= 9) {
if (from_our_host()) {
std::int32_t boss = -1;
std::memcpy(&boss, frame.data() + 5, 4);
Core::Bloodborne::OnBbscHostBossPacket(boss);
}
} else if (frame[4] == PlacementType) {
// A host looking for signs: for players not in a session yet, any host on the server.
BbscMod::OnHostPlacement(std::string(frame.begin() + 5, frame.end()), sender);
} else if (frame[4] == 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()));
}
}
}
DWORD WINAPI RelayThread(void*) {
WSADATA wsa{};
WSAStartup(MAKEWORD(2, 2), &wsa);
std::string last_problem;
for (;;) {
std::string host;
u16 port = 0;
if (!FindServer(host, port)) {
if (last_problem != "no server") {
last_problem = "no server";
Log("no game server address (host_overrides.json / BBSC_RELAY) - retrying");
}
Sleep(10'000);
continue;
}
if (BbscMod::IsPublicServerAddress(host)) {
// The Hunter's Dream / NoDream run no BBSC relay: BBSC packets take the P2P channel.
if (last_problem != "public") {
last_problem = "public";
Log("public game server (" + host + ") - no relay there; BBSC packets go over "
"the P2P channel");
}
Sleep(30'000);
continue;
}
addrinfo hints{};
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
addrinfo* found = nullptr;
SOCKET s = INVALID_SOCKET;
if (getaddrinfo(host.c_str(), std::to_string(port).c_str(), &hints, &found) == 0) {
s = socket(found->ai_family, found->ai_socktype, found->ai_protocol);
if (s != INVALID_SOCKET && connect(s, found->ai_addr, static_cast<int>(found->ai_addrlen)) != 0) {
closesocket(s);
s = INVALID_SOCKET;
}
freeaddrinfo(found);
}
if (s == INVALID_SOCKET) {
const std::string problem = "cannot reach " + host + ":" + std::to_string(port);
if (problem != last_problem) {
last_problem = problem;
Log(problem + " (game server not started, or an older one without the relay) - "
"retrying every 5 s");
}
Sleep(5'000);
continue;
}
BOOL on = TRUE;
setsockopt(s, IPPROTO_TCP, TCP_NODELAY, reinterpret_cast<const char*>(&on), sizeof(on));
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.
#ifdef _WIN32
const DWORD send_timeout_ms = 10'000;
setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, reinterpret_cast<const char*>(&send_timeout_ms),
sizeof(send_timeout_ms));
#else
const timeval send_timeout{10, 0}; // bbport: SO_SNDTIMEO takes a timeval here
setsockopt(s, SOL_SOCKET, SO_SNDTIMEO, &send_timeout, sizeof(send_timeout));
#endif
{
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)) {
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::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 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);
}
}
} // namespace
namespace BbscMod {
// Public game server (The Hunter's Dream...): it works like Sony's, so BBSC must summon the vanilla
// way (bbsc_mod.cpp Worker). No host_overrides.json found = not public (review 2026-10-11): a
// self-host install whose file BBSC does not look for must keep the seamless summons, and without
// the file the game reaches no server at all anyway.
bool GameServerIsPublic(std::string* host_out) {
std::string host;
u16 port = 0;
if (!FindServer(host, port)) {
if (host_out != nullptr) {
*host_out = "none found";
}
return false;
}
if (host_out != nullptr) {
*host_out = host;
}
return BbscMod::IsPublicServerAddress(host);
}
// Frames the P2P channel received (same format as the relay's, without the length).
void RelayDispatch(const std::vector<u8>& wrapped) {
std::scoped_lock lock{dispatch_mutex};
Dispatch(wrapped);
}
void RelaySendAimedPlacement(s64 target_user, const std::string& placement) {
std::string frame = "SHAD";
frame.push_back(static_cast<char>(AimedPlacementType));
frame += std::to_string(target_user) + "|" + placement;
SendFrame(frame.data(), static_cast<u32>(frame.size()));
}
void StartRelay() {
if (!started.exchange(true)) {
#ifdef _WIN32
LARGE_INTEGER counter{};
QueryPerformanceCounter(&counter);
const u64 ticks = static_cast<u64>(counter.QuadPart);
#else
timespec now{};
clock_gettime(CLOCK_MONOTONIC, &now);
const u64 ticks = static_cast<u64>(now.tv_sec) * 1'000'000'000ull + static_cast<u64>(now.tv_nsec);
#endif
own_id = static_cast<u32>((ticks ^ GetCurrentProcessId()) * 2654435761ull) | 1u;
if (HANDLE thread = CreateThread(nullptr, 0, RelayThread, nullptr, 0, nullptr)) {
CloseHandle(thread);
}
if (HANDLE thread = CreateThread(nullptr, 0, WriterThread, nullptr, 0, nullptr)) {
CloseHandle(thread);
}
}
}
} // namespace BbscMod
namespace Libraries::Np::NpSignaling::Helpers {
u32 SendBbscFollowToPeers(u32 packed_map, float x, float y, float z, float heading, u32 sequence,
s32 lamp_point) {
FollowPacket packet{};
packet.packed_map = packed_map;
packet.x = x;
packet.y = y;
packet.z = z;
packet.heading = heading;
packet.sequence = sequence;
packet.lamp_point = lamp_point;
return SendFrame(&packet, sizeof(packet));
}
u32 SendBbscWorldFlagsToPeers(const void* packet, u32 size) {
return SendFrame(packet, size);
}
} // namespace Libraries::Np::NpSignaling::Helpers
@@ -0,0 +1,223 @@
// SPDX-FileCopyrightText: Copyright 2026 CLS_AMG
// SPDX-License-Identifier: GPL-2.0-or-later
// bbport: the Linux counterpart of emulator/mod/bbsc_hooks.cpp - Core::InstallGuestCodeHook for
// libbbsc.so. The trampoline is the mod's, unchanged: save every register and YMM into a
// GuestRegisterSnapshot, call the C++ hook with it, restore the (possibly changed) snapshot, run the
// copied instructions, jump back. The trampolines live in 1 MB of our own, mapped within a rel32
// jump's reach below the game's image (bbport has no emulator trampoline area after it; the image
// grows upwards, and bbport's own hooks take pages below it the same way).
#include <sys/mman.h>
#include <unistd.h>
#include <array>
#include <cstddef>
#include <cstring>
#include <mutex>
#include <xbyak/xbyak.h>
#include "core/cpu_patches.h"
namespace BbscMod {
void LogPlain(const char* text);
u64 EbootAddress();
}
namespace Core {
namespace {
using namespace Xbyak::util;
constexpr u64 OurSize = 1ULL << 20;
std::mutex hook_mutex;
Xbyak::CodeGenerator* generator = nullptr;
bool area_checked = false;
bool area_ok = false;
// 1 MB below the image, within jump reach of all of it, never over anything already mapped.
u64 MapOwnArea(u64 image) {
for (u64 k = 1; k <= 256; ++k) {
const u64 hint = image - k * (8ULL << 20);
void* got = mmap(reinterpret_cast<void*>(hint), OurSize, PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED_NOREPLACE, -1, 0);
if (got != MAP_FAILED) {
return reinterpret_cast<u64>(got);
}
}
return 0;
}
bool CheckArea() {
if (area_checked) {
return area_ok;
}
area_checked = true;
const u64 image = BbscMod::EbootAddress();
if (image == 0) {
BbscMod::LogPlain("Hook area: the game image was not found - no hooks");
return false;
}
const u64 start = MapOwnArea(image);
if (start == 0) {
BbscMod::LogPlain("Hook area: no free memory within reach of the game image - no hooks");
return false;
}
generator = new Xbyak::CodeGenerator(OurSize, reinterpret_cast<void*>(start));
area_ok = true;
return true;
}
// The 5-byte jump (and NOPs to the end of the covered instructions): all but the first byte, then
// the first, so a thread meeting the site decodes either the old instruction start or the jump.
bool WriteJump(u8* site, const void* target, size_t length) {
const auto page = static_cast<u64>(sysconf(_SC_PAGESIZE));
void* first = reinterpret_cast<void*>(reinterpret_cast<u64>(site) & ~(page - 1));
const size_t span = reinterpret_cast<u64>(site) + length - reinterpret_cast<u64>(first);
if (mprotect(first, span, PROT_READ | PROT_WRITE | PROT_EXEC) != 0) {
return false;
}
std::array<u8, 32> bytes{};
const s64 rel = reinterpret_cast<s64>(target) - reinterpret_cast<s64>(site + 5);
bytes[0] = 0xE9;
const s32 rel32 = static_cast<s32>(rel);
std::memcpy(bytes.data() + 1, &rel32, 4);
for (size_t i = 5; i < length; ++i) {
bytes[i] = 0x90;
}
for (size_t i = 1; i < length; ++i) {
__atomic_store_n(site + i, bytes[i], __ATOMIC_SEQ_CST);
}
__atomic_store_n(site, bytes[0], __ATOMIC_SEQ_CST);
return true;
}
} // namespace
bool InstallGuestCodeHook(void* address, std::span<const u8> expected_instructions, u64 tag,
GuestCodeHook hook, const u8* skip_flag) {
constexpr size_t NearJumpBytes = 5;
constexpr size_t GuestRedZoneBytes = 128;
constexpr size_t SnapshotOffset = 0;
constexpr size_t ScratchR11Offset = sizeof(GuestRegisterSnapshot);
constexpr size_t ScratchRflagsOffset = ScratchR11Offset + sizeof(u64);
constexpr size_t FrameReserve =
((ScratchRflagsOffset + sizeof(u64) + GuestRedZoneBytes) + 15) & ~size_t{15};
auto* code = static_cast<u8*>(address);
if (hook == nullptr || expected_instructions.size() < NearJumpBytes ||
expected_instructions.size() > 32) {
return false;
}
std::scoped_lock lock{hook_mutex};
if (!CheckArea()) {
return false;
}
if (std::memcmp(expected_instructions.data(), code, expected_instructions.size()) != 0) {
return false;
}
auto& trampoline = *generator;
const size_t trampoline_offset = trampoline.getSize();
const auto* trampoline_start = trampoline.getCurr();
const auto snapshot = [](size_t field_offset) {
return qword[rsp + SnapshotOffset + field_offset];
};
try {
trampoline.lea(rsp, ptr[rsp - FrameReserve]);
trampoline.mov(qword[rsp + ScratchR11Offset], r11);
trampoline.pushfq();
trampoline.pop(r11);
trampoline.mov(qword[rsp + ScratchRflagsOffset], r11);
trampoline.mov(r11, rsp);
trampoline.and_(rsp, -16);
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, rax)), rax);
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, rbx)), rbx);
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, rcx)), rcx);
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, rdx)), rdx);
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, rsi)), rsi);
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, rdi)), rdi);
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, rbp)), rbp);
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, r8)), r8);
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, r9)), r9);
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, r10)), r10);
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, r12)), r12);
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, r13)), r13);
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, r14)), r14);
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, r15)), r15);
trampoline.mov(rax, qword[r11 + ScratchR11Offset]);
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, r11)), rax);
trampoline.mov(rax, qword[r11 + ScratchRflagsOffset]);
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, rflags)), rax);
trampoline.lea(rax, ptr[r11 + FrameReserve]);
trampoline.mov(snapshot(offsetof(GuestRegisterSnapshot, rsp)), rax);
trampoline.stmxcsr(dword[rsp + SnapshotOffset + offsetof(GuestRegisterSnapshot, mxcsr)]);
for (size_t index = 0; index < 16; ++index) {
const size_t offset = SnapshotOffset + offsetof(GuestRegisterSnapshot, ymm) +
index * sizeof(std::array<u8, 32>);
trampoline.vmovdqu(yword[rsp + offset], Xbyak::Ymm(static_cast<int>(index)));
}
// The hook is Windows code: 32 bytes of shadow space and the MS x64 arguments are not
// what a PS4_SYSV_ABI function expects, so it is called the SysV way (rdi, rsi), exactly
// as the emulator does. A 32-byte gap below the snapshot keeps either convention safe.
trampoline.mov(r12, rsp);
trampoline.sub(rsp, 32);
trampoline.mov(rdi, tag);
trampoline.lea(rsi, ptr[r12 + SnapshotOffset]);
trampoline.mov(rax, reinterpret_cast<u64>(hook));
trampoline.call(rax);
trampoline.mov(rsp, r12);
for (size_t index = 0; index < 16; ++index) {
const size_t offset = SnapshotOffset + offsetof(GuestRegisterSnapshot, ymm) +
index * sizeof(std::array<u8, 32>);
trampoline.vmovdqu(Xbyak::Ymm(static_cast<int>(index)), yword[rsp + offset]);
}
trampoline.ldmxcsr(dword[rsp + SnapshotOffset + offsetof(GuestRegisterSnapshot, mxcsr)]);
trampoline.mov(rbx, snapshot(offsetof(GuestRegisterSnapshot, rbx)));
trampoline.mov(rcx, snapshot(offsetof(GuestRegisterSnapshot, rcx)));
trampoline.mov(rdx, snapshot(offsetof(GuestRegisterSnapshot, rdx)));
trampoline.mov(rsi, snapshot(offsetof(GuestRegisterSnapshot, rsi)));
trampoline.mov(rdi, snapshot(offsetof(GuestRegisterSnapshot, rdi)));
trampoline.mov(rbp, snapshot(offsetof(GuestRegisterSnapshot, rbp)));
trampoline.mov(r8, snapshot(offsetof(GuestRegisterSnapshot, r8)));
trampoline.mov(r9, snapshot(offsetof(GuestRegisterSnapshot, r9)));
trampoline.mov(r10, snapshot(offsetof(GuestRegisterSnapshot, r10)));
trampoline.mov(r11, snapshot(offsetof(GuestRegisterSnapshot, r11)));
trampoline.mov(r12, snapshot(offsetof(GuestRegisterSnapshot, r12)));
trampoline.mov(r13, snapshot(offsetof(GuestRegisterSnapshot, r13)));
trampoline.mov(r14, snapshot(offsetof(GuestRegisterSnapshot, r14)));
trampoline.mov(r15, snapshot(offsetof(GuestRegisterSnapshot, r15)));
trampoline.mov(rax, snapshot(offsetof(GuestRegisterSnapshot, rflags)));
trampoline.push(rax);
trampoline.popfq();
trampoline.mov(rax, snapshot(offsetof(GuestRegisterSnapshot, rax)));
trampoline.mov(rsp, snapshot(offsetof(GuestRegisterSnapshot, rsp)));
Xbyak::Label run_original;
if (skip_flag != nullptr) {
// Function entry only: a plain ret skips the whole function (see cpu_patches.h).
trampoline.push(rax);
trampoline.mov(rax, reinterpret_cast<u64>(skip_flag));
trampoline.cmp(byte[rax], 0);
trampoline.mov(byte[rax], 0);
trampoline.pop(rax);
trampoline.je(run_original, Xbyak::CodeGenerator::LabelType::T_NEAR);
trampoline.ret();
}
trampoline.L(run_original);
trampoline.db(expected_instructions.data(), expected_instructions.size());
trampoline.jmp(code + expected_instructions.size(), Xbyak::CodeGenerator::LabelType::T_NEAR);
} catch (const Xbyak::Error& error) {
trampoline.setSize(trampoline_offset);
BbscMod::LogPlain(error.what());
return false;
}
return WriteJump(code, trampoline_start, expected_instructions.size());
}
} // namespace Core
+788
View File
@@ -0,0 +1,788 @@
// SPDX-FileCopyrightText: Copyright 2026 CLS_AMG
// SPDX-License-Identifier: GPL-2.0-or-later
// bbport: the Linux counterpart of emulator/mod/bbsc_mod.cpp - BBSC as a library (libbbsc.so)
// that bb-probe loads into the game's process (BB_BBSC_LIB) instead of a DLL injected into
// shadPS4. The steps are the DLL's, and the patch site logic below is its code unchanged:
// 1. wait until every byte patch site reads back as the bytes that must be there (all of them
// before writing any - another game or version is left alone);
// 2. write the byte patches (core/bbsc_patch_tables.h);
// 3. install every hook with core/bloodborne_re.cpp, compiled into this library with BBSC_MOD;
// 4. keep watching: a patch site written back is written again.
// What differs on Linux: the game is always at 0x800000000 (bbport, as the shadp2p fork); memory
// questions are answered from bbport's own map of the game's memory (runtime_memory_vma_info, no
// system call); the log goes to <user dir>/bbsc/logs/ and its warnings to the game's log; the F1 /
// L3+R3 cinematic camera and BBSC's own Discord status are not in this build (bbport has its own
// Discord status, and L3+R3 opens bbport's menu). Only BBSC 0.0.2's watch camera of a dead guest
// is, with its keys read through SDL (see "Watch camera" below).
#include <dlfcn.h>
#include <sys/mman.h>
#include <unistd.h>
#include <algorithm>
#include <array>
#include <atomic>
#include <cstdarg>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <ctime>
#include <filesystem>
#include <mutex>
#include <string>
#include <vector>
#include "linux/bbsc_win32.h"
#include "common/logging/log.h"
#include "common/memory_patcher.h"
#include "common/path_util.h"
#include "core/bbsc_cinematic.h"
#include "core/bbsc_patch_tables.h"
#include "core/bloodborne_re.h"
#include "core/debugger.h"
#include "core/libraries/np/np_matching2/np_matching2_mm.h"
#include "core/memory.h"
// bbport's runtime (bb-probe exports it): the game's memory map.
extern "C" int runtime_memory_vma_info(uintptr_t address, int* prot, int* type, uintptr_t* end);
using namespace Core::Bbsc::PatchTables;
// One line in this run's BBSC timeline (bloodborne_re.cpp, "Timeline").
#define BBSC_TIMELINE(kind, ...) Core::Bloodborne::BbscTimelineEvent(kind, fmt::format(__VA_ARGS__))
// Keep it equal to "version" in mod.json and to the Windows mod it plays with.
#define BBSC_VERSION "0.0.2"
namespace BbscMod {
void LogPlain(const char* text);
u64 EbootAddress();
void StartRelay();
void StartP2PChannel(); // bbsc_channel.cpp
bool GameServerIsPublic(std::string* host_out); // bbsc_relay.cpp
void InstallHttpWrappers();
} // namespace BbscMod
namespace {
constexpr u64 ForkEbootBase = 0x800000000ULL; // bbport and the shadp2p fork
u64 EbootBase = 0;
constexpr u64 EbootSize = 0x056D4000ULL; // CUSA03173 01.09 image size
// The image's code (its first PT_LOAD): made writable as shadPS4 maps it, since the shared hook
// code writes some code bytes directly.
constexpr u64 EbootCodeSize = 0x050DC000ULL;
constexpr DWORD WaitForImageMs = 180'000;
constexpr DWORD SettleMs = 20;
constexpr DWORD WatchMs = 500;
std::filesystem::path mod_dir; // where libbbsc.so and its LICENSE/NOTICE/SUPPORTERS are
std::filesystem::path data_dir; // <user dir>/bbsc: logs, captures
FILE* log_file = nullptr;
std::mutex log_mutex;
// The newest `keep` files of a folder with this extension stay; older ones (earlier game starts)
// go. Each start writes a log and a capture of its own.
void KeepNewest(const std::filesystem::path& folder, const char* extension, size_t keep) {
std::error_code ignored;
std::vector<std::filesystem::directory_entry> files;
for (const auto& entry : std::filesystem::directory_iterator(folder, ignored)) {
if (entry.is_regular_file(ignored) && entry.path().extension() == extension) {
files.push_back(entry);
}
}
if (files.size() <= keep) {
return;
}
std::sort(files.begin(), files.end(), [](const auto& a, const auto& b) {
std::error_code e;
return a.last_write_time(e) > b.last_write_time(e);
});
for (size_t i = keep; i < files.size(); ++i) {
std::filesystem::remove(files[i].path(), ignored);
}
}
void OpenLog() {
Dl_info info{};
if (dladdr(reinterpret_cast<void*>(&OpenLog), &info) != 0 && info.dli_fname != nullptr) {
mod_dir = std::filesystem::path{info.dli_fname}.parent_path();
}
// The game's user folder (run.sh exports it as BB_GPU_USER_DIR). Not the working directory's
// "user": in the AppImage that is inside its read-only mount, and BBSC installs none of its
// hooks without a capture folder (InstallReverseEngineeringTrace) - with its death patch
// still in, a death then never reloads. The temporary folder when the user folder is not
// writable either.
const char* user = std::getenv("BB_USER_DIR");
if (user == nullptr || *user == '\0') {
user = std::getenv("BB_GPU_USER_DIR");
}
const std::filesystem::path user_dir{user != nullptr && *user != '\0' ? user : "user"};
data_dir = user_dir / "bbsc";
std::error_code ignored;
// BBSC looks for its saves in shadPS4's layout under the US id (user/home/<user>/savedata/
// CUSA00207/SPRJ0005); bbport keeps them in user/savedata/<user>/<the game's id>/SPRJ0005 (EU
// CUSA00900 and the others). Without them its kit is never given (it backs the saves up first)
// and the own-world, loot and progress features have no save slot: point BBSC_SAVE_DIR, BBSC's
// own setting for a save folder elsewhere, at the most recently written one.
if (const char* set = std::getenv("BBSC_SAVE_DIR"); set == nullptr || *set == '\0') {
std::filesystem::path newest;
std::filesystem::file_time_type newest_time{};
try { // a directory walk's ++ throws even with an error_code
for (const auto& owner : std::filesystem::directory_iterator(user_dir / "savedata", ignored)) {
for (const auto& title : std::filesystem::directory_iterator(owner.path(), ignored)) {
const auto dir = title.path() / "SPRJ0005";
const auto written = std::filesystem::last_write_time(dir, ignored);
if (!ignored && (newest.empty() || written > newest_time)) {
newest = dir;
newest_time = written;
}
ignored.clear();
}
ignored.clear();
}
} catch (const std::exception&) {
}
ignored.clear();
// bbport: a first start has no saves yet, and the kit was then never given: the folder
// the game will write (user 1, src/runtime_services.c; BB_SAVEDATA_TITLE from the runtime).
const char* title = std::getenv("BB_SAVEDATA_TITLE");
if (newest.empty() && title != nullptr && *title != '\0' && std::strcmp(title, "UNKNOWN") != 0) {
newest = user_dir / "savedata" / "1" / title / "SPRJ0005";
}
if (!newest.empty()) {
setenv("BBSC_SAVE_DIR", newest.c_str(), 1);
}
}
std::filesystem::create_directories(data_dir / "captures" / "bloodborne-re", ignored);
if (ignored) {
data_dir = std::filesystem::temp_directory_path(ignored) / "bbport-bbsc";
std::filesystem::create_directories(data_dir / "captures" / "bloodborne-re", ignored);
}
std::filesystem::create_directories(data_dir / "logs", ignored);
KeepNewest(data_dir / "logs", ".log", 4); // with this one, 5
KeepNewest(data_dir / "logs", ".txt", 4); // BBSC 0.0.2's timelines (bbsc-timeline-*.txt)
KeepNewest(data_dir / "captures" / "bloodborne-re", ".jsonl", 4);
const std::time_t now = std::time(nullptr);
std::tm local{};
localtime_r(&now, &local);
char name[96];
std::snprintf(name, sizeof(name), "bbsc-mod-%04d%02d%02d-%02d%02d%02d-%d.log",
local.tm_year + 1900, local.tm_mon + 1, local.tm_mday, local.tm_hour,
local.tm_min, local.tm_sec, static_cast<int>(getpid()));
log_file = std::fopen((data_dir / "logs" / name).c_str(), "w");
std::printf("BBSC: log %s\n", (data_dir / "logs" / name).c_str());
}
// ---- Memory --------------------------------------------------------------------------------------
// The game's own memory is in bbport's map; anything else (the image, host memory a game pointer
// should never reach) is checked with mincore, which fails on an unmapped page.
bool HostMapped(u64 address, size_t size) {
const u64 page = static_cast<u64>(sysconf(_SC_PAGESIZE));
const u64 first = address & ~(page - 1);
const u64 last = (address + size - 1) & ~(page - 1);
std::vector<unsigned char> pages((last - first) / page + 1);
return mincore(reinterpret_cast<void*>(first), last - first + page, pages.data()) == 0;
}
bool Readable(u64 address, size_t size) {
if (address == 0 || address + size < address) {
return false;
}
if (EbootBase != 0 && address >= EbootBase && address + size <= EbootBase + EbootSize) {
return true;
}
u64 at = address;
const u64 end = address + size;
while (at < end) {
int prot = 0, type = 0;
uintptr_t region_end = 0;
if (!runtime_memory_vma_info(at, &prot, &type, &region_end)) {
return HostMapped(at, end - at);
}
if ((prot & 1) == 0) { // PS4 CPU read
return false;
}
at = region_end;
}
return true;
}
bool MatchesAt(u64 base, u64 offset, const u8* bytes, size_t size) {
const u64 address = base + offset;
return Readable(address, size) &&
std::memcmp(reinterpret_cast<const void*>(address), bytes, size) == 0;
}
bool Matches(u64 offset, const u8* bytes, size_t size) {
const u64 address = EbootBase + offset;
return Readable(address, size) &&
std::memcmp(reinterpret_cast<const void*>(address), bytes, size) == 0;
}
void Write(u64 offset, const u8* bytes, size_t size) {
void* address = reinterpret_cast<void*>(EbootBase + offset);
DWORD old = 0;
if (!VirtualProtect(address, size, PAGE_EXECUTE_READWRITE, &old)) {
return;
}
std::memcpy(address, bytes, size);
}
// ---- Byte patches (bbsc_mod.cpp, unchanged) ------------------------------------------------------
struct Site {
const char* name;
u64 offset;
std::array<u8, 6> original{};
std::array<u8, 6> patched{};
size_t size;
Group group;
};
template <size_t N>
std::array<u8, 6> Pad(const std::array<u8, N>& bytes) {
std::array<u8, 6> out{};
std::memcpy(out.data(), bytes.data(), N);
return out;
}
constexpr size_t SiteCount =
Retargets.size() + EntryReturns.size() + ByteEdits.size() + WideEdits.size() + EntryJumps.size();
std::array<Site, SiteCount> sites{};
std::mutex patch_mutex;
bool patches_ready = false;
bool session_on = true; // session groups: off while a suspension runs
bool role_on = false; // role groups (lamp): on while hosting
void BuildSites() {
size_t i = 0;
for (const auto& e : Retargets) {
sites[i++] = {e.name, e.site, Pad(EncodeRel32(e.opcode, e.site, e.original)),
Pad(EncodeRel32(e.opcode, e.site, e.target)), 5, e.group};
}
for (const auto& e : EntryReturns) {
std::array<u8, 6> ret{};
ret[0] = 0xC3;
sites[i++] = {e.name, e.site, e.prologue, ret, 1, e.group};
}
for (const auto& e : ByteEdits) {
sites[i++] = {e.name, e.site, Pad(e.original), Pad(e.replacement), 2, e.group};
}
for (const auto& e : WideEdits) {
sites[i++] = {e.name, e.site, Pad(e.original), Pad(e.replacement), 4, e.group};
}
for (const auto& e : EntryJumps) {
sites[i++] = {e.name, e.site, Pad(e.prologue), Pad(EncodeRel32(0xE9, e.site, e.target)), 5,
e.group};
}
}
bool AllSitesKnown(const Site** first_bad) {
for (const Site& s : sites) {
if (!GroupEnabled(s.group)) {
continue;
}
if (s.offset + s.size > EbootSize ||
(!Matches(s.offset, s.original.data(), s.size) &&
!Matches(s.offset, s.patched.data(), s.size))) {
*first_bad = &s;
return false;
}
}
return true;
}
bool Wanted(const Site& s) {
if (!GroupEnabled(s.group)) {
return false;
}
if (IsRoleGroup(s.group)) {
return role_on;
}
if (IsSessionGroup(s.group)) {
return session_on;
}
return true;
}
int SyncSites() {
int count = 0;
for (const Site& s : sites) {
if (!GroupEnabled(s.group)) {
continue;
}
const auto& want = Wanted(s) ? s.patched : s.original;
if (!Matches(s.offset, want.data(), s.size)) {
Write(s.offset, want.data(), s.size);
++count;
}
}
return count;
}
// ---- Start-up ------------------------------------------------------------------------------------
// BBSC's license, notice and supporters travel with it (next to libbbsc.so); missing or changed,
// BBSC stays off and says why, as the Windows mod does.
bool NoticesPresent() {
const auto holds = [](const char* name, const char* text) {
FILE* file = std::fopen((mod_dir / name).c_str(), "rb");
if (file == nullptr) {
return false;
}
std::string content(64 * 1024, '\0');
content.resize(std::fread(content.data(), 1, content.size(), file));
std::fclose(file);
return content.find(text) != std::string::npos;
};
return holds("LICENSE.txt", "GNU GENERAL PUBLIC LICENSE") &&
holds("NOTICE.txt", "github.com/Classmg22/BBSC") &&
holds("SUPPORTERS.txt", "BBSC SUPPORTERS");
}
DWORD WINAPI Worker(void*) {
OpenLog();
BbscMod::LogPlain("BBSC mod loaded - version " BBSC_VERSION " (bbport, Linux; built " __DATE__
" " __TIME__ ")");
if (!NoticesPresent()) {
LOG_ERROR(Mod, "LICENSE.txt / NOTICE.txt / SUPPORTERS.txt missing or changed in {} - BBSC "
"is off",
mod_dir.string());
BBSC_TIMELINE("START", "LICENSE.txt / NOTICE.txt / SUPPORTERS.txt missing or changed in {} "
"- BBSC is off",
mod_dir.string());
return 0;
}
LOG_INFO(Mod, "BBSC mod in pid {}", static_cast<int>(getpid()));
if (const char* saves = std::getenv("BBSC_SAVE_DIR"); saves != nullptr && *saves != '\0') {
LOG_INFO(Mod, "Saves: {}", saves);
} else {
LOG_WARNING(Mod, "Saves: none found - no co-op kit, own world or shared progress");
}
{
std::string server;
if (BbscMod::GameServerIsPublic(&server)) {
setenv("BBSC_MOD_SEAMLESS", "0", 1);
setenv("SHADPS4_BBSC_FIVE_HUNTERS", "0", 1);
LOG_INFO(Mod, "Public game server ({}): vanilla summons - seamless summon patches and the "
"5-hunter search off (the session itself stays seamless)",
server);
BBSC_TIMELINE("START", "Public game server ({}): vanilla summons - seamless summon "
"patches and the 5-hunter search off (the session itself stays "
"seamless)",
server);
}
}
BuildSites();
// bbport loads the game at the fork's address before any of its code runs.
EbootBase = ForkEbootBase;
const DWORD started = GetTickCount();
if (mprotect(reinterpret_cast<void*>(EbootBase), EbootCodeSize,
PROT_READ | PROT_WRITE | PROT_EXEC) == 0) {
LOG_INFO(Mod, "Game code made writable, as shadPS4 maps it");
}
const Site* bad = nullptr;
while (!AllSitesKnown(&bad)) {
if (GetTickCount() - started > WaitForImageMs) {
LOG_ERROR(Mod,
"No Bloodborne CUSA03173 01.09 image after {} s (first unknown site: {} at "
"{:#x}) - not this game or not this version; nothing was changed",
WaitForImageMs / 1000, bad ? bad->name : "?", bad ? bad->offset : 0);
BBSC_TIMELINE("START", "No Bloodborne CUSA03173 01.09 image after {} s - not this game "
"or not this version; nothing was changed",
WaitForImageMs / 1000);
return 0;
}
Sleep(1);
}
Sleep(SettleMs);
const DWORD verified = GetTickCount() - started;
MemoryPatcher::g_eboot_address = EbootBase;
MemoryPatcher::g_eboot_image_size = EbootSize;
MemoryPatcher::g_game_serial = "CUSA03173";
// Wozzardman's seamless patches first: one of them (0x019471B1) is also in BBSC's list, and it
// checks for the original bytes.
Core::Bloodborne::InstallSeamlessCoopPatches();
int written = 0;
{
std::scoped_lock lock{patch_mutex};
patches_ready = true;
written = SyncSites();
}
LOG_INFO(Mod, "Image verified after {} ms: {} byte patch sites written", verified, written);
BBSC_TIMELINE("START", "Image verified after {} ms: {} byte patch sites written", verified, written);
Core::Bloodborne::InstallReverseEngineeringTrace();
BbscMod::StartRelay();
BbscMod::StartP2PChannel();
BbscMod::InstallHttpWrappers();
LOG_INFO(Mod, "BBSC mod ready after {} ms", GetTickCount() - started);
for (;;) {
Sleep(WatchMs);
std::scoped_lock lock{patch_mutex};
if (const int redone = SyncSites(); redone != 0) {
LOG_WARNING(Mod, "{} patch site(s) had changed back - written again", redone);
}
}
}
} // namespace
// ---- Logging -------------------------------------------------------------------------------------
namespace BbscMod {
void LogLine(char level, const char* category, const std::string& text) {
std::scoped_lock lock{log_mutex};
// The game's log gets the mod's own lines and every warning or error.
if (level == 'W' || level == 'E' || std::strcmp(category, "Mod") == 0) {
std::printf("BBSC: %c %s: %s\n", level, category, text.c_str());
}
if (log_file == nullptr) {
return;
}
timespec now{};
clock_gettime(CLOCK_REALTIME, &now);
std::tm local{};
localtime_r(&now.tv_sec, &local);
std::fprintf(log_file, "[%02d:%02d:%02d.%03ld] %c %s: %s\n", local.tm_hour, local.tm_min,
local.tm_sec, now.tv_nsec / 1'000'000, level, category, text.c_str());
std::fflush(log_file);
}
void LogPlain(const char* text) {
LogLine('I', "Mod", text);
}
u64 EbootAddress() {
return EbootBase;
}
// BBSC 0.0.2's safe import slot reads and writes (bbsc_http.cpp, bbsc_channel.cpp): on Windows a
// slot page could be unreadable while the emulator linked the imports. Here the slot is only
// touched while bbport's map says it is readable, as everything else this library reads.
bool ReadSlotSafely(u64 address, u64* value) {
if (!Readable(address, sizeof(u64))) {
return false;
}
*value = *reinterpret_cast<const volatile u64*>(address);
return true;
}
bool WriteSlotSafely(u64 address, u64 value) {
if (!Readable(address, sizeof(u64))) {
return false;
}
DWORD old = 0;
if (!VirtualProtect(reinterpret_cast<void*>(address), 8, PAGE_READWRITE, &old)) {
return false;
}
*reinterpret_cast<volatile u64*>(address) = value;
return true;
}
// Import slots swapped for wrappers (bbsc_http.cpp, bbsc_channel.cpp): put back if anything links
// them again (bbport links the imports once, before the game runs; kept as the Windows mod has it).
namespace {
struct GuardedSlot {
u64 slot_offset;
u64 wrapper;
void** original;
const char* name;
int rewritten = 0;
};
std::mutex guarded_mutex;
std::vector<GuardedSlot> guarded_slots;
DWORD WINAPI SlotGuardThread(void*) {
for (;;) {
Sleep(5);
std::scoped_lock lock{guarded_mutex};
for (auto& guarded : guarded_slots) {
const u64 slot = EbootBase + guarded.slot_offset;
u64 value = 0;
if (!ReadSlotSafely(slot, &value)) {
continue;
}
if (value == guarded.wrapper || value == 0 ||
(value >= EbootBase && value < EbootBase + EbootSize)) {
continue;
}
*reinterpret_cast<void* volatile*>(guarded.original) = reinterpret_cast<void*>(value);
if (!WriteSlotSafely(slot, guarded.wrapper)) {
continue; // the next round tries again
}
if (++guarded.rewritten <= 3) {
LOG_INFO(Mod, "Import {} was linked again - wrapper put back ({}x)", guarded.name,
guarded.rewritten);
}
}
}
}
} // namespace
void GuardSlot(u64 slot_offset, u64 wrapper, void** original, const char* name) {
std::scoped_lock lock{guarded_mutex};
if (guarded_slots.empty()) {
CreateThread(nullptr, 0, SlotGuardThread, nullptr, 0, nullptr);
}
guarded_slots.push_back({slot_offset, wrapper, original, name});
}
void StartDiscordStatus() {} // bbport has its own Discord status
} // namespace BbscMod
// ---- What bloodborne_re.cpp asks the emulator for ----------------------------------------------
namespace Core {
bool MemoryManager::IsValidMapping(u64 address, u64 size) {
return Readable(address, size == 0 ? 1 : size);
}
// The PS4 protection bits (1 read, 2 write, 4 execute) of the region at address. bbport's map
// gives where the region ends; its start is reported as the address's own page.
s32 MemoryManager::QueryProtection(u64 address, void** start, void** end, u32* prot) {
const u64 page = static_cast<u64>(sysconf(_SC_PAGESIZE));
if (EbootBase != 0 && address >= EbootBase && address < EbootBase + EbootSize) {
*start = reinterpret_cast<void*>(EbootBase);
*end = reinterpret_cast<void*>(EbootBase + EbootSize);
*prot = address < EbootBase + EbootCodeSize ? 7u : 3u;
return 0;
}
int region_prot = 0, type = 0;
uintptr_t region_end = 0;
if (!runtime_memory_vma_info(address, &region_prot, &type, &region_end)) {
return -1;
}
*start = reinterpret_cast<void*>(address & ~(page - 1));
*end = reinterpret_cast<void*>(region_end);
*prot = static_cast<u32>(region_prot) & 7u;
return 0;
}
namespace Debugger {
int GetCurrentPid() {
return static_cast<int>(getpid());
}
} // namespace Debugger
namespace Bbsc {
void InstallPersistentSessionPatches() {} // applied by Worker
void SetPersistentSessionPatches(bool enabled) {
std::scoped_lock lock{patch_mutex};
if (!patches_ready || session_on == enabled) {
return;
}
session_on = enabled;
const int count = SyncSites();
LOG_INFO(Debug, "BBSC persistence {} ({} session sites; echoes, lamp, fog walls and deaths "
"untouched)",
enabled ? "re-applied" : "suspended", count);
}
void SetHostLampPatches(bool hosting) {
std::scoped_lock lock{patch_mutex};
if (!patches_ready || role_on == hosting) {
return;
}
role_on = hosting;
const int count = SyncSites();
LOG_INFO(Debug, "BBSC lamp: {} ({} sites)",
hosting ? "prompt allowed (hosting a session)"
: "vanilla gates (solo or guest - a guest never gets the lamp)",
count);
}
// ---- Watch camera (BBSC 0.0.2) -------------------------------------------------------------------
// The F1 free camera is not in the Linux build: the Windows mod reads the keyboard and mouse for it
// and hands the game a controller at rest, while here every key and button also reaches the game
// through bbport's own pad (src/runtime_pad.c: WASD move, E / Q / F / Tab are game buttons) and
// L3+R3 opens bbport's menu - flying the camera would move the hunter too. BBSC 0.0.2's watch
// camera is different: it only comes on by itself while our hunter is dead in co-op (bloodborne_re.cpp,
// CinematicForceOn after the death has played out), when the game has nothing to do with those
// keys. So that camera is here, read through SDL like the F8 key (the game's window already uses
// it), on the game thread, and only while the camera is on:
// Tab / E / left click next hunter, Shift+Tab / Q previous, F fly freely and back; the
// controller's R1 / L1 and right stick (left stick: closer / farther); arrow keys turn, W / S
// closer / farther; flying freely: WASD, Space / E up, C / Q down, Shift fast, Alt slow.
// F1 turns it off for the rest of that death (it is bbport's "let the mouse go" key too).
// Mouse movement is not read: SDL's relative mouse state is bbport's own mouse look.
namespace {
void* WatchSdl(const char* name) {
return dlsym(RTLD_DEFAULT, name);
}
// SDL3 scancodes and gamepad enums (SDL_scancode.h, SDL_gamepad.h).
enum : int {
ScA = 4, ScC = 6, ScD = 7, ScE = 8, ScF = 9, ScQ = 20, ScS = 22, ScW = 26, ScTab = 43,
ScSpace = 44, ScF1 = 58, ScRight = 79, ScLeft = 80, ScDown = 81, ScUp = 82, ScLShift = 225,
ScLAlt = 226, ScRShift = 229, ScRAlt = 230, ScCount = 231
};
constexpr int PadL1 = 9, PadR1 = 10; // SDL_GAMEPAD_BUTTON_LEFT / RIGHT_SHOULDER
constexpr int AxisLY = 1, AxisRX = 2, AxisRY = 3; // SDL_GAMEPAD_AXIS_*
std::mutex watch_mutex;
bool watch_on = false; // switched on by the game (a death in co-op), off by F1 or respawn
bool watch_watching = false; // the game says it is watching a hunter
std::array<bool, ScCount> watch_keys_before{};
bool watch_click_before = false;
bool watch_l1_before = false, watch_r1_before = false;
float WatchAxis(int value) {
const float v = static_cast<float>(value) / 32767.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);
}
// The first controller the game's runtime has open (SDL_GetGamepadFromID gives only open ones).
void* WatchGamepad() {
using List = unsigned* (*)(int*);
using FromId = void* (*)(unsigned);
using Free = void (*)(void*);
static const auto list = reinterpret_cast<List>(WatchSdl("SDL_GetGamepads"));
static const auto from_id = reinterpret_cast<FromId>(WatchSdl("SDL_GetGamepadFromID"));
static const auto release = reinterpret_cast<Free>(WatchSdl("SDL_free"));
if (list == nullptr || from_id == nullptr || release == nullptr) {
return nullptr;
}
int count = 0;
unsigned* ids = list(&count);
void* pad = nullptr;
for (int i = 0; ids != nullptr && i < count && pad == nullptr; ++i) {
pad = from_id(ids[i]);
}
release(ids);
return pad;
}
} // namespace
CinematicInput TakeCinematicInput() {
std::scoped_lock lock{watch_mutex};
CinematicInput in{};
if (!watch_on) {
return in;
}
in.enabled = true;
using KeyState = const bool* (*)(int*);
using Focus = void* (*)();
using MouseState = unsigned (*)(float*, float*);
static const auto key_state = reinterpret_cast<KeyState>(WatchSdl("SDL_GetKeyboardState"));
static const auto focus = reinterpret_cast<Focus>(WatchSdl("SDL_GetKeyboardFocus"));
static const auto mouse_state = reinterpret_cast<MouseState>(WatchSdl("SDL_GetMouseState"));
int count = 0;
const bool* keys = key_state != nullptr && focus != nullptr && focus() != nullptr
? key_state(&count)
: nullptr;
std::array<bool, ScCount> now{};
for (int i = 0; keys != nullptr && i < ScCount && i < count; ++i) {
now[i] = keys[i];
}
const auto pressed = [&](int key) { return now[key] && !watch_keys_before[key]; };
if (pressed(ScF1)) {
watch_on = false;
watch_watching = false;
watch_keys_before = now;
LOG_INFO(Debug, "BBSC watch camera: off (F1)");
return CinematicInput{};
}
const bool shift = now[ScLShift] || now[ScRShift];
in.switch_target = (pressed(ScTab) ? (shift ? -1 : 1) : 0) + (pressed(ScE) ? 1 : 0) -
(pressed(ScQ) ? 1 : 0);
in.toggle_free = pressed(ScF);
const bool click = keys != nullptr && mouse_state != nullptr &&
(mouse_state(nullptr, nullptr) & 1u) != 0; // SDL_BUTTON_LMASK
in.switch_target += click && !watch_click_before ? 1 : 0;
watch_click_before = click;
in.forward = now[ScW];
in.back = now[ScS];
in.left = now[ScA];
in.right = now[ScD];
in.up = now[ScSpace] || (now[ScE] && !watch_watching);
in.down = now[ScC] || (now[ScQ] && !watch_watching);
in.fast = shift;
in.slow = now[ScLAlt] || now[ScRAlt];
in.look_up = now[ScUp];
in.look_down = now[ScDown];
in.look_left = now[ScLeft];
in.look_right = now[ScRight];
watch_keys_before = now;
using Button = bool (*)(void*, int);
using Axis = short (*)(void*, int);
static const auto button = reinterpret_cast<Button>(WatchSdl("SDL_GetGamepadButton"));
static const auto axis = reinterpret_cast<Axis>(WatchSdl("SDL_GetGamepadAxis"));
void* pad = button != nullptr && axis != nullptr ? WatchGamepad() : nullptr;
if (pad != nullptr) {
const bool l1 = button(pad, PadL1), r1 = button(pad, PadR1);
if (watch_watching) {
in.switch_target += (r1 && !watch_r1_before ? 1 : 0) - (l1 && !watch_l1_before ? 1 : 0);
}
watch_l1_before = l1;
watch_r1_before = r1;
in.pad_look_x = WatchAxis(axis(pad, AxisRX));
in.pad_look_y = -WatchAxis(axis(pad, AxisRY));
in.pad_move_y = -WatchAxis(axis(pad, AxisLY)); // stick up = closer
}
return in;
}
void CinematicForceOff() {
std::scoped_lock lock{watch_mutex};
watch_on = false;
watch_watching = false;
}
void CinematicForceOn() {
if (!Core::Bbsc::SwitchEnabled("SHADPS4_BBSC_CINEMATIC")) {
return;
}
std::scoped_lock lock{watch_mutex};
if (!watch_on) {
// Keys already held (the attack that killed us) do not count as presses.
watch_keys_before.fill(true);
watch_click_before = watch_l1_before = watch_r1_before = true;
}
watch_on = true;
}
void CinematicSetWatching(bool watching) {
std::scoped_lock lock{watch_mutex};
watch_watching = watching;
}
void CinematicPadRead(unsigned, unsigned char, unsigned char, unsigned char, unsigned char) {}
} // namespace Bbsc
} // namespace Core
namespace Common::FS {
std::filesystem::path GetUserPath(PathType type) {
return type == PathType::CapturesDir ? data_dir / "captures" : data_dir;
}
} // namespace Common::FS
namespace Libraries::Np::NpMatching2 {
int BbscReplayRoomMembers(bool) {
return 0; // the online module's room cache is out of reach here
}
} // namespace Libraries::Np::NpMatching2
// bb-probe calls this once the game is loaded, linked and patched, before it runs any of it.
extern "C" __attribute__((visibility("default"))) void bbsc_start(void) {
CreateThread(nullptr, 0, Worker, nullptr, 0, nullptr);
}
+121
View File
@@ -0,0 +1,121 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// bbport: the handful of Win32 and Winsock names the BBSC mod uses, on Linux. BSD sockets under
// their Winsock names (as bbbridge's port), POSIX threads, mprotect, a monotonic clock. Only what
// the mod's files call; anything that differs in meaning is handled in the file itself (#ifdef).
#pragma once
#include <arpa/inet.h>
#include <cerrno>
#include <cstdint>
#include <ctime>
#include <netdb.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <sys/mman.h>
#include <sys/socket.h>
#include <thread>
#include <unistd.h>
using DWORD = std::uint32_t;
using BOOL = int;
using HANDLE = void*;
using LPVOID = void*;
using SOCKET = int;
#ifndef TRUE
#define TRUE 1
#define FALSE 0
#endif
#define WINAPI
#define MAX_PATH 4096
#define INVALID_SOCKET (-1)
#define SOCKET_ERROR (-1)
#define INVALID_HANDLE_VALUE (reinterpret_cast<HANDLE>(-1))
// Memory protection: only the constants the mod passes.
constexpr DWORD PAGE_NOACCESS = 0x01, PAGE_READONLY = 0x02, PAGE_READWRITE = 0x04,
PAGE_EXECUTE = 0x10, PAGE_EXECUTE_READ = 0x20, PAGE_EXECUTE_READWRITE = 0x40;
inline int BbscPosixProt(DWORD protect) {
switch (protect) {
case PAGE_NOACCESS:
return PROT_NONE;
case PAGE_READONLY:
return PROT_READ;
case PAGE_READWRITE:
return PROT_READ | PROT_WRITE;
case PAGE_EXECUTE:
case PAGE_EXECUTE_READ:
return PROT_READ | PROT_EXEC;
default:
return PROT_READ | PROT_WRITE | PROT_EXEC;
}
}
// The pages under [address, address + size) get `protect`. The previous protection is not known
// here: it is reported as read/write/execute, which is what restoring it then sets (the game's
// image is made read/write/execute at start anyway, as shadPS4 maps it).
inline BOOL VirtualProtect(void* address, std::size_t size, DWORD protect, DWORD* old) {
const auto page = static_cast<std::uintptr_t>(sysconf(_SC_PAGESIZE));
const auto start = reinterpret_cast<std::uintptr_t>(address) & ~(page - 1);
const auto end = reinterpret_cast<std::uintptr_t>(address) + size;
if (old != nullptr) {
*old = PAGE_EXECUTE_READWRITE;
}
return mprotect(reinterpret_cast<void*>(start), end - start, BbscPosixProt(protect)) == 0;
}
inline HANDLE GetCurrentProcess() {
return nullptr;
}
// x86: instruction fetches see stores without a flush.
inline BOOL FlushInstructionCache(HANDLE, const void*, std::size_t) {
return TRUE;
}
inline DWORD GetCurrentProcessId() {
return static_cast<DWORD>(getpid());
}
inline std::uint64_t GetTickCount64() {
timespec now{};
clock_gettime(CLOCK_MONOTONIC, &now);
return static_cast<std::uint64_t>(now.tv_sec) * 1000 + static_cast<std::uint64_t>(now.tv_nsec) / 1'000'000;
}
inline DWORD GetTickCount() {
return static_cast<DWORD>(GetTickCount64());
}
inline void Sleep(DWORD ms) {
timespec span{static_cast<time_t>(ms / 1000), static_cast<long>(ms % 1000) * 1'000'000};
while (nanosleep(&span, &span) != 0 && errno == EINTR) {
}
}
// A detached thread running a DWORD WINAPI f(void*); the handle only says it started.
inline HANDLE CreateThread(void*, std::size_t, DWORD (*start)(void*), void* argument, DWORD,
DWORD*) {
try {
std::thread([start, argument] { start(argument); }).detach();
} catch (...) {
return nullptr;
}
return reinterpret_cast<HANDLE>(1);
}
inline BOOL CloseHandle(HANDLE) {
return TRUE;
}
// Winsock.
struct WSADATA {};
#define MAKEWORD(a, b) static_cast<unsigned short>((a) | ((b) << 8))
inline int WSAStartup(unsigned short, WSADATA*) {
return 0;
}
// BBSC 0.0.2's relay: the error of the last socket call, and shutdown's "both ways".
inline int WSAGetLastError() {
return errno;
}
#define SD_BOTH SHUT_RDWR
// shutdown first: a thread blocked in recv on the socket wakes up (close alone leaves it there).
inline int closesocket(SOCKET s) {
shutdown(s, SHUT_RDWR);
return close(s);
}
+44
View File
@@ -0,0 +1,44 @@
{
"id": "bbsc",
"name": "BBSC - Bloodborne Seamless Co-op",
"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"],
"dll": "bbsc.dll",
"enabled_by_default": true,
"settings": [
{"id": "seamless", "name": "Seamless summon", "description": "Summon without the vanilla signs and waits (Wozzardman)", "type": "bool", "default": true, "env": "BBSC_MOD_SEAMLESS"},
{"id": "hooks", "name": "Co-op hooks", "description": "Warps, world sync, Hunter's Dream, messages... (most features below need this)", "type": "bool", "default": true, "env": "BBSC_MOD_HOOKS"},
{"id": "connect_first", "name": "Cross-map summon", "description": "Join first, then one load into the host's map", "type": "bool", "default": true, "env": "SHADPS4_BBSC_CONNECT_FIRST"},
{"id": "warp_together", "name": "Warp together", "description": "The guest follows the host through lamp warps", "type": "bool", "default": true, "env": "SHADPS4_BBSC_WARP_TOGETHER"},
{"id": "world_sync", "name": "World sync", "description": "The guest sees the host's world state (doors, bosses, lifts)", "type": "bool", "default": true, "env": "SHADPS4_BBSC_WORLD_SYNC"},
{"id": "dream", "name": "Hunter's Dream together", "description": "The guest uses the Doll, workshop and messengers", "type": "bool", "default": true, "env": "SHADPS4_BBSC_DREAM"},
{"id": "no_fog_walls", "name": "No co-op fog walls", "description": "Leaving the area is allowed", "type": "bool", "default": true, "env": "SHADPS4_BBSC_NO_FOG_WALLS"},
{"id": "lamp", "name": "Lamps in co-op", "description": "The host can use lamps during a session", "type": "bool", "default": true, "env": "SHADPS4_BBSC_LAMP"},
{"id": "host_dead", "name": "Stay when the host dies", "description": "The guest keeps the session if the host dies", "type": "bool", "default": true, "env": "SHADPS4_BBSC_PERSIST_HOSTDEAD"},
{"id": "spectate", "name": "Respawn in place", "description": "A dead player respawns / spectates instead of leaving", "type": "bool", "default": true, "env": "SHADPS4_BBSC_SPECTATE"},
{"id": "full_echoes", "name": "Full echoes", "description": "A guest is paid the host's rate, not half", "type": "bool", "default": true, "env": "SHADPS4_BBSC_FULL_ECHOES"},
{"id": "no_hp_penalty", "name": "No guest HP cut", "description": "Removes the summoned guest's 30% max HP cut", "type": "bool", "default": true, "env": "SHADPS4_BBSC_NO_HP_PENALTY"},
{"id": "guest_items", "name": "Guest items", "description": "A guest picks up and keeps items", "type": "bool", "default": true, "env": "SHADPS4_BBSC_GUEST_ITEMS"},
{"id": "solid_npcs", "name": "Solid NPCs", "description": "A guest sees NPCs solid, not as grey ghosts", "type": "bool", "default": true, "env": "SHADPS4_BBSC_SOLID_NPCS"},
{"id": "death_penalty", "name": "Bloodstain on death", "description": "A kept death still drops a bloodstain and echoes", "type": "bool", "default": true, "env": "SHADPS4_BBSC_DEATH_PENALTY"},
{"id": "free_bell", "name": "Free Beckoning Bell", "description": "The bell costs no Insight", "type": "bool", "default": true, "env": "SHADPS4_BBSC_FREE_BELL"},
{"id": "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"},
{"id": "floor_guard", "name": "Floor guard", "description": "No crash when a character's floor was unloaded", "type": "bool", "default": true, "env": "SHADPS4_BBSC_FLOOR_GUARD"},
{"id": "scaling", "name": "Enemy scaling", "description": "Enemies and bosses get stronger for every guest (replaces the game's own boss buff)", "type": "bool", "default": true, "env": "SHADPS4_BBSC_SCALING"},
{"id": "scale_npc_hunters", "name": "Scale NPC hunters", "description": "Henryk, Djura and the other hostile hunters also get the enemy scaling (off = vanilla)", "type": "bool", "default": false, "env": "BBSC_SCALE_NPC_HUNTERS"},
{"id": "scale_enemy_hp", "name": "Enemy HP per guest (%)", "description": "Extra health for normal enemies, per guest", "type": "int", "default": 40, "min": 0, "max": 300, "env": "BBSC_SCALE_ENEMY_HP"},
{"id": "scale_boss_hp", "name": "Boss HP per guest (%)", "description": "Extra health for bosses, per guest", "type": "int", "default": 100, "min": 0, "max": 300, "env": "BBSC_SCALE_BOSS_HP"},
{"id": "scale_damage", "name": "Enemy damage per guest (%)", "description": "Extra damage for enemies and bosses, per guest", "type": "int", "default": 15, "min": 0, "max": 200, "env": "BBSC_SCALE_DAMAGE"},
{"id": "scale_enemy_stagger", "name": "Enemy stagger resistance per guest (%)", "description": "Harder to stagger normal enemies, per guest", "type": "int", "default": 15, "min": 0, "max": 200, "env": "BBSC_SCALE_ENEMY_STAGGER"},
{"id": "scale_boss_stagger", "name": "Boss stagger resistance per guest (%)", "description": "Harder to stagger bosses, per guest", "type": "int", "default": 20, "min": 0, "max": 200, "env": "BBSC_SCALE_BOSS_STAGGER"},
{"id": "shared_progress", "name": "Shared progress", "description": "When a guest goes home, bosses, shortcuts, doors and lamps the host has done are added to the guest's own world (adds only, never removes; NPC quests and your character are not touched; saves backed up first)", "type": "bool", "default": true, "env": "SHADPS4_BBSC_SHARED_PROGRESS"},
{"id": "discord", "name": "Discord status", "description": "Shows BBSC, your area, 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"}
]
}
@@ -0,0 +1,15 @@
// BBSC mod: stand-in for the emulator's logging. Same macros, written to bbsc-mod.log.
#pragma once
#include <string>
#include <fmt/format.h>
namespace BbscMod {
void LogLine(char level, const char* category, const std::string& text);
}
#define LOG_TRACE(cat, ...) ::BbscMod::LogLine('T', #cat, fmt::format(__VA_ARGS__))
#define LOG_DEBUG(cat, ...) ::BbscMod::LogLine('D', #cat, fmt::format(__VA_ARGS__))
#define LOG_INFO(cat, ...) ::BbscMod::LogLine('I', #cat, fmt::format(__VA_ARGS__))
#define LOG_WARNING(cat, ...) ::BbscMod::LogLine('W', #cat, fmt::format(__VA_ARGS__))
#define LOG_ERROR(cat, ...) ::BbscMod::LogLine('E', #cat, fmt::format(__VA_ARGS__))
#define LOG_CRITICAL(cat, ...) ::BbscMod::LogLine('C', #cat, fmt::format(__VA_ARGS__))
@@ -0,0 +1,11 @@
// BBSC mod: the three values the emulator's MemoryPatcher knows about the game, set by the DLL
// once it has verified the image (bbsc_mod.cpp).
#pragma once
#include <cstdint>
#include <string>
namespace MemoryPatcher {
inline std::uintptr_t g_eboot_address = 0;
inline std::uint64_t g_eboot_image_size = 0;
inline std::string g_game_serial;
} // namespace MemoryPatcher
+13
View File
@@ -0,0 +1,13 @@
// BBSC mod: paths. The "user" folder is the DLL's own folder.
#pragma once
#include <filesystem>
#include <string>
namespace Common::FS {
enum class PathType { UserDir, LogDir, CapturesDir };
std::filesystem::path GetUserPath(PathType type);
inline std::string PathToUTF8String(const std::filesystem::path& path) {
const auto text = path.u8string();
return std::string(text.begin(), text.end());
}
} // namespace Common::FS
+5
View File
@@ -0,0 +1,5 @@
// BBSC mod: process id for the capture file name.
#pragma once
namespace Core::Debugger {
int GetCurrentPid();
}
@@ -0,0 +1,18 @@
// BBSC mod: the game's param.sfo is not read; the DLL only runs on an image whose patch sites all
// match CUSA03173 01.09, so that is what it reports.
#pragma once
#include <optional>
#include <string_view>
class PSF {
public:
std::optional<std::string_view> GetString(std::string_view key) const {
if (key == "APP_VER") {
return "01.09";
}
if (key == "TITLE_ID") {
return "CUSA03173";
}
return std::nullopt;
}
};
@@ -0,0 +1,5 @@
// BBSC mod: the emulator's room-member replay is not reachable from a mod.
#pragma once
namespace Libraries::Np::NpMatching2 {
int BbscReplayRoomMembers(bool announce_left);
}
@@ -37,7 +37,6 @@ struct SignalingBbscWorldFlags {
};
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;
@@ -0,0 +1,10 @@
// BBSC mod: BBSC's own packets go through the BBSC relay (bbsc_relay.cpp), not the emulator's
// signaling. Same functions, same meaning.
#pragma once
#include "common/types.h"
namespace Libraries::Np::NpSignaling::Helpers {
u32 SendBbscFollowToPeers(u32 packed_map, float x, float y, float z, float heading, u32 sequence,
s32 lamp_point);
u32 SendBbscWorldFlagsToPeers(const void* packet, u32 size);
} // namespace Libraries::Np::NpSignaling::Helpers
+27
View File
@@ -0,0 +1,27 @@
// BBSC mod: stand-in for the emulator's memory manager. The game's memory sits at its guest
// addresses inside the emulator's process, so Windows' VirtualQuery answers what the emulator's
// own map would.
#pragma once
#include "common/enum.h"
#include "common/singleton.h"
#include "common/types.h"
namespace Core {
enum class MemoryProt : u32 {
NoAccess = 0,
CpuRead = 1,
CpuWrite = 2,
CpuReadWrite = 3,
CpuExec = 4,
};
DECLARE_ENUM_FLAG_OPERATORS(MemoryProt)
class MemoryManager {
public:
bool IsValidMapping(u64 address, u64 size = 0);
s32 QueryProtection(u64 address, void** start, void** end, u32* prot);
};
using Memory = Common::Singleton<MemoryManager>;
} // namespace Core
+161
View File
@@ -0,0 +1,161 @@
// SPDX-FileCopyrightText: Copyright 2024 shadPS4 Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <type_traits>
#include "common/types.h"
#define DECLARE_ENUM_FLAG_OPERATORS(type) \
[[nodiscard]] constexpr type operator|(type a, type b) noexcept { \
using T = std::underlying_type_t<type>; \
return static_cast<type>(static_cast<T>(a) | static_cast<T>(b)); \
} \
[[nodiscard]] constexpr type operator&(type a, type b) noexcept { \
using T = std::underlying_type_t<type>; \
return static_cast<type>(static_cast<T>(a) & static_cast<T>(b)); \
} \
[[nodiscard]] constexpr type operator^(type a, type b) noexcept { \
using T = std::underlying_type_t<type>; \
return static_cast<type>(static_cast<T>(a) ^ static_cast<T>(b)); \
} \
[[nodiscard]] constexpr type operator<<(type a, type b) noexcept { \
using T = std::underlying_type_t<type>; \
return static_cast<type>(static_cast<T>(a) << static_cast<T>(b)); \
} \
[[nodiscard]] constexpr type operator>>(type a, type b) noexcept { \
using T = std::underlying_type_t<type>; \
return static_cast<type>(static_cast<T>(a) >> static_cast<T>(b)); \
} \
constexpr type& operator|=(type& a, type b) noexcept { \
a = a | b; \
return a; \
} \
constexpr type& operator&=(type& a, type b) noexcept { \
a = a & b; \
return a; \
} \
constexpr type& operator^=(type& a, type b) noexcept { \
a = a ^ b; \
return a; \
} \
constexpr type& operator<<=(type& a, type b) noexcept { \
a = a << b; \
return a; \
} \
constexpr type& operator>>=(type& a, type b) noexcept { \
a = a >> b; \
return a; \
} \
[[nodiscard]] constexpr type operator~(type key) noexcept { \
using T = std::underlying_type_t<type>; \
return static_cast<type>(~static_cast<T>(key)); \
} \
[[nodiscard]] constexpr bool True(type key) noexcept { \
using T = std::underlying_type_t<type>; \
return static_cast<T>(key) != 0; \
} \
[[nodiscard]] constexpr bool False(type key) noexcept { \
using T = std::underlying_type_t<type>; \
return static_cast<T>(key) == 0; \
}
namespace Common {
template <typename T>
class Flags {
public:
using IntType = std::underlying_type_t<T>;
Flags() {}
Flags(IntType t) : m_bits(t) {}
template <typename... Tx>
Flags(T f, Tx... fx) {
this->set(f, fx...);
}
template <typename... Tx>
void set(Tx... fx) {
m_bits |= bits(fx...);
}
void set(Flags flags) {
m_bits |= flags.m_bits;
}
template <typename... Tx>
void clr(Tx... fx) {
m_bits &= ~bits(fx...);
}
void clr(Flags flags) {
m_bits &= ~flags.m_bits;
}
template <typename... Tx>
bool any(Tx... fx) const {
return (m_bits & bits(fx...)) != 0;
}
template <typename... Tx>
bool all(Tx... fx) const {
const IntType mask = bits(fx...);
return (m_bits & mask) == mask;
}
bool test(T f) const {
return this->any(f);
}
bool isClear() const {
return m_bits == 0;
}
void clrAll() {
m_bits = 0;
}
u32 raw() const {
return m_bits;
}
Flags operator&(const Flags& other) const {
return Flags(m_bits & other.m_bits);
}
Flags operator|(const Flags& other) const {
return Flags(m_bits | other.m_bits);
}
Flags operator^(const Flags& other) const {
return Flags(m_bits ^ other.m_bits);
}
bool operator==(const Flags& other) const {
return m_bits == other.m_bits;
}
bool operator!=(const Flags& other) const {
return m_bits != other.m_bits;
}
private:
IntType m_bits = 0;
static IntType bit(T f) {
return IntType(1) << static_cast<IntType>(f);
}
template <typename... Tx>
static IntType bits(T f, Tx... fx) {
return bit(f) | bits(fx...);
}
static IntType bits() {
return 0;
}
};
} // namespace Common
+28
View File
@@ -0,0 +1,28 @@
// SPDX-FileCopyrightText: Copyright 2024 shadPS4 Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <memory>
namespace Common {
template <class T>
class Singleton {
public:
static T* Instance() {
if (!m_instance) {
m_instance = std::make_unique<T>();
}
return m_instance.get();
}
protected:
Singleton();
~Singleton();
private:
static inline std::unique_ptr<T> m_instance{};
};
} // namespace Common
+39
View File
@@ -0,0 +1,39 @@
// SPDX-FileCopyrightText: Copyright 2024 shadPS4 Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <array>
#include <cstdint>
using s8 = std::int8_t;
using s16 = std::int16_t;
using s32 = std::int32_t;
using s64 = std::int64_t;
using u8 = std::uint8_t;
using u16 = std::uint16_t;
using u32 = std::uint32_t;
using u64 = std::uint64_t;
using f32 = float;
using f64 = double;
using u128 = std::array<std::uint64_t, 2>;
static_assert(sizeof(u128) == 16, "u128 must be 128 bits wide");
using VAddr = uintptr_t;
using PAddr = uintptr_t;
#define PS4_SYSV_ABI __attribute__((sysv_abi))
// UDLs for memory size values
constexpr unsigned long long operator""_KB(unsigned long long x) {
return 1024ULL * x;
}
constexpr unsigned long long operator""_MB(unsigned long long x) {
return 1024_KB * x;
}
constexpr unsigned long long operator""_GB(unsigned long long x) {
return 1024_MB * x;
}
@@ -5,7 +5,6 @@
#include <array>
#include <cstdlib>
#include <fstream>
#include <mutex>
#include <unordered_map>
@@ -30,56 +29,9 @@ constexpr std::u16string_view BossPlaceholder = u"{boss}";
// second {count}.
constexpr std::array<std::u16string_view, 2> NumberPlaceholders{u"{n}", u"{count}"};
// The language the game shows its text in: bbport.ini game_text_language (one of the game's
// codes, scripts/patches.py), else BB_LANGUAGE (the PS4 system language code). -1: English and
// the others.
int TextLanguageCode(const std::string& code) {
if (code == "eses" || code == "esar") {
return int{Spanish};
}
if (code == "frfr") {
return int{French};
}
if (code == "dede") {
return int{German};
}
if (code == "itit") {
return int{Italian};
}
if (code == "ptbr" || code == "ptpt") {
return int{Portuguese};
}
if (code == "ruru") {
return int{Russian};
}
return -1;
}
std::string IniTextLanguage() {
const char* path = std::getenv("BB_CONFIG");
std::ifstream ini{path != nullptr && *path != '\0' ? path : "bbport.ini"};
std::string line, value;
while (std::getline(ini, line)) {
if (line.rfind("game_text_language", 0) != 0) {
continue;
}
const size_t equals = line.find('=');
if (equals == std::string::npos ||
line.find_first_not_of(" \t", 18) != equals) {
continue;
}
value = line.substr(equals + 1);
value.erase(0, value.find_first_not_of(" \t"));
value.erase(value.find_last_not_of(" \t\r") + 1);
}
return value;
}
// The PS4 system language codes of the game (BB_LANGUAGE): -1 for English and the others.
int GameLanguage() {
static const int language = [] {
if (const std::string code = IniTextLanguage(); !code.empty() && code != "auto") {
return TextLanguageCode(code);
}
const char* value = std::getenv("BB_LANGUAGE");
switch (value != nullptr ? std::atoi(value) : 1) {
case 3: // Spanish (Spain)
@@ -10,8 +10,7 @@
namespace Core::Bbsc {
// `english` in the language the game shows its text in (bbport.ini game_text_language, else
// BB_LANGUAGE, the PS4 system language code), or `english`
// `english` in the game's language (BB_LANGUAGE, the PS4 system language code), or `english`
// itself when there is no translation. A text naming one boss (<?npcNameId@N?>) uses the
// translation of its {boss} form. The pointer stays valid for the whole run.
const char16_t* Text(const char16_t* english);
+154
View File
@@ -0,0 +1,154 @@
// SPDX-FileCopyrightText: Copyright 2026 CLS_AMG
// SPDX-License-Identifier: GPL-2.0-or-later
#include "core/bbsc_memscan.h"
#ifdef _WIN32
#include <windows.h>
#else
// bbport: the Linux search.
#include <cinttypes>
#include <cstdio>
#include <fcntl.h>
#include <sys/uio.h>
#include <unistd.h>
#endif
#include <algorithm>
#include <cstring>
namespace Core::Bbsc {
namespace {
#ifdef _WIN32
// One region. The emulator changes page protection all the time (GPU tracking), so a page that was
// readable a moment ago may fault now: the search runs under SEH and a fault just ends the region.
void SearchRegion(const std::uint8_t* start, std::size_t size, const std::uint8_t* needle,
std::size_t length, std::vector<std::uint64_t>& hits, std::size_t max_hits) {
__try {
const std::uint8_t* end = start + size;
const std::uint8_t* at = start;
while (hits.size() < max_hits && end - at >= static_cast<std::ptrdiff_t>(length)) {
at = static_cast<const std::uint8_t*>(
std::memchr(at, needle[0], static_cast<std::size_t>(end - at) - length + 1));
if (at == nullptr) {
break;
}
if (std::memcmp(at, needle, length) == 0) {
hits.push_back(reinterpret_cast<std::uint64_t>(at));
}
++at;
}
} __except (EXCEPTION_EXECUTE_HANDLER) {
}
}
#else
// bbport: one region, copied in slices through process_vm_readv on this process: a page the GPU
// side just protected, or a region unmapped meanwhile, only fails that read (as the SEH guard on
// Windows) instead of faulting. Consecutive slices overlap by the needle's length.
// process_vm_readv cannot read a driver's mapping (VM_IO / VM_PFNMAP): on AMD the game's memory is a
// GPU dma-buf (gpu/shim/bbport_guest_memory.cpp), and the item tables were never found there
// (issue #9). /proc/self/mem reads those through the driver's own access, so a failed slice is
// read again that way.
void SearchRegion(std::uint64_t start, std::uint64_t end, const std::uint8_t* needle,
std::size_t length, std::vector<std::uint8_t>& buffer, int mem,
std::vector<std::uint64_t>& hits, std::size_t max_hits) {
const pid_t self = getpid();
std::uint64_t at = start;
while (at < end && hits.size() < max_hits) {
const auto want =
static_cast<std::size_t>(std::min<std::uint64_t>(buffer.size(), end - at));
iovec local{buffer.data(), want};
iovec remote{reinterpret_cast<void*>(at), want};
ssize_t got = process_vm_readv(self, &local, 1, &remote, 1, 0);
if (got <= 0 && mem >= 0) {
got = pread(mem, buffer.data(), want, static_cast<off_t>(at));
}
if (got <= 0) {
at = (at & ~std::uint64_t{0xFFF}) + 0x1000; // an unreadable page: the next one
continue;
}
const auto size = static_cast<std::size_t>(got);
const std::uint8_t* data = buffer.data();
const std::uint8_t* scan = data;
while (size >= length && hits.size() < max_hits) {
if (scan > data + size - length) {
break;
}
const auto left = static_cast<std::size_t>(data + size - length + 1 - scan);
scan = static_cast<const std::uint8_t*>(std::memchr(scan, needle[0], left));
if (scan == nullptr) {
break;
}
if (std::memcmp(scan, needle, length) == 0) {
hits.push_back(at + static_cast<std::uint64_t>(scan - data));
}
++scan;
}
at += at + size >= end || size < length ? size : size - length + 1;
}
}
#endif
} // namespace
std::vector<std::uint64_t> FindInMemory(std::span<const std::uint8_t> needle, std::uint64_t lo,
std::uint64_t hi, std::size_t max_hits) {
std::vector<std::uint64_t> hits;
#ifdef _WIN32
if (needle.empty()) {
return hits;
}
constexpr DWORD Readable = PAGE_READONLY | PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READ |
PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY;
std::uint64_t at = lo;
while (at < hi && hits.size() < max_hits) {
MEMORY_BASIC_INFORMATION info{};
if (VirtualQuery(reinterpret_cast<void*>(at), &info, sizeof(info)) == 0) {
break;
}
const auto start = reinterpret_cast<std::uint64_t>(info.BaseAddress);
const std::uint64_t end = start + info.RegionSize;
if (info.State == MEM_COMMIT && (info.Protect & Readable) != 0 &&
(info.Protect & (PAGE_GUARD | PAGE_NOACCESS)) == 0) {
SearchRegion(reinterpret_cast<const std::uint8_t*>(start), info.RegionSize,
needle.data(), needle.size(), hits, max_hits);
}
if (end <= at) {
break;
}
at = end;
}
#else
if (needle.empty()) {
return hits;
}
// The readable mappings in [lo, hi), from /proc/self/maps.
FILE* maps = std::fopen("/proc/self/maps", "r");
if (maps == nullptr) {
return hits;
}
std::vector<std::uint8_t> buffer(std::size_t{1} << 20);
const int mem = open("/proc/self/mem", O_RDONLY | O_CLOEXEC);
char line[512];
while (hits.size() < max_hits && std::fgets(line, sizeof(line), maps) != nullptr) {
std::uint64_t start = 0;
std::uint64_t end = 0;
char prot[5] = {};
if (std::sscanf(line, "%" SCNx64 "-%" SCNx64 " %4s", &start, &end, prot) != 3 ||
prot[0] != 'r' || end <= lo || start >= hi) {
continue;
}
SearchRegion(std::max(start, lo), std::min(end, hi), needle.data(), needle.size(), buffer,
mem, hits, max_hits);
}
if (mem >= 0) {
close(mem);
}
std::fclose(maps);
#endif
return hits;
}
} // namespace Core::Bbsc
File renamed without changes.
@@ -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, 36> 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,7 +46,6 @@ inline constexpr std::array<std::string_view, 36> 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_SIGN_ANY_AREA", // the Kin Bell's sign is posted in any area (0x014B6366)
"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
@@ -1,4 +1,4 @@
// Generated by gpu/bbnet20/bbsc/translations/gen_translations.py from its *.json - do not
// Generated by bbsc/translations/gen_translations.py from bbsc/translations/*.json - do not
// edit. {english, {es, fr, de, it, pt, ru}}
{u"<?joinName?> joined your game.", {u"<?joinName?> se ha unido a tu partida.", u"<?joinName?> a rejoint votre partie.", u"<?joinName?> ist deinem Spiel beigetreten.", u"<?joinName?> si è unito alla tua partita.", u"<?joinName?> entrou no seu jogo.", u"Игрок <?joinName?> вошёл в вашу игру."}},
{u"You joined <?hostName?>'s game.", {u"Te has unido a la partida de <?hostName?>.", u"Vous avez rejoint la partie de <?hostName?>.", u"Du bist dem Spiel von <?hostName?> beigetreten.", u"Ti sei unito alla partita di <?hostName?>.", u"Você entrou no jogo de <?hostName?>.", u"Вы вошли в игру игрока <?hostName?>."}},
@@ -3,6 +3,11 @@
#include "core/bloodborne_re.h"
#ifndef _WIN32
#include <dlfcn.h> // bbport: F8 through SDL
#include <unistd.h>
#endif
#include <algorithm>
#include <array>
#include <atomic>
@@ -17,7 +22,6 @@
#ifdef _WIN32
#include <process.h>
#else
#include <dlfcn.h> // bbport: F8 through SDL
#include <unistd.h>
#endif
#include <fstream>
@@ -51,7 +55,6 @@
#include "core/libraries/np/np_signaling/np_signaling_helpers.h"
#include "core/libraries/np/np_signaling/np_signaling_bbsc.h"
#include "core/memory.h"
#include "core/bbport_bbsc_kit.h" // Teefpyre (SrShadowy fork)
namespace Core::Bloodborne {
namespace {
@@ -153,14 +156,6 @@ constexpr std::array<u8, 8> ResponderBellCommonResult{0x4C, 0x89, 0xF7, 0xE8,
0xF5, 0x9F, 0x34, 0x00};
constexpr std::array<u8, 8> ResponderBellAllowedCommonResult{0xE9, 0xE9, 0x09, 0x00,
0x00, 0x90, 0x90, 0x90};
// The cooperator sign's post (see InstallBbscSignPostAreaPatch).
constexpr u64 SignPostAreaCheckOffset = 0x014B6366;
constexpr std::array<u8, 5> SignPostAreaCheck{0xE8, 0xE5, 0x43, 0x46, 0x00}; // call 0x0191A750
constexpr std::array<u8, 5> SignPostAllowedArea{0xB0, 0x01, 0x90, 0x90, 0x90}; // mov al, 1
constexpr u64 SummonMessageAreaCheckOffset = 0x0187387B;
constexpr std::array<u8, 5> SummonMessageAreaCheck{0xE8, 0xD0, 0x6E, 0x0A, 0x00}; // call 0x0191A750
constexpr u64 SignAreaCheckOffset = 0x0191A750;
constexpr u64 WorldChrManPointerOffset = 0x0553E878;
constexpr u64 ActiveBellAreaComparisonOffset = 0x015068BB;
constexpr std::array<u8, 2> ActiveBellAreaComparison{0x77, 0x4A};
constexpr std::array<u8, 2> ActiveBellSignedAreaComparison{0x7F, 0x4A};
@@ -1027,31 +1022,6 @@ std::string BbscTimelineWhere() {
return fmt::format("{}, {}", role, BbscAreaName(map));
}
// bbport: every game start wrote a timeline and a trace capture of its own, kept for good. The
// newest `keep` files of a folder named `prefix`...`extension` stay; older starts' files go.
void KeepNewestFiles(const std::filesystem::path& folder, std::string_view prefix,
std::string_view extension, std::size_t keep) {
std::error_code ignored;
std::vector<std::filesystem::directory_entry> files;
for (const auto& entry : std::filesystem::directory_iterator(folder, ignored)) {
const std::string name = entry.path().filename().string();
if (entry.is_regular_file(ignored) && name.starts_with(prefix) &&
entry.path().extension() == extension) {
files.push_back(entry);
}
}
if (files.size() <= keep) {
return;
}
std::sort(files.begin(), files.end(), [](const auto& a, const auto& b) {
std::error_code e;
return a.last_write_time(e) > b.last_write_time(e);
});
for (std::size_t i = keep; i < files.size(); ++i) {
std::filesystem::remove(files[i].path(), ignored);
}
}
void BbscTimeline(std::string_view kind, std::string_view text) {
static const bool enabled =
Core::Bbsc::BuiltIn && EnvFlagEnabled("SHADPS4_BLOODBORNE_RE_TRACE") &&
@@ -1083,7 +1053,6 @@ void BbscTimeline(std::string_view kind, std::string_view text) {
#endif
std::error_code ec;
fs::create_directories(dir, ec);
KeepNewestFiles(dir, "bbsc-timeline-", ".txt", 4); // with this one, 5
const fs::path path =
dir / fmt::format("bbsc-timeline-{:04}{:02}{:02}-{:02}{:02}{:02}-{}.txt",
local.tm_year + 1900, local.tm_mon + 1, local.tm_mday,
@@ -4327,9 +4296,9 @@ u64 FindLivingPartnerCharacter() {
// code took another host's position as "the host travelled" (a guest pulled to someone else's map
// behind a black screen). Every frame now carries its sender and the sender's session (the host's
// sender id; bbsc_relay.cpp), and a guest locks onto its own host's sender:
// - the only player on the server who is hosting, or
// - with several, the host whose follow packet puts it in our map within BbscRelayLockMetres of
// the host character in our world (FindRemoteHostCharacter).
// - the host that rang our sign (the aimed placement, bbsc_http.cpp; BBSC 0.0.2), or
// - for a host on a BBSC without it, the one host whose follow packet puts it in our map within
// BbscRelayLockMetres of the host character in our world (FindRemoteHostCharacter).
// The lock is made here on the game tick (game memory is not read on the relay thread) and dropped
// once we have not been a guest for BbscRelayUnlockMs.
constexpr s64 BbscRelayHostFreshMs = 5'000;
@@ -5945,7 +5914,6 @@ std::optional<BbscSaveSlot> BbscActiveSaveSlotUnchecked() {
// As shadPS4 picks its user folder: <working folder>\user when it exists (portable install),
// else %APPDATA%\shadPS4 - never both (another window may play from the other one).
std::vector<fs::path> bases{fs::current_path(ec) / "user"};
if (const char* root = extra_bbsc_save_root(); root != nullptr) { bases.push_back(root); } // [extra: Teefpyre]
#ifdef _WIN32
if (!fs::is_directory(bases.front() / "home", ec) &&
!fs::is_directory(bases.front() / "savedata", ec)) {
@@ -9660,28 +9628,6 @@ void PS4_SYSV_ABI BbscBellCheckHook(u64, const GuestRegisterSnapshot* registers)
ok ? "usable side" : "NOT usable side");
}
// Diagnostics (2026-10-21): the guest got the host's claim and signaling came up, but it never
// joined. SosStatus.Update 0x01872360 pops the next summon message (0x014BB200, rax at
// 0x0187383C), then needs the area check (patched, see InstallBbscSignPostAreaPatch), the matching
// state [0x05540290]+0x124 in {0, 2, 5}, [this+0x1F8] != 0 and the message to parse (0x00C90DD0;
// 0x018739F1 runs only then, right before it goes to the session rules).
void PS4_SYSV_ABI BbscSummonMessageHook(u64, const GuestRegisterSnapshot* registers) {
if (registers == nullptr || registers->rax == 0) {
return;
}
const u64 matching = GetMatchingState();
LOG_INFO(Debug,
"BBSC summon message: {:#x} ({} bytes) - matching state {}, ready flag {} (needs "
"state 0/2/5 and flag 1)",
registers->rax, ReadValue<u32>(registers->rax, 0xB0),
ReadablePointer(matching, 0x128) ? ReadValue<s32>(matching, 0x124) : -99,
ReadValue<u8>(registers->rbx, 0x1F8));
}
void PS4_SYSV_ABI BbscSummonMessageParsedHook(u64, const GuestRegisterSnapshot*) {
LOG_INFO(Debug, "BBSC summon message: parsed - handed to the session rules");
}
void InstallBbscQuitHook() {
if (image_base == 0) {
return;
@@ -9699,17 +9645,6 @@ void InstallBbscQuitHook() {
LOG_ERROR(Debug, "BBSC bell check: failed to hook {:#x}", BellCheckTraceOffset);
}
}
{
static constexpr std::array<u8, 6> MessagePop{0x49, 0x89, 0xC5, 0x4D, 0x85, 0xED};
static constexpr std::array<u8, 6> MessageParsed{0x8B, 0x85, 0x4C, 0xF9, 0xFF, 0xFF};
const bool pop = InstallGuestCodeHook(reinterpret_cast<void*>(image_base + 0x0187383C),
std::span<const u8>{MessagePop}, 0,
BbscSummonMessageHook);
const bool parsed = InstallGuestCodeHook(reinterpret_cast<void*>(image_base + 0x018739F1),
std::span<const u8>{MessageParsed}, 0,
BbscSummonMessageParsedHook);
LOG_INFO(Debug, "BBSC summon message: traced (pop {}, parsed {})", pop, parsed);
}
{
static constexpr std::array<u8, 6> ShopPrologue{0x55, 0x48, 0x89, 0xE5, 0x41, 0x57};
static constexpr std::array<u8, 7> ShopResult{0x45, 0x89, 0x26, 0x49,
@@ -12296,7 +12231,7 @@ extern "C" __declspec(dllimport) void* __stdcall GetForegroundWindow();
extern "C" __declspec(dllimport) unsigned long __stdcall GetWindowThreadProcessId(void* window,
unsigned long* pid);
extern "C" __declspec(dllimport) unsigned long __stdcall GetCurrentProcessId();
#elif !defined(_WIN32)
#elif defined(BBSC_MOD)
// bbport: on Linux, F8 through SDL, which the game's window uses (already in the process; the
// runtime reads its keyboard state from the game's threads too). The foreground window is the
// game's while SDL has a window with keyboard focus.
@@ -12326,7 +12261,7 @@ unsigned long GetCurrentProcessId() {
#endif
void TickBbscUnstuckKey() {
#if (defined(_WIN32) && defined(BBSC_MOD)) || !defined(_WIN32) // the DLL and Linux
#if defined(BBSC_MOD) // the DLL, and bbport's libbbsc.so; the built-in build has no F8
static bool was_down = false;
const bool down = (GetAsyncKeyState(0x77 /* VK_F8 */) & 0x8000) != 0;
const bool pressed = down && !was_down;
@@ -13712,20 +13647,6 @@ u64 BbscParamRow(u64 table, u32 id, size_t size = 8) {
// min_rows / first_id: the table's row count must be above min_rows and its first row id first_id.
std::vector<u64> FindBbscParamTables(std::string_view type, u16 min_rows = 1000,
u32 first_id = 0) {
// [extra: Teefpyre - direct param lookup without memory scanning]
static const BbscParamMemory mem{
&ReadablePointer, &ReadValue<u64>, &ReadValue<s32>,
&ReadValue<u16>, &ReadValue<u32>,
[](u64 table, const char* name, size_t len) {
return ReadGuestAscii(table + 0x0C, 32) == std::string_view(name, len);
}
};
std::vector<u64> direct;
const u64 repo = extra_bbsc_get_solo_param_repo(image_base, &mem);
extra_bbsc_find_param_tables_direct(repo, type.data(), type.size(), min_rows, first_id, &mem, &direct);
if (!direct.empty() || !std::getenv("BB_BBSC_MEMSCAN")) {
return direct;
}
// Several BBSC features need the same very large guest-memory scan. Running those searches in
// parallel made each one take about 75-95 seconds on BBport, delaying the kit until after users
// had already opened the shop. Serialize scans and retain the two stable tables shared by the
@@ -13745,8 +13666,7 @@ std::vector<u64> FindBbscParamTables(std::string_view type, u16 min_rows = 1000,
std::vector<u64> tables;
for (const u64 hit : Core::Bbsc::FindInMemory(needle, BbscScanLow(), 0x10000000000ULL, 16)) {
const u64 table = hit - 0x0C;
if (ReadValue<u16>(table, 0x0A) > min_rows && ReadValue<u32>(table, 0x40) == first_id &&
std::ranges::find(tables, table) == tables.end()) {
if (ReadValue<u16>(table, 0x0A) > min_rows && ReadValue<u32>(table, 0x40) == first_id) {
tables.push_back(table);
}
}
@@ -14014,11 +13934,9 @@ void StartBbscSolidNpcs() {
if (!EnvFlagEnabled("SHADPS4_BBSC_SOLID_NPCS")) {
return;
}
// bbport: a search that gave up after 3 minutes never came back (a slow first start, compiling
// shaders). It keeps looking: every 5 s for those 3 minutes, then every 15 s.
std::thread{[] {
for (int attempt = 0;; ++attempt) {
std::this_thread::sleep_for(std::chrono::seconds(attempt < 36 ? 5 : 15));
for (int attempt = 0; attempt < 36; ++attempt) {
std::this_thread::sleep_for(std::chrono::seconds(5));
const auto tables = FindBbscParamTables("NPC_PARAM_ST");
size_t rows = 0;
size_t cleared = 0;
@@ -14048,11 +13966,8 @@ void StartBbscSolidNpcs() {
tables.size(), rows, cleared);
return;
}
if (attempt == 35) {
LOG_INFO(Debug, "BBSC solid NPCs: NpcParam table not found after 3 minutes - "
"still looking every 15 s");
}
}
LOG_INFO(Debug, "BBSC solid NPCs: NpcParam table not found after 3 minutes");
}}.detach();
}
@@ -14065,19 +13980,16 @@ void StartBbscScaling() {
}
bbsc_scale_state = BbscScaleState::Scanning;
std::thread{[] {
// bbport: as for the solid NPCs, it keeps looking after 3 minutes, every 15 s.
for (int attempt = 0;; ++attempt) {
std::this_thread::sleep_for(std::chrono::seconds(attempt < 36 ? 5 : 15));
for (int attempt = 0; attempt < 36; ++attempt) {
std::this_thread::sleep_for(std::chrono::seconds(5));
PrepareBbscScaling();
if (bbsc_scale_state == BbscScaleState::Ready) {
return;
}
if (attempt == 35) {
LOG_INFO(Debug, "BBSC scaling: param tables not found after 3 minutes - still "
"looking every 15 s");
}
bbsc_scale_state = BbscScaleState::Scanning;
}
bbsc_scale_state = BbscScaleState::Failed;
LOG_INFO(Debug, "BBSC scaling: param tables not found after 3 minutes - no scaling");
}}.detach();
}
// ---- PvP toggle item: the Duel Bell (2026-10-05) ------------------------------------------------
@@ -14306,8 +14218,17 @@ void StartBbscDuelBell() {
"memory scan decides");
}
}}.detach();
// bbport: the item tables are only there once a character is loaded, and the search gave up
// after 3 minutes - a player who stayed longer on the title screen (a first start compiling
// shaders) never got the kit, while the vanilla bells already said to use it. It keeps looking:
// every 5 s for those 3 minutes, then every 15 s.
std::thread{[started_ms] {
extra_bbsc_bell_scan_forever(&bbsc_duel_bell_ready, [] { PrepareBbscDuelBell(); }); // [extra]
for (int attempt = 0; !bbsc_duel_bell_ready; ++attempt) {
if (attempt != 0) {
std::this_thread::sleep_for(std::chrono::seconds(attempt < 36 ? 5 : 15));
}
PrepareBbscDuelBell();
}
if (bbsc_duel_bell_ready) {
BBSC_TIMELINE("KIT", "every bell table copy ready after {} ms",
SteadyNowMs() - started_ms);
@@ -14519,7 +14440,6 @@ bool BackupBbscSavesUnchecked() {
std::vector<fs::path> bases;
std::error_code ec;
bases.push_back(fs::current_path(ec) / "user");
if (const char* root = extra_bbsc_save_root(); root != nullptr) { bases.push_back(root); } // [extra]
#ifdef _WIN32
if (const char* appdata = std::getenv("APPDATA")) {
bases.push_back(fs::path(appdata) / "shadPS4");
@@ -14575,15 +14495,9 @@ bool BackupBbscSavesUnchecked() {
Common::FS::PathToUTF8String(game), Common::FS::PathToUTF8String(target));
}
}
return true; // [extra]
return copied != 0;
}
// bbport: what the kit takes out is journaled per character save, so that the port can give it
// back when BBSC is off (gpu/games/bloodborne_kit_restore: libbbgpu, loaded in every run).
extern "C" void bbport_kit_journal_reset(void);
extern "C" void bbport_kit_journal_item(u32 goods, s32 count, int storage);
extern "C" void bbport_kit_journal_flag(u32 flag);
// The kit every BBSC player gets (the user, 2026-10-05: given when you start playing, even as a new
// player, only once - it checks whether you carry it or have it in storage). Each kit item missing
// from BOTH the carried items and the storage box goes into the carried items. Checked once per
@@ -14605,12 +14519,12 @@ void TickBbscDuelBellGive() {
}
const u64 carried = BbscCarriedInventory();
const u64 storage = BbscStorageInventory();
if (carried == 0) { // [extra: Teefpyre]
if (carried == 0 || storage == 0) {
return;
}
const auto has = [&](u32 id) {
const u32 code = 0x40000000u | id;
return BbscInventoryHas(carried, code) || (storage != 0 && BbscInventoryHas(storage, code)); // [extra: Teefpyre]
return BbscInventoryHas(carried, code) || BbscInventoryHas(storage, code);
};
// Older builds could grant these unused goods IDs before their parameter rows were converted.
// Merely finding the ID then incorrectly accepted a bell that remained cached as a Key Item (or
@@ -14649,9 +14563,6 @@ void TickBbscDuelBellGive() {
missing.push_back(&item);
}
}
if (missing.size() == std::size(BbscKit)) {
bbport_kit_journal_reset(); // bbport: a new kit (a fresh character, or after a restore)
}
// The vanilla items the kit replaces (and the bells' "ringing" forms), carried or stored, are
// taken out (the user: "old items are still there, they are not hidden").
// The Dream's messenger holds out the vanilla Beckoning Bell (event 12101024, item lot 10010,
@@ -14662,7 +14573,6 @@ void TickBbscDuelBellGive() {
ReadValue<u64>(image_base + EventFlagManPointerOffset, 0) != 0 && !BbscEventFlag(6622)) {
using SetEventFlag = u64 PS4_SYSV_ABI (*)(u64 unused, u32 flag, u8 value);
reinterpret_cast<SetEventFlag>(image_base + 0x0132E5A0)(0, 6622, 1);
bbport_kit_journal_flag(6622);
LOG_INFO(Debug, "BBSC kit: the Dream messenger's vanilla Beckoning Bell marked as given "
"(flag 6622) - the kit's Hunt Bell replaces it");
}
@@ -14683,15 +14593,8 @@ void TickBbscDuelBellGive() {
}
for (const u32 id : Retired) {
for (const u64 inventory : {carried, storage}) {
if (inventory != 0 && BbscInventoryHas(inventory, 0x40000000u | id)) { // Teefpyre
const s32 slot = BbscInventorySlot(inventory, 0x40000000u | id);
const u64 entry =
slot >= 0 ? BbscInventorySlotEntry(inventory, static_cast<u32>(slot)) : 0;
const s32 count = entry != 0 ? std::max(ReadValue<s32>(entry, 8), 1) : 1;
if (BbscInventoryHas(inventory, 0x40000000u | id)) {
const bool gone = BbscInventoryRemove(inventory, 0x40000000u | id);
if (gone) {
bbport_kit_journal_item(id, count, inventory == storage);
}
LOG_INFO(Debug, "BBSC kit: vanilla goods {} taken out of the {} ({})", id,
inventory == carried ? "carried items" : "storage box",
gone ? "gone" : "STILL THERE");
@@ -15150,42 +15053,12 @@ void ApplyBbscPvpTeams() {
}
}
// The sign post above, seen from the game: when a sign of ours comes up (SpEffect 9005), what
// the game's own area check would have said. A no is what kept the sign on this PC before.
void TickBbscSignPostCheck() {
static bool was_up = false;
const bool up = BbscLocalEffectNode(9005) != 0;
if (up == was_up) {
return;
}
was_up = up;
const u64 world = ReadValue<u64>(image_base + WorldChrManPointerOffset, 0);
if (!up || !ReadablePointer(world, 0x68)) {
return;
}
using AreaCheck = bool PS4_SYSV_ABI (*)(u64 world_chr_man);
const bool allowed = reinterpret_cast<AreaCheck>(image_base + SignAreaCheckOffset)(world);
const bool patched = EnvFlagEnabled("SHADPS4_BBSC_SIGN_ANY_AREA");
if (allowed) {
LOG_INFO(Debug, "BBSC sign post: sign up in map {:#x}; the game's area check allows it",
GetCurrentPackedMap());
} else {
LOG_WARNING(Debug,
"BBSC sign post: sign up in map {:#x}; the game's area check refuses it "
"(event flags 2405 {}, 2406 {}, 12400160 {}) - {}",
GetCurrentPackedMap(), BbscEventFlag(2405), BbscEventFlag(2406),
BbscEventFlag(12400160),
patched ? "posted anyway" : "not posted (SHADPS4_BBSC_SIGN_ANY_AREA=0)");
}
}
void TickBbscPvp() {
if (image_base == 0) {
return;
}
StartBbscDuelBell();
TickBbscDuelBellGive();
TickBbscSignPostCheck();
TickBbscHostBlank();
TickBbscInvadable();
const s32 state = GetSessionState();
@@ -16383,12 +16256,14 @@ void PS4_SYSV_ABI TraceEntry(u64 tag, const GuestRegisterSnapshot* registers) {
if (!capture_file) {
return;
}
// bbport: one capture stops at 64 MB (a long session wrote about 30 MB an hour).
#if defined(BBSC_MOD) && !defined(_WIN32)
// bbport: one capture stops at 64 MB (a long session wrote ~30 MB an hour).
if (capture_file.tellp() > std::streampos{64 << 20}) {
capture_file.close();
LOG_INFO(Debug, "Bloodborne RE capture stopped at 64 MB");
return;
}
#endif
capture_file << "{\"type\":\"entry\",\"seq\":" << sequence << ",\"time_ms\":" << timestamp
<< ",\"site\":\"" << site.name << "\",\"offset\":\"0x" << std::hex
<< site.offset << std::dec << "\",\"hit\":" << hit << ",\"registers\":{";
@@ -17407,51 +17282,6 @@ void TraceMatching2LeaveRoom(std::uintptr_t return_address, std::uint64_t room_i
room_id, return_address, caller_offset, reload_phase, current_map, requested_map);
}
// ---- The cooperator's sign leaves the PC in any area (2026-10-20) ------------------------------
// Runs of 2026-10-20 (Linux and Windows, both network modules, two players): the Kin Bell rang,
// SpEffect 9005 came on and 0x014B9180 built the sign (local candidate, dirty byte [sign+0xA0] =
// 1), but summon_messenger/create was never sent. The summon manager 0x014B5E10 (every frame from
// FrpgNetMan::Update) clears that byte at 0x014B632A, then asks 0x0191A750(WorldChrMan) - the area
// check the bell's own item check makes, which ResponderBellAreaResult already answers with yes -
// and posts the sign (0x01E90E30, request 0x19) only on a yes; a no is never retried. The same
// answer here: the call becomes mov al, 1 (SignPostAreaCheckOffset).
// The same check refused the summon itself (two players, 2026-10-21): the host found the sign,
// claimed it, the claim came back in the sign's next post and signaling to the host came up, but
// the guest never joined and both sides dropped the connection after 25 s. SosStatus.Update
// 0x01872360 takes the next summon message (0x014BB200), asks 0x0191A750 at 0x0187387B and, on a
// no, throws the message away (0x014BB3D0) instead of handing it to the session rules
// [0x0553D6D0] (0x01789010). Same answer there. Switch SHADPS4_BBSC_SIGN_ANY_AREA (on).
void InstallBbscSignPostAreaPatch(u64 base, u64 image_size) {
static bool done = false;
if (done || !EnvFlagEnabled("SHADPS4_BBSC_SIGN_ANY_AREA") || base == 0) {
return;
}
done = true;
struct AreaCheckSite {
u64 offset;
std::span<const u8> expected;
const char* what;
};
const AreaCheckSite sites[] = {
{SignPostAreaCheckOffset, SignPostAreaCheck, "a cooperator sign is posted"},
{SummonMessageAreaCheckOffset, SummonMessageAreaCheck, "a host's summon is taken"},
};
for (const AreaCheckSite& check : sites) {
if (check.offset + check.expected.size() > image_size) {
continue;
}
auto* site = reinterpret_cast<u8*>(base + check.offset);
const std::span<const u8> actual{site, check.expected.size()};
if (!std::ranges::equal(actual, check.expected)) {
LOG_ERROR(Debug, "BBSC sign post: signature mismatch at {:#x}: expected={} actual={}",
check.offset, BytesToHex(check.expected), BytesToHex(actual));
continue;
}
std::memcpy(site, SignPostAllowedArea.data(), SignPostAllowedArea.size());
LOG_INFO(Debug, "BBSC sign post: {} in any area ({:#x})", check.what, check.offset);
}
}
void InstallSeamlessCoopPatches() {
if ((negative_area_patch_installed && area_flag_patch_installed &&
responder_bell_area_patch_installed && responder_bell_common_patch_installed &&
@@ -17909,7 +17739,6 @@ void InstallSeamlessCoopPatches() {
StageTransitionStopMatchingCallOffset, SummonBuildEntryOffset,
CrossMapGuestHandoffOffset, SosStatusUpdateOffset, SetSummonReloadStateOffset,
HealingFountainAvailabilityOffset);
InstallBbscSignPostAreaPatch(base, image_size);
}
void InstallReverseEngineeringTrace() {
@@ -17945,7 +17774,6 @@ void InstallReverseEngineeringTrace() {
Common::FS::PathToUTF8String(capture_dir), error.message());
return;
}
KeepNewestFiles(capture_dir, "cusa03173-", ".jsonl", 4); // bbport: with this one, 5
const auto timestamp = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch())
@@ -75,6 +75,8 @@ 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
+96
View File
@@ -0,0 +1,96 @@
// SPDX-FileCopyrightText: Copyright 2024 shadPS4 Emulator Project
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <array>
#include <span>
#include "common/types.h"
// ============================================================================
// Windows static guest red-zone protection
// ============================================================================
enum class WindowsGuestRedZoneProtectionMode : u32 {
Disabled,
StaticPatching,
};
namespace Core::WindowsGuestRedZoneProtection {
void SetActiveMode(WindowsGuestRedZoneProtectionMode mode) noexcept;
WindowsGuestRedZoneProtectionMode GetActiveMode() noexcept;
bool IsStaticPatchingEnabled() noexcept;
} // namespace Core::WindowsGuestRedZoneProtection
// ============================================================================
// End Windows static guest red-zone protection
// ============================================================================
namespace Core {
struct alignas(16) GuestRegisterSnapshot {
u64 rax;
u64 rbx;
u64 rcx;
u64 rdx;
u64 rsi;
u64 rdi;
u64 rbp;
u64 rsp;
u64 r8;
u64 r9;
u64 r10;
u64 r11;
u64 r12;
u64 r13;
u64 r14;
u64 r15;
u64 rflags;
u32 mxcsr;
alignas(16) std::array<std::array<u8, 32>, 16> ymm;
};
using GuestCodeHook = void PS4_SYSV_ABI (*)(u64 tag, const GuestRegisterSnapshot* registers);
// Windows static guest red-zone protection
struct RedZonePatchResult {
u64 function_count{};
u64 instruction_count{};
u64 red_zone_function_count{};
u64 memory_instruction_count{};
u64 short_memory_instruction_count{};
u64 patched_memory_instruction_count{};
u64 stack_dependent_memory_instruction_count{};
u64 control_flow_memory_instruction_count{};
u64 unrelocatable_memory_instruction_count{};
u64 indirect_red_zone_function_count{};
u64 cpu_patch_instruction_count{};
u64 patched_cpu_patch_instruction_count{};
u64 unsupported_cpu_patch_instruction_count{};
};
/// Registers a module for patching, providing an area to generate trampoline code.
void RegisterPatchModule(void* module_ptr, u64 module_size, void* trampoline_area_ptr,
u64 trampoline_area_size);
/// Installs a byte-verified observer while preserving GPR, flags, MXCSR, and YMM state.
/// The copied instruction span must contain whole instructions and must not use relative
/// addressing.
// skip_flag (optional): if the hook sets *skip_flag to non-zero, the trampoline clears it and
// returns straight to the caller instead of running the hooked function (function entry only).
bool InstallGuestCodeHook(void* address, std::span<const u8> expected_instructions, u64 tag,
GuestCodeHook hook,
const u8* skip_flag = nullptr);
/// Applies CPU patches that need to be done before beginning executions.
void PrePatchInstructions(u64 segment_addr, u64 segment_size);
// Windows static guest red-zone protection
/// Keeps Windows exception dispatch outside live guest red zones at faultable memory accesses.
RedZonePatchResult PatchRedZoneMemoryInstructions(u64 segment_addr, u64 segment_size,
std::span<const uintptr_t> function_starts);
} // namespace Core
+66
View File
@@ -0,0 +1,66 @@
# Hosting BBSC for your group
Seamless co-op with everything on (summons across areas, five hunters) needs two servers that
your group shares:
| Server | What it does | Where it comes from |
|---|---|---|
| **shadNet** | Accounts, matchmaking, bells and summons | [shadnet-p2p](https://github.com/Wozzardman/shadnet-p2p) by Wozzardman |
| **BBSC's game server** | The server list, messages, bloodstains and BBSC's relay | [`gameserver/`](gameserver/README.md), in this folder |
Both can run on one always-on computer (a home server, a VPS, or one of the players' PCs) that every
player reaches through [Tailscale](https://tailscale.com), so no port is opened to the Internet.
## Quick start
The full guide, step by step, is [docs/SERVER.md](../../docs/SERVER.md). In short, on the server:
```sh
# shadNet, in seamless mode
docker build -t bbport-shadnet tools/shadnet-server
docker run -d --name shadnet --restart unless-stopped --network host -v shadnet-data:/data \
-e SHADNET_HOST=100.x.y.z -e SHADNET_REGISTRATION_KEY=a-long-code-of-your-own \
-e SHADNET_BLOODBORNE_SEAMLESS_COOP=1 bbport-shadnet
# BBSC's game server, next to it
docker build -t bbport-bbsc-server tools/bbsc-server
docker run -d --name bbsc-server --restart unless-stopped --network host \
-v bbsc-data:/data -e BBSC_HOST=100.x.y.z bbport-bbsc-server
```
`100.x.y.z` is the server's Tailscale address (`tailscale ip -4`). Without Docker, the game server
is one Python file with nothing to install:
```sh
python3 bbsc/server/gameserver/bbsc_gameserver.py --shadnet 100.x.y.z --host 100.x.y.z
```
Then every player, in the launcher's **Multiplayer → Online** tab:
1. chooses **Your own server** with the server's address, and makes an account on it;
2. sets **Game server** to `http://100.x.y.z:20443`;
3. turns on BBSC in the **Seamless co-op (BBSC)** group.
## Ports
| Port | Protocol | Server | Used for |
|---|---|---|---|
| 31313 | TCP | shadNet | Sign-in and matchmaking |
| 31314 | UDP | shadNet | Finding each other's address (STUN) |
| 31315 | TCP | shadNet | Its web API: summon signs |
| 20443 | TCP | Game server | The server list, messages, bloodstains |
| 20444 | TCP | Game server | BBSC's relay: warps, world sync, enemy scaling |
| 18671 | TCP | Game server | Its bbhost-compatible service (`--no-bbhost` turns it off) |
| 3478 | UDP | Game server | Relay for players who cannot reach each other directly |
## What is in this folder
| Path | Contents |
|---|---|
| `gameserver/` | BBSC's game server (`bbsc_gameserver.py`), its `ss.info` template and notice text |
| `server/` | shadNet's settings as BBSC expects them (seamless co-op on, its ports, worlds and scoreboards) |
## Keep private
shadNet's registration key and its account database (the `shadnet-data` volume), and the game
server's data (`bbsc-data`), belong to your group: do not share them or publish them.
Loaded 100 of 1450 files, more files were not shown because too many files have changed in this diff. Show more