mirror of
https://github.com/ayozetr/bloodborne_linux.git
synced 2026-11-09 17:03:24 -10:00
Hunter's Atlas: the game-state readers and lamp travel
First part of Hunter's Atlas by MikeTat1988, a fullscreen in-game map (GPL-2.0-or-later, github.com/MikeTat1988/HuntersAtlas), as the Linux fork has it: 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. - 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 frame pacer entry (0x2034770, not a site 0.7's frame rate hooks touch) and calls the game's own return-point and warp functions on the game thread. - 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. Both test programs run in build.sh --test and pass. The map screen comes next. Co-Authored-By: MikeTat1988 <[email protected]>
This commit is contained in:
1 parent
e2ac43b725
commit
6621a1f0a7
13 files changed
+567
No files matched your search
@@ -126,4 +126,9 @@ if [[ ${1:-} == --test ]]; then
|
||||
out/content-test
|
||||
"$CC" -std=c11 -O2 -g -Wall -Wextra -Werror -pthread -Isrc tests/test_trophy.c src/runtime_trophy.c -o out/trophy-test
|
||||
out/trophy-test out/trophy_test_tmp
|
||||
# Hunter's Atlas: its guarded game-memory readers on synthetic images.
|
||||
"$CC" -std=c11 -O2 -g -Wall -Wextra -Werror -Isrc tests/test_map_location.c src/runtime_map.c -lm -o out/map-location-test
|
||||
out/map-location-test
|
||||
"$CC" -std=c11 -O2 -g -Wall -Wextra -Werror -pthread -Isrc tests/test_atlas_state.c src/runtime_atlas_state.c -lm -o out/atlas-state-test
|
||||
out/atlas-state-test
|
||||
fi
|
||||
@@ -416,6 +416,7 @@ BB_ENV(BB_COPY_FILTER_GATE, TUNING, "=1 with BB_COPY_SKIP_SAME: the copy filter
|
||||
BB_ENV(BB_COPY_WHERE, DEBUG, "=1: the copy shader's sources and destinations per frame, VRAM or in place (log lines)")
|
||||
BB_ENV(BB_VERTEX_IN_PLACE, DEBUG, "=1: vertex data bound in place per frame, by why it has no VRAM copy, and what draws read of it (log lines)")
|
||||
BB_ENV(BB_VERTEX_READ_COPY, TUNING, "=1 (experimental): vertex data bound in place copied into VRAM for the vertices each draw reads (once per guest submission)")
|
||||
BB_ENV(BB_ATLAS_DEBUG, DEBUG, "=1: Hunter's Atlas logs what its game-state readers find")
|
||||
BB_ENV(BB_EXPERIMENT_CYCLE, DEBUG, "=bits,bits,...:seconds: the experiment bits step through the list, over and over (test builds)")
|
||||
BB_ENV(BB_EXP_EXTRA_BREAKS, DEBUG, "=N (experiment): the render pass ended before every Nth draw, to measure breaks")
|
||||
BB_ENV(BB_EXP_FINISH_BEFORE_TARGET, DEBUG, "=hex address (experiment): the GPU finishes everything before a draw into that target")
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
#define BB_MAP_GUARD_OFFSET 0x18db3c0
|
||||
static const unsigned char bb_map_guard[]={0x55,0x48,0x89,0xe5,0x41,0x57,0x41,0x56,0x41,0x55,0x41,0x54,0x53,0x48,0x83,0xec,0x38,0x89,0x55,0xd4,0x41,0x89,0xf7,0x48,0x89,0xfb,0x4c,0x8d,0x35,0x97,0x34,0xc6,0x03,0x49,0x8b,0x0e,0x48,0x85,0xc9,0x75,0x24,0x48,0x8d,0x3d,0x79,0xff,0x05,0x03,0x48,0x8d,0x15,0xc6,0xff,0x05,0x03,0x48,0x8d,0x0d,0x6d,0xfe,0x05,0x03,0xbe,0xb1};
|
||||
@@ -0,0 +1,9 @@
|
||||
/* CUSA00207 1.09 original region predicate: native Cross-confirm layout.
|
||||
* Verified read-only against the current mapped executable and native menu.
|
||||
* Check beyond the function prologue; other predicate patches fail closed. */
|
||||
static const unsigned char bb_map_cross_region_guard[]={
|
||||
0x55,0x48,0x89,0xe5,0x41,0x57,0x41,0x56,
|
||||
0x41,0x54,0x53,0x48,0x83,0xec,0x40,0x4c,
|
||||
0x8b,0x25,0x9a,0xb4,0x42,0x03,0x49,0x8b,
|
||||
0x04,0x24,0x48,0x89,0x45,0xd8,0x8b,0x0d
|
||||
};
|
||||
+11
@@ -12,6 +12,8 @@
|
||||
#include <cpuid.h>
|
||||
#include "runtime.h"
|
||||
#include "runtime_trophy.h"
|
||||
#include "runtime_map.h"
|
||||
#include "runtime_atlas_state.h"
|
||||
#include "gpu/bbgpu.h"
|
||||
#if !defined(__x86_64__) || !defined(__GNUC__)
|
||||
#error This prototype requires x86-64 GCC or Clang (including MinGW).
|
||||
@@ -799,6 +801,15 @@ int main(int argc, char **argv) {
|
||||
runtime_net_image_mapped(image, size); /* online modules that read the original code */
|
||||
if (patch_file) apply_patches(patch_file, segments, ns, relocs, nr);
|
||||
if (!cpu_only) bbgpu_patch_image(image, size);
|
||||
/* Hunter's Atlas (BB_ATLAS_ENABLED=1): its guarded readers, and the opt-in lamp travel hook,
|
||||
* written here while the image is still writable. Off, nothing of the game is touched. */
|
||||
if (!cpu_only) {
|
||||
const char *atlas = bb_env("BB_ATLAS_ENABLED");
|
||||
if (atlas && atlas[0] && strcmp(atlas, "0")) {
|
||||
runtime_map_bind(image, size);
|
||||
runtime_atlas_state_bind(image, size);
|
||||
}
|
||||
}
|
||||
#ifdef _WIN32
|
||||
patch_guest_tls(segments, ns);
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
/* SPDX-License-Identifier: GPL-2.0-or-later */
|
||||
#pragma once
|
||||
#define BB_ATLAS_BUILD_JSON "{\"adapter_protocol\":1,\"upstream_release\":\"windows-v1.8.1\",\"upstream_commit\":\"bee7ec16213022f15bf34084b5587f1ff09e0e65\",\"feature_schema\":1}"
|
||||
@@ -0,0 +1,17 @@
|
||||
/* SPDX-License-Identifier: GPL-2.0-or-later */
|
||||
#ifndef BB_ATLAS_RELEASE_H
|
||||
#define BB_ATLAS_RELEASE_H
|
||||
#include <stdint.h>
|
||||
typedef struct {uint32_t buttons;unsigned axes,triggers;} BbAtlasRelease;
|
||||
static inline int bb_atlas_axis_held(uint8_t value){return value<110 || value>146;}
|
||||
static inline void bb_atlas_remember(BbAtlasRelease *s,uint32_t buttons,const uint8_t axes[4],const uint8_t triggers[2]){
|
||||
s->buttons|=buttons;
|
||||
for(unsigned i=0;i<4;++i)if(bb_atlas_axis_held(axes[i]))s->axes|=1u<<i;
|
||||
for(unsigned i=0;i<2;++i)if(triggers[i]>32)s->triggers|=1u<<i;
|
||||
}
|
||||
static inline void bb_atlas_release_filter(BbAtlasRelease *s,uint32_t raw_buttons,uint32_t *buttons,uint8_t axes[4],uint8_t triggers[2]){
|
||||
s->buttons&=raw_buttons;*buttons&=~s->buttons;
|
||||
for(unsigned i=0;i<4;++i)if(s->axes&(1u<<i)){if(!bb_atlas_axis_held(axes[i]))s->axes&=~(1u<<i);else axes[i]=128;}
|
||||
for(unsigned i=0;i<2;++i)if(s->triggers&(1u<<i)){if(triggers[i]<=32)s->triggers&=~(1u<<i);else triggers[i]=0;}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,201 @@
|
||||
/* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
* Atlas's guarded event-flag observation and explicitly enabled lamp travel.
|
||||
* Layouts and entry bytes verified against the supported local 1.09 image.
|
||||
* Flags are only read. Travel uses the game's normal return-point/loading calls.
|
||||
* From Hunter's Atlas by MikeTat1988 (github.com/MikeTat1988/HuntersAtlas). bbport (Linux): the
|
||||
* reads go through runtime_map_read, the lock is a pthread mutex, and the frame hook is written
|
||||
* while the loader still has the image writable (runtime_atlas_state_bind is called from
|
||||
* bb-probe right after bbgpu_patch_image), so no page protection changes at run time.
|
||||
*/
|
||||
#define _GNU_SOURCE
|
||||
#include "runtime_atlas_state.h"
|
||||
#include "runtime_map.h"
|
||||
#include "../gpu/shim/bb_env.h"
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdio.h>
|
||||
#include <math.h>
|
||||
#include <stdatomic.h>
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#else
|
||||
#include <pthread.h>
|
||||
#include <sys/mman.h>
|
||||
#include <time.h>
|
||||
#endif
|
||||
#define ATLAS_ABI __attribute__((sysv_abi))
|
||||
static uintptr_t base;
|
||||
static int state_ready, travel_ready;
|
||||
static atomic_int travel_status;
|
||||
static BbAtlasTravelDestination pending;
|
||||
static uintptr_t pending_player;
|
||||
static uint64_t request_time;
|
||||
static uint32_t source_block;
|
||||
static int observed_loading;
|
||||
#ifdef _WIN32
|
||||
static SRWLOCK request_lock = SRWLOCK_INIT;
|
||||
#define LOCK() AcquireSRWLockExclusive(&request_lock)
|
||||
#define UNLOCK() ReleaseSRWLockExclusive(&request_lock)
|
||||
#else
|
||||
/* bbport: the Win32 calls of the travel code, on Linux. */
|
||||
static pthread_mutex_t request_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||
#define LOCK() pthread_mutex_lock(&request_lock)
|
||||
#define UNLOCK() pthread_mutex_unlock(&request_lock)
|
||||
static uint64_t GetTickCount64(void) {
|
||||
struct timespec t;
|
||||
clock_gettime(CLOCK_MONOTONIC,&t);
|
||||
return (uint64_t)t.tv_sec*1000u+(uint64_t)t.tv_nsec/1000000u;
|
||||
}
|
||||
#endif
|
||||
static int64_t (ATLAS_ABI *original_frame)(void *);
|
||||
|
||||
static int read_mem(uintptr_t at, void *out, size_t size) {
|
||||
return runtime_map_read(at, out, size); /* bbport: one reader for both platforms */
|
||||
}
|
||||
static uintptr_t ptr(uintptr_t at) { uintptr_t p=0; return read_mem(at,&p,sizeof(p))?p:0; }
|
||||
uintptr_t runtime_atlas_player_token(void) {
|
||||
if(!state_ready) return 0;
|
||||
uintptr_t world=ptr(base+0x553e878);
|
||||
return world?ptr(world+0x60):0;
|
||||
}
|
||||
int runtime_atlas_flag(uint32_t id) {
|
||||
if(!state_ready || id>99999999u) return -1;
|
||||
uintptr_t man=ptr(base+0x553b100),head,node,found;
|
||||
uint32_t bits=0;
|
||||
if(!man || !read_mem(man+0x1c,&bits,4) || bits!=1000) return -1;
|
||||
head=ptr(man+0x38); node=head?ptr(head+8):0; found=head;
|
||||
const uint32_t group=id/bits, bit=id%bits;
|
||||
int stopped=0;
|
||||
for(int depth=0;depth<64;++depth) {
|
||||
unsigned char nil=1; uint32_t key=0;
|
||||
if(!node || !read_mem(node+0x19,&nil,1))return -1;
|
||||
if(nil){stopped=1;break;}
|
||||
if(!read_mem(node+0x20,&key,4))return -1;
|
||||
if(key<group)node=ptr(node+0x10);
|
||||
else {found=node;node=ptr(node);}
|
||||
}
|
||||
uint32_t key=0,kind=0;uintptr_t data=0;
|
||||
if(!stopped || found==head || !read_mem(found+0x20,&key,4) || key!=group || !read_mem(found+0x28,&kind,4))return -1;
|
||||
if(kind==2)data=ptr(found+0x30);
|
||||
else if(kind==1) {
|
||||
uint32_t stride=0,index=0;uintptr_t pool=ptr(man+0x28);
|
||||
if(!pool || !read_mem(man+0x20,&stride,4) || stride<125 || stride>4096 || !read_mem(found+0x30,&index,4))return -1;
|
||||
uint64_t offset=(uint64_t)stride*index;if(offset>UINT32_MAX)return -1;
|
||||
data=pool+(uintptr_t)offset;
|
||||
} else return -1;
|
||||
unsigned char value=0;
|
||||
if(!data || !read_mem(data+(bit>>3),&value,1) || man!=ptr(base+0x553b100))return -1;
|
||||
return (value&(0x80u>>(bit&7)))?1:0;
|
||||
}
|
||||
static int eligible(const BbAtlasTravelDestination *d) {
|
||||
if(d->return_point<=0 || d->on_count>8 || d->off_count>8)return 0;
|
||||
if(d->unlock_flag && runtime_atlas_flag(d->unlock_flag)!=1)return 0;
|
||||
for(unsigned i=0;i<d->on_count;++i)if(runtime_atlas_flag(d->on[i])!=1)return 0;
|
||||
for(unsigned i=0;i<d->off_count;++i)if(runtime_atlas_flag(d->off[i])!=0)return 0;
|
||||
return 1;
|
||||
}
|
||||
int runtime_atlas_travel_supported(void){return travel_ready;}
|
||||
int runtime_atlas_travel_status(void){return atomic_load_explicit(&travel_status,memory_order_acquire);}
|
||||
int runtime_atlas_travel_request(const BbAtlasTravelDestination *d) {
|
||||
BbMapLocation loc={0};
|
||||
BbMapMenu menu={0};
|
||||
if(!travel_ready || !d || !runtime_map_location(&loc) || !runtime_map_menu_state(&menu) || !menu.top_level || !eligible(d))return 0;
|
||||
for(int i=0;i<3;++i)if(!isfinite(d->arrival[i])||fabsf(d->arrival[i])>20000)return 0;
|
||||
LOCK();
|
||||
int status=atomic_load(&travel_status);
|
||||
if(status==1 || status==2){UNLOCK();return 0;}
|
||||
pending=*d;pending_player=runtime_atlas_player_token();request_time=GetTickCount64();source_block=loc.block;observed_loading=0;
|
||||
atomic_store_explicit(&travel_status,1,memory_order_release);
|
||||
UNLOCK();return 1;
|
||||
}
|
||||
static void game_tick(void) {
|
||||
int status=atomic_load_explicit(&travel_status,memory_order_acquire);
|
||||
if(status!=1 && status!=2)return;
|
||||
BbMapLocation loc={0};const int valid=runtime_map_location(&loc);
|
||||
if(status==2) {
|
||||
if(!valid)observed_loading=1;
|
||||
/* Placement may keep the same block; require loading/player replacement
|
||||
* or enough time for the native stage change, then its target block. */
|
||||
const uint32_t block=((uint32_t)(pending.return_point/100000)<<24)|((uint32_t)((pending.return_point/10000)%10)<<16);
|
||||
if(valid && loc.block==block && GetTickCount64()-request_time>1500 &&
|
||||
(observed_loading || loc.block!=source_block || runtime_atlas_player_token()!=pending_player ||
|
||||
(fabsf(loc.x-pending.arrival[0])<20&&fabsf(loc.y-pending.arrival[1])<20&&fabsf(loc.z-pending.arrival[2])<20)))atomic_store(&travel_status,3);
|
||||
else if(GetTickCount64()-request_time>60000)atomic_store(&travel_status,-2);
|
||||
return;
|
||||
}
|
||||
LOCK();
|
||||
if(atomic_load(&travel_status)!=1){UNLOCK();return;}
|
||||
BbMapMenu menu={0};
|
||||
uintptr_t data=ptr(base+0x553b130),record=data?ptr(data+8):0;int32_t hp=0;
|
||||
if(!valid || !record || !read_mem(record+0x14,&hp,4) || hp<=0 ||
|
||||
!runtime_map_menu_state(&menu) || !menu.top_level || runtime_atlas_player_token()!=pending_player ||
|
||||
GetTickCount64()-request_time>3000 || !eligible(&pending)) {
|
||||
atomic_store(&travel_status,-1);UNLOCK();return;
|
||||
}
|
||||
/* These three exact SysV entry points are fingerprinted before installation.
|
||||
* First choose the return point for arrival and the following respawn; then
|
||||
* request the game's normal stage change. No flag setter is called. */
|
||||
typedef int64_t (ATLAS_ABI *SetReturn)(int32_t);
|
||||
typedef int64_t (ATLAS_ABI *UseReturn)(int32_t);
|
||||
typedef int64_t (ATLAS_ABI *Warp)(uintptr_t,int32_t);
|
||||
const int32_t destination=pending.return_point;
|
||||
request_time=GetTickCount64();atomic_store_explicit(&travel_status,2,memory_order_release);
|
||||
UNLOCK();
|
||||
((SetReturn)(base+0x156d110))(destination);
|
||||
((UseReturn)(base+0x156d0e0))(1);
|
||||
((Warp)(base+0x132e050))(0,destination);
|
||||
printf("Atlas: native lamp travel queued to %d\n",destination);
|
||||
}
|
||||
static int64_t ATLAS_ABI frame_bridge(void *self) {
|
||||
game_tick();return original_frame(self);
|
||||
}
|
||||
static int guard(uintptr_t rva,const unsigned char *bytes,size_t n,uint64_t size) {
|
||||
return rva<=size && n<=size-rva && !memcmp((const void*)(base+rva),bytes,n);
|
||||
}
|
||||
void runtime_atlas_state_bind(void *image,uint64_t size) {
|
||||
state_ready=travel_ready=0;base=(uintptr_t)image;
|
||||
if(!image || size<0x553e880)return;
|
||||
static const unsigned char flag_guard[]={0x53,0x49,0x89,0xd0,0x8b,0x4f,0x1c,0x31,0xd2,0x89,0xf0,0xf7,0xf1,0x41,0x89,0xc2,0x44,0x0f,0xaf,0xd1,0x48,0x8b,0x5f,0x38};
|
||||
#include "map_build_guard.inc"
|
||||
if(!guard(BB_MAP_GUARD_OFFSET,bb_map_guard,sizeof(bb_map_guard),size))return;
|
||||
if(!guard(0x13cfc00,flag_guard,sizeof(flag_guard),size))return;
|
||||
state_ready=1;puts("Atlas: guarded read-only event flags ready");
|
||||
const char *opt=bb_env("BB_MAP_TRAVEL_ENABLED");if(!opt || strcmp(opt,"1"))return;
|
||||
static const unsigned char frame[]={0x55,0x48,0x89,0xe5,0x41,0x57,0x41,0x56,0x41,0x55,0x41,0x54,0x53,0x48,0x83,0xec,0x38};
|
||||
static const unsigned char warp[]={0x55,0x48,0x89,0xe5,0x48,0x63,0xc6,0x48,0x69,0xc8,0x89,0xb5,0xf8,0x14,0x48,0x89,0xca,0x48,0xc1,0xea,0x3f,0x48,0xc1,0xe9,0x2d,0x01,0xd1};
|
||||
static const unsigned char ret[]={0x55,0x48,0x89,0xe5,0x41,0x57,0x41,0x56,0x53,0x48,0x83,0xec,0x28,0x89,0xfb,0x4c,0x8b,0x35,0xba,0x6a,0xe7,0x03};
|
||||
static const unsigned char use[]={0x48,0x8b,0x05,0x91,0x95,0xfe,0x03,0x40,0x88,0xb8,0x24,0x15,0,0,0xc3};
|
||||
if(!guard(0x2034770,frame,sizeof(frame),size)||!guard(0x132e050,warp,sizeof(warp),size)||
|
||||
!guard(0x156d110,ret,sizeof(ret),size)||!guard(0x156d0e0,use,sizeof(use),size)){
|
||||
puts("Atlas: travel unavailable; native entry fingerprint mismatch");return;
|
||||
}
|
||||
#ifdef _WIN32
|
||||
unsigned char *trampoline=VirtualAlloc(NULL,4096,MEM_COMMIT|MEM_RESERVE,PAGE_READWRITE);
|
||||
if(!trampoline)return;
|
||||
memcpy(trampoline,frame,sizeof(frame));trampoline[17]=0xff;trampoline[18]=0x25;memset(trampoline+19,0,4);
|
||||
uintptr_t resume=base+0x2034770+17;memcpy(trampoline+23,&resume,8);
|
||||
DWORD old=0;
|
||||
if(!VirtualProtect(trampoline,4096,PAGE_EXECUTE_READ,&old)){VirtualFree(trampoline,0,MEM_RELEASE);return;}
|
||||
original_frame=(int64_t (ATLAS_ABI *)(void*))trampoline;
|
||||
unsigned char jump[17]={0xff,0x25,0,0,0,0};uintptr_t target=(uintptr_t)frame_bridge;
|
||||
memcpy(jump+6,&target,8);memset(jump+14,0x90,3);
|
||||
void *site=(void*)(base+0x2034770);
|
||||
if(!VirtualProtect(site,17,PAGE_EXECUTE_READWRITE,&old)){VirtualFree(trampoline,0,MEM_RELEASE);original_frame=NULL;return;}
|
||||
memcpy(site,jump,17);FlushInstructionCache(GetCurrentProcess(),site,17);
|
||||
DWORD ignored=0;VirtualProtect(site,17,old,&ignored);
|
||||
#else
|
||||
/* bbport: the trampoline (the 17 bytes the jump covers, then a jump back) in a page of its
|
||||
* own; the jump over the game's per-frame function is written as plain bytes, the image being
|
||||
* still writable here (see the header). Absolute jumps: no page near the image is needed. */
|
||||
unsigned char *trampoline=mmap(NULL,4096,PROT_READ|PROT_WRITE,MAP_PRIVATE|MAP_ANONYMOUS,-1,0);
|
||||
if(trampoline==MAP_FAILED)return;
|
||||
memcpy(trampoline,frame,sizeof(frame));trampoline[17]=0xff;trampoline[18]=0x25;memset(trampoline+19,0,4);
|
||||
uintptr_t resume=base+0x2034770+17;memcpy(trampoline+23,&resume,8);
|
||||
if(mprotect(trampoline,4096,PROT_READ|PROT_EXEC)){munmap(trampoline,4096);return;}
|
||||
original_frame=(int64_t (ATLAS_ABI *)(void*))trampoline;
|
||||
unsigned char jump[17]={0xff,0x25,0,0,0,0};uintptr_t target=(uintptr_t)frame_bridge;
|
||||
memcpy(jump+6,&target,8);memset(jump+14,0x90,3);
|
||||
memcpy((void*)(base+0x2034770),jump,17);
|
||||
#endif
|
||||
travel_ready=1;puts("Atlas: opt-in native lamp travel ready on the main game thread");
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/* SPDX-License-Identifier: GPL-2.0-or-later */
|
||||
#ifndef BB_RUNTIME_ATLAS_STATE_H
|
||||
#define BB_RUNTIME_ATLAS_STATE_H
|
||||
#include <stdint.h>
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
void runtime_atlas_state_bind(void *image, uint64_t size);
|
||||
/* -1 means unreadable/unsupported, 0 means off, 1 means on. */
|
||||
int runtime_atlas_flag(uint32_t id);
|
||||
uintptr_t runtime_atlas_player_token(void);
|
||||
/* Travel requests contain only a verified native destination and its eligibility. */
|
||||
typedef struct {
|
||||
int32_t return_point;
|
||||
uint32_t unlock_flag;
|
||||
uint32_t on[8], off[8];
|
||||
uint8_t on_count, off_count;
|
||||
float arrival[3];
|
||||
} BbAtlasTravelDestination;
|
||||
int runtime_atlas_travel_request(const BbAtlasTravelDestination *destination);
|
||||
/* 0 idle, 1 queued, 2 loading, 3 arrived, -1 rejected, -2 timed out. */
|
||||
int runtime_atlas_travel_status(void);
|
||||
int runtime_atlas_travel_supported(void);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -0,0 +1,152 @@
|
||||
/* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
* Local observation only: no writes, game calls, hooks, or save access.
|
||||
* 1.09 layout facts cross-checked against bbhost's public engine documentation.
|
||||
* From Hunter's Atlas by MikeTat1988 (github.com/MikeTat1988/HuntersAtlas); bbport: the reads
|
||||
* use process_vm_readv on Linux (ReadProcessMemory on Windows only).
|
||||
*/
|
||||
#define _GNU_SOURCE
|
||||
#include "runtime_map.h"
|
||||
#include "../gpu/shim/bb_env.h"
|
||||
#include <math.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#else
|
||||
#include <fcntl.h>
|
||||
#include <sys/uio.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
static uintptr_t world_slot;
|
||||
static uintptr_t menu_slot;
|
||||
static uintptr_t menu_main_slot,image_base;
|
||||
|
||||
void runtime_map_bind(void *image, uint64_t size) {
|
||||
world_slot=0;
|
||||
menu_slot=0;
|
||||
menu_main_slot=0;image_base=0;
|
||||
/* Anchors from the installed CUSA00207 1.09 executable. Full anchors rather
|
||||
* than prologues prevent a future executable silently using stale offsets. */
|
||||
if (!image || size<0x553e880) return;
|
||||
/* MAP_BUILD_GUARD is an image byte fingerprint generated during packaging. */
|
||||
#include "map_build_guard.inc"
|
||||
if (memcmp((unsigned char *)image+BB_MAP_GUARD_OFFSET,bb_map_guard,sizeof(bb_map_guard))) {
|
||||
puts("Map: executable layout mismatch; position tracking disabled");return;
|
||||
}
|
||||
world_slot=(uintptr_t)image+0x553e878;
|
||||
/* Game IsShowMenu reads this manager; +0x54 contributes the player-menu bit.
|
||||
* Fingerprint the exact predicate, and observe without calling or patching it. */
|
||||
static const unsigned char menu_guard[]={0x48,0x8b,0x05,0xd6,0x6f,0xdf,0x03,0x83,0x78,0x3c,0x00};
|
||||
/* The native grid's selected-index read, independently correlated with
|
||||
* actual 0/1/2 top-row selection and 8 below System in isolated runtime.
|
||||
* No writes or game function calls are used by this bridge. */
|
||||
static const unsigned char focus_guard[]={0x8b,0x8b,0xe8,0x0a,0,0,0x85,0xc9,0x79,0x06,0x8b,0x8b,0xe4,0x0a,0,0};
|
||||
if(size>0x5562880 && !memcmp((unsigned char*)image+0x176b89b,menu_guard,sizeof(menu_guard)) &&
|
||||
!memcmp((unsigned char*)image+0x1bdf835,focus_guard,sizeof(focus_guard))) {
|
||||
image_base=(uintptr_t)image;menu_slot=image_base+0x5562878;menu_main_slot=image_base+0x5562870;
|
||||
}
|
||||
puts("Map: read-only local position reader ready");
|
||||
}
|
||||
|
||||
int runtime_map_read(uintptr_t at,void *out,uint64_t bytes) {
|
||||
if (!at || at<0x10000 || !out) return 0;
|
||||
#ifdef _WIN32
|
||||
SIZE_T done=0;
|
||||
return ReadProcessMemory(GetCurrentProcess(),(const void *)at,out,bytes,&done) && done==bytes;
|
||||
#else
|
||||
/* bbport: a read of an unmapped or guarded address fails instead of faulting. On AMD the
|
||||
* game's memory is a GPU dma-buf, which process_vm_readv cannot read: /proc/self/mem then. */
|
||||
struct iovec local={out,(size_t)bytes},remote={(void *)at,(size_t)bytes};
|
||||
if (process_vm_readv(getpid(),&local,1,&remote,1,0)==(ssize_t)bytes) return 1;
|
||||
static int mem=-2;
|
||||
if (mem==-2) mem=open("/proc/self/mem",O_RDONLY|O_CLOEXEC);
|
||||
return mem>=0 && pread(mem,out,(size_t)bytes,(off_t)at)==(ssize_t)bytes;
|
||||
#endif
|
||||
}
|
||||
static int read_at(uintptr_t at,void *out,size_t bytes) {
|
||||
return runtime_map_read(at,out,bytes);
|
||||
}
|
||||
static uintptr_t pointer(uintptr_t at) {
|
||||
uintptr_t p=0;return read_at(at,&p,sizeof(p)) ? p : 0;
|
||||
}
|
||||
int runtime_map_menu_open(void) {
|
||||
uintptr_t menu=pointer(menu_slot);int value=0;
|
||||
if(!menu || !read_at(menu+0x54,&value,sizeof(value)))return 0;
|
||||
if(value==2)return 1; /* Stable shown state, verified against the actual quick menu. */
|
||||
/* bbport: 0 back from the System page while the quick menu is shown (runtime_map_menu_state). */
|
||||
BbMapMenu state;
|
||||
return value==0 && runtime_map_menu_state(&state) && state.top_level;
|
||||
}
|
||||
uint32_t runtime_map_confirm_button(void) {
|
||||
/* The game uses its region predicate, not the platform enter-button setting.
|
||||
* Observe the exact applied 1.09 region/confirm patch; never execute or alter it.
|
||||
* Unlock Game Region returns false (Cross), Circle Confirm returns true.
|
||||
* An unknown predicate cannot safely establish menu input ownership. */
|
||||
static const unsigned char cross[]={0x31,0xc0,0xc3,0x90,0x90,0x90};
|
||||
static const unsigned char circle[]={0xb8,0x01,0x00,0x00,0x00,0xc3};
|
||||
unsigned char code[6];
|
||||
/* The manifest-supported US build's original predicate uses Cross. */
|
||||
#include "map_confirm_guard.inc"
|
||||
unsigned char original[sizeof(bb_map_cross_region_guard)];
|
||||
if(!image_base || !read_at(image_base+0x1fb8730,code,sizeof(code)))return 0;
|
||||
if(!memcmp(code,cross,sizeof(cross)))return 0x4000u;
|
||||
if(!memcmp(code,circle,sizeof(circle)))return 0x2000u;
|
||||
if(read_at(image_base+0x1fb8730,original,sizeof(original)) &&
|
||||
!memcmp(original,bb_map_cross_region_guard,sizeof(original)))return 0x4000u;
|
||||
return 0;
|
||||
}
|
||||
int runtime_map_menu_state(BbMapMenu *out) {
|
||||
if(!out)return 0;
|
||||
memset(out,0,sizeof(*out));out->selected=-1;
|
||||
if(!runtime_map_confirm_button())return 0;
|
||||
uintptr_t menu=pointer(menu_slot),main=pointer(menu_main_slot);
|
||||
uintptr_t list=pointer(main?main+0x80:0);
|
||||
uint32_t status=0,shown=0;int shape[6]={0};
|
||||
/* bbport: BB_ATLAS_DEBUG=1 logs what the menu reader sees whenever it changes. */
|
||||
static int debug=-1;
|
||||
if(debug<0){const char *d=bb_env("BB_ATLAS_DEBUG");debug=d&&d[0]=='1';}
|
||||
if(debug){
|
||||
static char last[256];char now[256];uint32_t s=0,st=0;int sh[6]={0};
|
||||
const uintptr_t vt=list?pointer(list):0;
|
||||
if(menu)read_at(menu+0x54,&s,sizeof(s));
|
||||
if(list){read_at(list+0x98,&st,sizeof(st));read_at(list+0xae0,sh,sizeof(sh));}
|
||||
snprintf(now,sizeof(now),"menu=%d main=%#lx list=%#lx vt=%s shown=%u status=%u shape=%d,%d,%d,%d,%d,%d",
|
||||
menu!=0,(unsigned long)main,(unsigned long)list,vt==image_base+0x539c680?"ok":"other",s,st,
|
||||
sh[0],sh[1],sh[2],sh[3],sh[4],sh[5]);
|
||||
if(strcmp(now,last)){strcpy(last,now);printf("Atlas debug: %s\n",now);fflush(stdout);}
|
||||
}
|
||||
if(!menu || !main || !list || pointer(list)!=image_base+0x539c680 ||
|
||||
!read_at(menu+0x54,&shown,sizeof(shown)) ||
|
||||
!read_at(list+0x98,&status,sizeof(status)) ||
|
||||
!read_at(list+0xae0,shape,sizeof(shape)))return 0;
|
||||
/* This native list is a four-row six-slot grid, with only three enabled
|
||||
* items on its first row. +0x98 is 1 while the list is interactive and 2
|
||||
* while hidden behind nested dialogs; manager+0x54 stays 2 in both. */
|
||||
if(shape[0]!=24 || shape[3]!=6 || shape[4]!=4 || shape[1]<0 || shape[1]>=24 || shape[2]!=-1)return 0;
|
||||
if(main!=pointer(menu_main_slot) || list!=pointer(main+0x80))return 0;
|
||||
/* bbport: back from the System page the manager's +0x54 stays 0 while the list is shown and
|
||||
* interactive again (until the menu is closed and opened): 0 counts as shown then. The list
|
||||
* itself is freed when the menu closes (its vtable check above fails), so a closed menu is
|
||||
* never taken for a shown one (BB_ATLAS_DEBUG=1 log, 2026-10-19). */
|
||||
out->supported=1;out->top_level=(shown==2 || shown==0) && status==1;out->selected=shape[1];return 1;
|
||||
}
|
||||
int runtime_map_location(BbMapLocation *out) {
|
||||
if (!out) return 0;
|
||||
memset(out,0,sizeof(*out));
|
||||
if (!world_slot) return 0;
|
||||
uintptr_t world=pointer(world_slot),chr=pointer(world ? world+0x60 : 0);
|
||||
uintptr_t modules=pointer(chr ? chr+0x3b0 : 0);
|
||||
uintptr_t transform=pointer(modules ? modules+0x68 : 0);
|
||||
float v[3],yaw;uint32_t block;
|
||||
if (!transform || !pointer(chr+0x288) ||
|
||||
!read_at(transform+0x1e0,v,sizeof(v)) || !read_at(transform+0x1d4,&yaw,sizeof(yaw)) ||
|
||||
!read_at(chr+0x3f8,&block,sizeof(block))) return 0;
|
||||
if (chr!=pointer(world+0x60) || transform!=pointer(modules+0x68)) return 0;
|
||||
unsigned area=block>>24;
|
||||
if (area<21 || area>36 || block==0xffffffffu || !isfinite(yaw) ||
|
||||
!isfinite(v[0]) || !isfinite(v[1]) || !isfinite(v[2]) ||
|
||||
fabsf(v[0])>20000 || fabsf(v[1])>20000 || fabsf(v[2])>20000) return 0;
|
||||
out->x=v[0];out->y=v[1];out->z=v[2];out->yaw=yaw;out->block=block;out->valid=1;
|
||||
return 1;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/* SPDX-License-Identifier: GPL-2.0-or-later */
|
||||
#ifndef BB_RUNTIME_MAP_H
|
||||
#define BB_RUNTIME_MAP_H
|
||||
#include <stdint.h>
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
typedef struct { float x, y, z, yaw; uint32_t block; int valid; } BbMapLocation;
|
||||
typedef struct { int supported, top_level, selected; } BbMapMenu;
|
||||
void runtime_map_bind(void *image, uint64_t size);
|
||||
/* bbport: reads `bytes` of the game's memory at `at` without faulting; 1 on success. */
|
||||
int runtime_map_read(uintptr_t at, void *out, uint64_t bytes);
|
||||
int runtime_map_location(BbMapLocation *out);
|
||||
int runtime_map_menu_open(void);
|
||||
int runtime_map_menu_state(BbMapMenu *out);
|
||||
uint32_t runtime_map_confirm_button(void);
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -0,0 +1,62 @@
|
||||
/* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
* From Hunter's Atlas by MikeTat1988 (github.com/MikeTat1988/HuntersAtlas); bbport: setenv for
|
||||
* _putenv_s. */
|
||||
#define _GNU_SOURCE /* bbport: setenv */
|
||||
#include "../src/runtime_atlas_state.h"
|
||||
#include "../src/runtime_map.h"
|
||||
#include "../src/map_build_guard.inc"
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdint.h>
|
||||
/* This fixture exercises guarded flag memory and disabled travel. Live map/menu
|
||||
* discovery and successful travel are covered by copied-save gameplay checks. */
|
||||
int runtime_map_location(BbMapLocation *out){memset(out,0,sizeof(*out));return 0;}
|
||||
int runtime_map_menu_state(BbMapMenu *out){memset(out,0,sizeof(*out));return 0;}
|
||||
/* bbport: the fixture's memory is all readable (runtime_map.c's reader is tested there). */
|
||||
int runtime_map_read(uintptr_t at,void *out,uint64_t bytes){if(at<0x10000||!out)return 0;memcpy(out,(const void*)at,bytes);return 1;}
|
||||
static void put_ptr(unsigned char *p,size_t off,void *value){memcpy(p+off,&value,sizeof(value));}
|
||||
static void put_u32(unsigned char *p,size_t off,uint32_t value){memcpy(p+off,&value,4);}
|
||||
int main(void) {
|
||||
unsigned char *image=calloc(1,0x5570000),*manager=calloc(1,0x100),*head=calloc(1,0x38),*node=calloc(1,0x38),*data=calloc(1,125);
|
||||
assert(image&&manager&&head&&node&&data);
|
||||
setenv("BB_MAP_TRAVEL_ENABLED","0",1);
|
||||
runtime_atlas_state_bind(image,0x5570000);assert(runtime_atlas_flag(12417830)==-1);
|
||||
const unsigned char guard[]={0x53,0x49,0x89,0xd0,0x8b,0x4f,0x1c,0x31,0xd2,0x89,0xf0,0xf7,0xf1,0x41,0x89,0xc2,0x44,0x0f,0xaf,0xd1,0x48,0x8b,0x5f,0x38};
|
||||
memcpy(image+0x13cfc00,guard,sizeof(guard));memcpy(image+BB_MAP_GUARD_OFFSET,bb_map_guard,sizeof(bb_map_guard));
|
||||
put_ptr(image,0x553b100,manager);put_u32(manager,0x1c,1000);put_ptr(manager,0x38,head);
|
||||
head[0x19]=1;put_ptr(head,8,node);put_ptr(node,0,head);put_ptr(node,0x10,head);put_u32(node,0x20,12417);put_u32(node,0x28,2);put_ptr(node,0x30,data);
|
||||
runtime_atlas_state_bind(image,0x5570000);
|
||||
assert(runtime_atlas_flag(12417830)==0);
|
||||
data[830/8]|=(unsigned char)(0x80u>>(830&7));assert(runtime_atlas_flag(12417830)==1);
|
||||
assert(runtime_atlas_flag(12417831)==0);assert(runtime_atlas_flag(12418830)==-1);
|
||||
put_u32(node,0x28,1);put_u32(node,0x30,0);put_ptr(manager,0x28,data);put_u32(manager,0x20,125);
|
||||
assert(runtime_atlas_flag(12417830)==1);put_u32(manager,0x20,0);assert(runtime_atlas_flag(12417830)==-1);
|
||||
put_u32(manager,0x20,125);put_ptr(node,0,node);assert(runtime_atlas_flag(12417830)==-1);
|
||||
assert(!runtime_atlas_travel_supported());BbAtlasTravelDestination d={0};d.return_point=2412951;d.unlock_flag=12417830;assert(!runtime_atlas_travel_request(&d));
|
||||
/* bbport: travel on, its four entry points in place: the jump over the game's per-frame
|
||||
* function is written into the (still writable) image, an absolute jmp [rip] to the bridge. */
|
||||
{
|
||||
static const unsigned char frame[]={0x55,0x48,0x89,0xe5,0x41,0x57,0x41,0x56,0x41,0x55,0x41,0x54,0x53,0x48,0x83,0xec,0x38};
|
||||
static const unsigned char warp[]={0x55,0x48,0x89,0xe5,0x48,0x63,0xc6,0x48,0x69,0xc8,0x89,0xb5,0xf8,0x14,0x48,0x89,0xca,0x48,0xc1,0xea,0x3f,0x48,0xc1,0xe9,0x2d,0x01,0xd1};
|
||||
static const unsigned char ret[]={0x55,0x48,0x89,0xe5,0x41,0x57,0x41,0x56,0x53,0x48,0x83,0xec,0x28,0x89,0xfb,0x4c,0x8b,0x35,0xba,0x6a,0xe7,0x03};
|
||||
static const unsigned char use[]={0x48,0x8b,0x05,0x91,0x95,0xfe,0x03,0x40,0x88,0xb8,0x24,0x15,0,0,0xc3};
|
||||
memcpy(image+0x2034770,frame,sizeof(frame));memcpy(image+0x132e050,warp,sizeof(warp));
|
||||
memcpy(image+0x156d110,ret,sizeof(ret));memcpy(image+0x156d0e0,use,sizeof(use));
|
||||
setenv("BB_MAP_TRAVEL_ENABLED","1",1);
|
||||
runtime_atlas_state_bind(image,0x5570000);
|
||||
assert(runtime_atlas_travel_supported());
|
||||
const unsigned char jmp[]={0xff,0x25,0,0,0,0};
|
||||
uint64_t target=0;memcpy(&target,image+0x2034770+6,8);
|
||||
assert(!memcmp(image+0x2034770,jmp,sizeof(jmp)) && target!=0);
|
||||
assert(image[0x2034770+14]==0x90 && image[0x2034770+16]==0x90);
|
||||
/* A mismatching entry leaves the image alone and travel off. */
|
||||
memcpy(image+0x2034770,frame,sizeof(frame));image[0x132e050]^=1;
|
||||
runtime_atlas_state_bind(image,0x5570000);
|
||||
assert(!runtime_atlas_travel_supported() && !memcmp(image+0x2034770,frame,sizeof(frame)));
|
||||
setenv("BB_MAP_TRAVEL_ENABLED","0",1);
|
||||
}
|
||||
runtime_atlas_state_bind(NULL,0);assert(runtime_atlas_flag(12417830)==-1);
|
||||
free(data);free(node);free(head);free(manager);free(image);puts("ATLAS_STATE_PASS");return 0;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/* SPDX-License-Identifier: GPL-2.0-or-later
|
||||
* From Hunter's Atlas by MikeTat1988 (github.com/MikeTat1988/HuntersAtlas); bbport: setenv for
|
||||
* _putenv_s. */
|
||||
#define _GNU_SOURCE /* bbport: setenv */
|
||||
#include "../src/runtime_map.h"
|
||||
#include <assert.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <math.h>
|
||||
#include "../src/map_build_guard.inc"
|
||||
static void ptr(unsigned char* memory,size_t off,void* value){memcpy(memory+off,&value,sizeof(value));}
|
||||
int main(void) {
|
||||
BbMapLocation l;
|
||||
unsigned char *image=calloc(1,0x5570000),*world=calloc(1,0x100),*chr=calloc(1,0x600),*modules=calloc(1,0x100),*transform=calloc(1,0x300),*menu=calloc(1,0x100);
|
||||
assert(image && world && chr && modules && transform);
|
||||
runtime_map_bind(image,0x5570000);assert(!runtime_map_location(&l) && !l.valid && !runtime_map_menu_open());
|
||||
memcpy(image+BB_MAP_GUARD_OFFSET,bb_map_guard,sizeof(bb_map_guard));
|
||||
const unsigned char menu_guard[]={0x48,0x8b,0x05,0xd6,0x6f,0xdf,0x03,0x83,0x78,0x3c,0x00};
|
||||
const unsigned char focus_guard[]={0x8b,0x8b,0xe8,0x0a,0,0,0x85,0xc9,0x79,0x06,0x8b,0x8b,0xe4,0x0a,0,0};
|
||||
memcpy(image+0x1bdf835,focus_guard,sizeof(focus_guard));
|
||||
memcpy(image+0x176b89b,menu_guard,sizeof(menu_guard));ptr(image,0x5562878,menu);
|
||||
runtime_map_bind(image,0x5570000);assert(!runtime_map_menu_open());
|
||||
assert(runtime_map_confirm_button()==0); /* Missing native predicate: fail closed. */
|
||||
const unsigned char cross[]={0x31,0xc0,0xc3,0x90,0x90,0x90};
|
||||
const unsigned char circle[]={0xb8,0x01,0x00,0x00,0x00,0xc3};
|
||||
memcpy(image+0x1fb8730,cross,sizeof(cross));
|
||||
setenv("BB_ENTER_BUTTON","0",1);
|
||||
assert(runtime_map_confirm_button()==0x4000u); /* Conflicting environment cannot override game. */
|
||||
memcpy(image+0x1fb8730,circle,sizeof(circle));
|
||||
setenv("BB_ENTER_BUTTON","1",1);
|
||||
assert(runtime_map_confirm_button()==0x2000u);
|
||||
image[0x1fb8730+1]=2;assert(runtime_map_confirm_button()==0);
|
||||
BbMapMenu native_menu;assert(!runtime_map_menu_state(&native_menu)&&!native_menu.supported);
|
||||
memcpy(image+0x1fb8730,cross,sizeof(cross));
|
||||
setenv("BB_ENTER_BUTTON","",1);
|
||||
#include "../src/map_confirm_guard.inc"
|
||||
memcpy(image+0x1fb8730,bb_map_cross_region_guard,sizeof(bb_map_cross_region_guard));
|
||||
assert(runtime_map_confirm_button()==0x4000u);
|
||||
image[0x1fb8730+24]^=1;assert(runtime_map_confirm_button()==0);
|
||||
memcpy(image+0x1fb8730,cross,sizeof(cross));
|
||||
int state=2;memcpy(menu+0x54,&state,4);assert(runtime_map_menu_open());
|
||||
state=1;memcpy(menu+0x54,&state,4);assert(!runtime_map_menu_open());
|
||||
state=0;memcpy(menu+0x54,&state,4);assert(!runtime_map_menu_open());
|
||||
ptr(image,0x5562878,(void*)0x12345678);assert(!runtime_map_menu_open());
|
||||
ptr(image,0x5562878,menu);
|
||||
ptr(image,0x553e878,world);ptr(world,0x60,chr);ptr(chr,0x3b0,modules);ptr(modules,0x68,transform);
|
||||
ptr(chr,0x288,world);uint32_t block=0x18010000;memcpy(chr+0x3f8,&block,4);
|
||||
float pos[3]={-196.75f,-28.65f,65.39f},yaw=1.0f;memcpy(transform+0x1e0,pos,12);memcpy(transform+0x1d4,&yaw,4);
|
||||
assert(runtime_map_location(&l) && l.valid && l.x==pos[0] && l.block==block);
|
||||
pos[0]=NAN;memcpy(transform+0x1e0,pos,12);assert(!runtime_map_location(&l) && !l.valid);
|
||||
ptr(modules,0x68,(void*)0x12345);assert(!runtime_map_location(&l));
|
||||
ptr(world,0x60,NULL);assert(!runtime_map_location(&l));
|
||||
runtime_map_bind(NULL,0);assert(runtime_map_confirm_button()==0);
|
||||
free(menu);free(transform);free(modules);free(chr);free(world);free(image);
|
||||
puts("MAP_LOCATION_READ_ONLY_PASS");return 0;
|
||||
}
|
||||
Reference in new issue
Block a user