/* * bakin_minimal.so - LD_PRELOAD shim that makes Embarrassed Shina-chan run on * Proton. Three fixes, no game files touched: * * - VAO rebind: the engine feeds 2D-sprite/UI vertex attributes to the default * VAO (0). Fine on a compat-profile GL context, rejected on the core-profile * one Wine hands it, so those draws (characters, menus, UI) silently vanish. * Keep a real VAO bound whenever the game is on VAO 0. This is the one that * brings the characters back. * - cap the infinite NtWaitForSingleObject the audio init deadlocks on after * WASAPI setup fails, which otherwise freezes the game once the map loads. * - drop the IBL BRDF LUT the game can't build under Wine into its temp dir. * * The shim is LD_PRELOADed into the whole Proton process tree, so the ntdll * splice, the IBL dropper and their poller threads run only in the game process * (bakinplayer.exe on the command line); every other wine and Steam helper just * carries the harmless GL interposition. Logging is off unless BAKIN_HOOK_LOG is * set in the environment, in which case each process writes /tmp/bakin-hook..log. * * README has the long version. build: * gcc -O2 -shared -fPIC -o bakin_minimal.so bakin_minimal.c -ldl -lpthread */ #define _GNU_SOURCE #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include /* install.sh fills these two in. */ #define IBL_BRDF_SRC "@IBL_SRC@" #define BAKIN_ENGINE_TMPBASE "@TMPBASE@" #define LOGPATH_FMT "/tmp/bakin-hook.%d.log" #define STATUS_SUCCESS 0x00000000UL #define STATUS_TIMEOUT 0x00000102UL #define NTDLL_WAITFORSINGLEOBJ_VA 0x5b530UL /* Wine/Proton 11 ntdll.so */ static FILE *logfile = NULL; static void logln(const char *s) { if (logfile) { fputs(s, logfile); fputc('\n', logfile); fflush(logfile); } } /* * Detour a function: copy its first `prologue` bytes to a trampoline (followed * by a jmp back), then overwrite the entry with an abs jmp to `hook`. Returns * the trampoline, i.e. a callable pointer to the original. Only used for ntdll. */ static void *install_splice(uintptr_t func, void *hook, int prologue) { if (prologue < 14) return NULL; /* need room for the 14-byte jmp */ uint8_t *tramp = mmap(NULL, prologue + 14, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (tramp == MAP_FAILED) return NULL; memcpy(tramp, (void *)func, prologue); uint8_t *back = tramp + prologue; back[0] = 0xff; back[1] = 0x25; memset(back + 2, 0, 4); /* jmp [rip+0] */ uintptr_t cont = func + prologue; memcpy(back + 6, &cont, 8); uintptr_t page = func & ~(uintptr_t)0xFFF; if (mprotect((void *)page, 0x2000, PROT_READ | PROT_WRITE | PROT_EXEC) != 0) { munmap(tramp, prologue + 14); return NULL; } uint8_t *p = (uint8_t *)func; p[0] = 0xff; p[1] = 0x25; memset(p + 2, 0, 4); uintptr_t haddr = (uintptr_t)hook; memcpy(p + 6, &haddr, 8); memset(p + 14, 0x90, prologue - 14); /* nop the tail */ mprotect((void *)page, 0x2000, PROT_READ | PROT_EXEC); return tramp; } /* --- audio: cap infinite NtWaitForSingleObject waits at 10s --- * The audio init path blocks forever on a handle that never signals once WASAPI * setup fails under Wine. We turn every INFINITE wait into a 10s one and report * it as signaled (STATUS_SUCCESS) rather than STATUS_TIMEOUT, so the caller * proceeds past the dead handle instead of looping on the timeout. This is a * blunt instrument - it assumes the game has no genuinely-long INFINITE wait * that needs to keep blocking - which holds here because the splice is scoped to * the game process (see is_game_process), not the wine services around it. */ typedef uint32_t (*ntwfso_fn)(uintptr_t, int, const int64_t *); static ntwfso_fn ntwfso_orig = NULL; static uint32_t ntwfso_hook(uintptr_t h, int alertable, const int64_t *timeout) { if (!ntwfso_orig) return STATUS_SUCCESS; if (timeout) return ntwfso_orig(h, alertable, timeout); /* only touch INFINITE waits */ const int64_t ten_s = -100000000LL; /* negative = relative, 100ns ticks */ uint32_t r = ntwfso_orig(h, alertable, &ten_s); return r == STATUS_TIMEOUT ? STATUS_SUCCESS : r; } static int match_ntdll(struct dl_phdr_info *info, size_t sz, void *out) { (void)sz; if (info->dlpi_name && strstr(info->dlpi_name, "ntdll.so")) { *(uintptr_t *)out = (uintptr_t)info->dlpi_addr; return 1; } return 0; } /* ntdll is mapped after our constructor runs, so poll for it. */ static void *ntdll_poller(void *a) { (void)a; for (int i = 0; i < 6000; i++) { uintptr_t base = 0; dl_iterate_phdr(match_ntdll, &base); if (base) { ntwfso_orig = install_splice(base + NTDLL_WAITFORSINGLEOBJ_VA, ntwfso_hook, 16); logln("[bakin] ntwfso patched"); return NULL; } nanosleep(&(struct timespec){0, 10 * 1000 * 1000}, NULL); } return NULL; } /* --- IBL: the game wants ibl_brdf_lut.bmp in its per-launch temp extraction --- */ static void copy_file(const char *from, const char *to) { FILE *in = fopen(from, "rb"); if (!in) return; char tmp[1408]; snprintf(tmp, sizeof(tmp), "%s.wr_%d", to, (int)getpid()); /* write to a sidecar, then rename */ int fd = open(tmp, O_WRONLY | O_CREAT | O_EXCL, 0644); if (fd >= 0) { FILE *out = fdopen(fd, "wb"); if (out) { char buf[65536]; size_t n; while ((n = fread(buf, 1, sizeof(buf), in)) > 0) fwrite(buf, 1, n, out); fclose(out); if (rename(tmp, to) != 0) unlink(tmp); } else { close(fd); unlink(tmp); } } fclose(in); } static void place_ibl_brdf_lut(void) { DIR *d = opendir(BAKIN_ENGINE_TMPBASE); if (!d) return; struct dirent *ent; while ((ent = readdir(d))) { if (ent->d_name[0] == '.') continue; char dir[1024], probe[1280], dest[1300]; snprintf(dir, sizeof(dir), "%s/%s", BAKIN_ENGINE_TMPBASE, ent->d_name); snprintf(probe, sizeof(probe), "%s/lib/sysresource/shader", dir); struct stat st; if (stat(probe, &st) != 0) continue; /* not an extraction dir */ snprintf(dest, sizeof(dest), "%s/lib/sysresource/texture/ibl_brdf_lut.bmp", dir); if (stat(dest, &st) == 0) continue; /* already there */ char texdir[1300]; snprintf(texdir, sizeof(texdir), "%s/lib/sysresource/texture", dir); mkdir(texdir, 0755); copy_file(IBL_BRDF_SRC, dest); } closedir(d); } static void *ibl_poller(void *a) { (void)a; for (int i = 0; i < 1400; i++) { /* ~21s, covers the extraction */ place_ibl_brdf_lut(); nanosleep(&(struct timespec){0, 15 * 1000 * 1000}, NULL); } return NULL; } /* --- VAO fix --- * We interpose the three GL calls the billboard/UI setup path uses, plus dlsym * and *GetProcAddress so the game's runtime symbol lookup lands on us. Whenever * the game is on VAO 0, bind a real one instead. */ typedef void *(*dlsym_fn)(void *, const char *); static dlsym_fn real_dlsym = NULL; static void grab_dlsym(void) { if (real_dlsym) return; /* our dlsym override shadows the plain symbol, so reach the real one by * version. One of these two exists on any glibc from the last ~15 years. */ real_dlsym = (dlsym_fn)dlvsym(RTLD_NEXT, "dlsym", "GLIBC_2.34"); if (!real_dlsym) real_dlsym = (dlsym_fn)dlvsym(RTLD_NEXT, "dlsym", "GLIBC_2.2.5"); if (!real_dlsym) { /* nothing resolves without this: the GL wrappers below would have no * real function to forward to and would silently drop draws. Make the * failure loud instead of invisible. */ static int warned = 0; if (!warned) { warned = 1; fputs("[bakin] FATAL: could not resolve real dlsym\n", stderr); } } } /* dlsym first, then glXGetProcAddressARB - GL extension entry points often * aren't plain exported symbols. */ static void *resolve_gl(const char *name) { grab_dlsym(); if (!real_dlsym) return NULL; void *p = real_dlsym(RTLD_NEXT, name); if (p) return p; void *(*gpa)(const char *) = (void *(*)(const char *))real_dlsym(RTLD_NEXT, "glXGetProcAddressARB"); return gpa ? gpa(name) : NULL; } typedef void (*glGenVertexArrays_fn)(int, unsigned *); typedef void (*glBindVertexArray_fn)(unsigned); typedef void (*glGetIntegerv_fn)(unsigned, int *); typedef void (*glVertexAttribPointer_fn)(unsigned, int, unsigned, unsigned char, int, const void *); typedef void (*glEnableVertexAttribArray_fn)(unsigned); typedef void *(*glXGetCurrentContext_fn)(void); typedef void *(*eglGetCurrentContext_fn)(void); static glGenVertexArrays_fn real_glGenVertexArrays; static glBindVertexArray_fn real_glBindVertexArray; static glGetIntegerv_fn real_glGetIntegerv; static glVertexAttribPointer_fn real_glVertexAttribPointer; static glEnableVertexAttribArray_fn real_glEnableVertexAttribArray; static glXGetCurrentContext_fn real_glXGetCurrentContext; static eglGetCurrentContext_fn real_eglGetCurrentContext; #define RESOLVE(fn) do { if (!real_##fn) real_##fn = (fn##_fn)resolve_gl(#fn); } while (0) #define GL_VERTEX_ARRAY_BINDING 0x85B5 /* One persistent VAO per GL context. VAO names are not shared between contexts, * so a single global would fail the moment the game issued a default-VAO draw * from a second context. Contexts are few (usually one), so a small table under * a lock is plenty. */ static struct { void *ctx; unsigned vao; } g_vaos[8]; static pthread_mutex_t g_vao_lock = PTHREAD_MUTEX_INITIALIZER; static void *current_gl_context(void) { RESOLVE(glXGetCurrentContext); RESOLVE(eglGetCurrentContext); void *c = real_glXGetCurrentContext ? real_glXGetCurrentContext() : NULL; if (!c && real_eglGetCurrentContext) c = real_eglGetCurrentContext(); return c; /* NULL is a valid key: one fallback VAO when neither GLX nor EGL answers */ } static unsigned vao_for_current_context(void) { RESOLVE(glGenVertexArrays); if (!real_glGenVertexArrays) return 0; void *ctx = current_gl_context(); unsigned vao = 0; int free_slot = -1; pthread_mutex_lock(&g_vao_lock); for (unsigned i = 0; i < 8; i++) { if (g_vaos[i].vao && g_vaos[i].ctx == ctx) { vao = g_vaos[i].vao; break; } if (!g_vaos[i].vao && free_slot < 0) free_slot = (int)i; } if (!vao) { real_glGenVertexArrays(1, &vao); if (vao && free_slot >= 0) { g_vaos[free_slot].ctx = ctx; g_vaos[free_slot].vao = vao; } if (logfile) { fprintf(logfile, "[bakin] VAO fix: created VAO %u for ctx %p\n", vao, ctx); fflush(logfile); } } pthread_mutex_unlock(&g_vao_lock); return vao; } static void ensure_vao(void) { RESOLVE(glBindVertexArray); RESOLVE(glGetIntegerv); if (!real_glBindVertexArray || !real_glGetIntegerv) return; int cur = -1; real_glGetIntegerv(GL_VERTEX_ARRAY_BINDING, &cur); if (cur != 0) return; /* a real VAO is bound, leave it */ unsigned vao = vao_for_current_context(); if (vao) real_glBindVertexArray(vao); } void glVertexAttribPointer(unsigned i, int size, unsigned type, unsigned char norm, int stride, const void *ptr) { RESOLVE(glVertexAttribPointer); ensure_vao(); if (real_glVertexAttribPointer) real_glVertexAttribPointer(i, size, type, norm, stride, ptr); } void glEnableVertexAttribArray(unsigned i) { RESOLVE(glEnableVertexAttribArray); ensure_vao(); if (real_glEnableVertexAttribArray) real_glEnableVertexAttribArray(i); } void glBindVertexArray(unsigned arr) { RESOLVE(glBindVertexArray); if (arr == 0) arr = vao_for_current_context(); /* default VAO -> ours */ if (real_glBindVertexArray) real_glBindVertexArray(arr); } static void *our_wrapper(const char *name) { if (!name) return NULL; if (!strcmp(name, "glVertexAttribPointer")) return glVertexAttribPointer; if (!strcmp(name, "glEnableVertexAttribArray")) return glEnableVertexAttribArray; if (!strcmp(name, "glBindVertexArray")) return glBindVertexArray; return NULL; } void *dlsym(void *handle, const char *name) { grab_dlsym(); void *w = our_wrapper(name); return w ? w : (real_dlsym ? real_dlsym(handle, name) : NULL); } /* glXGetProcAddress / glXGetProcAddressARB / eglGetProcAddress are the same * shape; the arg is a name string either way. */ #define PROC_ADDR_SHIM(sym) \ void *sym(const void *name) { \ static void *(*next)(const char *); \ grab_dlsym(); \ if (!next && real_dlsym) next = (void *(*)(const char *))real_dlsym(RTLD_NEXT, #sym); \ void *w = our_wrapper((const char *)name); \ return w ? w : (next ? next((const char *)name) : NULL); \ } PROC_ADDR_SHIM(glXGetProcAddress) PROC_ADDR_SHIM(glXGetProcAddressARB) PROC_ADDR_SHIM(eglGetProcAddress) /* The hook is preloaded into the whole Proton tree; the game's unix process is * the one with bakinplayer.exe on its command line. Everything else (wineserver, * services.exe, the Steam reaper) skips the ntdll splice and the pollers. */ static int is_game_process(void) { int fd = open("/proc/self/cmdline", O_RDONLY); if (fd < 0) return 0; char buf[4096]; ssize_t n = read(fd, buf, sizeof(buf) - 1); close(fd); if (n <= 0) return 0; for (ssize_t i = 0; i < n; i++) if (buf[i] == '\0') buf[i] = ' '; buf[n] = '\0'; for (char *p = buf; *p; p++) if ((*p == 'b' || *p == 'B') && strncasecmp(p, "bakin", 5) == 0) return 1; return 0; } __attribute__((constructor)) static void bakin_init(void) { int game = is_game_process(); if (getenv("BAKIN_HOOK_LOG")) { /* logging is opt-in */ char path[256]; snprintf(path, sizeof(path), LOGPATH_FMT, getpid()); logfile = fopen(path, "w"); } logln(game ? "[bakin] init (game process)" : "[bakin] init"); if (!game) return; /* other procs keep only the GL interposition, which is free without GL */ pthread_attr_t attr; pthread_attr_init(&attr); pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); pthread_t t; pthread_create(&t, &attr, ntdll_poller, NULL); pthread_create(&t, &attr, ibl_poller, NULL); pthread_attr_destroy(&attr); /* the VAO fix works purely by symbol interposition, no thread needed. */ }