Ver Fonte

Embarrassed Shina-chan Linux/Proton fix pack

Make the RPG Developer Bakin game "Embarrassed Shina-chan" run on Linux
under Steam Proton. Reversible fixes only, no game binaries included:
a disk-FS retarget and a plugin-path IL rewrite so the asset-loader
plugins load, a shader keep-alive, a Wayland keyboard-focus fix, and an
LD_PRELOAD runtime hook that rebinds the default VAO (which brings back
the characters, menus and 2D UI), caps the audio-init wait, and drops in
the IBL BRDF LUT the engine cannot build under Wine.

See README.md for details and how to apply the approach to other Bakin
games.
uwu há 2 meses atrás
commit
924a350e94

+ 5 - 0
.gitignore

@@ -0,0 +1,5 @@
+patches/02-pluginpath/bin/
+patches/02-pluginpath/obj/
+patches/04-runtime-hook/*.so
+patches/04-runtime-hook/bakin_minimal.installed.c
+launcher.installed.sh

+ 21 - 0
LICENSE

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 the contributors
+
+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.

+ 165 - 0
README.md

@@ -0,0 +1,165 @@
+# Embarrassed Shina-chan: Linux / Proton fix pack
+
+Makes **Embarrassed Shina-chan~ the Naked Wandering College Girl** (Steam AppID
+`2326780`, built with RPG Developer Bakin) run on Linux under Proton. Out of
+the box the game reaches a grey/white screen and then freezes. Even once it
+boots, the character sprites and all 2D menus are invisible. This pack applies
+a set of small, documented, reversible fixes to your own copy so the game
+boots, loads the full 3D map, and renders the characters, menus and UI.
+
+![working screenshot](docs/working.png)
+
+The bugs are in this older Bakin engine build (`kmyCore.dll` ~5.4 MB, v1.1.0).
+Newer Bakin games run on the same Proton with no changes at all, since the
+engine fixed these issues upstream. This pack backports that behaviour into
+the older build.
+
+## What it does not do
+
+It ships no game files: no `kmyCore.dll`, no `bakinplayer.exe`, no shaders. It
+only carries scripts and source that transform your installed copy, and it
+verifies the original bytes and hashes before touching anything. Every change
+is backed up and can be reverted with `uninstall.sh`.
+
+## Requirements
+
+- A Linux Steam install of the game, run through Proton (tested on Proton
+  Experimental / 11).
+- `gcc`, `patch`, `python3`, `od`/`dd` (standard on any desktop Linux).
+- The .NET SDK (`dotnet`), only to build the ~30-line IL patcher for fix 2.
+  The first build fetches Mono.Cecil from NuGet, so it needs network access
+  once.
+
+## Install
+
+```bash
+git clone <this-repo> && cd shina-linux-patch
+./install.sh            # auto-detects the game, or pass its folder explicitly
+```
+
+Clone to a path without spaces or `:` (e.g. `~/shina-linux-patch`).
+`LD_PRELOAD` cannot handle such paths and the installer refuses them.
+
+Then, as the installer prints:
+
+1. Steam -> the game -> Properties -> Launch Options:
+   ```
+   "/abs/path/to/launcher.installed.sh" %command%
+   ```
+2. Wayland only (keyboard focus): close the game fully, then
+   ```
+   bash patches/05-wayland-keyboard.sh "/path/to/.../Embarrassed Shina-chan~ ..."
+   ```
+
+Launch from Steam. Revert anytime: `./uninstall.sh "/path/to/...game folder"`.
+
+## The fixes
+
+| #  | Symptom | Root cause | What the fix does | Layer |
+|----|---------|-----------|-------------------|-------|
+| 1  | Grey screen, no models | `scanPlugin` reads the in-memory `data.rbpack` FS, which contains no `.dlp` asset loaders | 1-byte retarget of one `call` so it uses the on-disk FS creator | `kmyCore.dll` (native) |
+| 2  | (same) no `.dlp` found | `Entry.initialize("", null)` makes the native path derivation fail under Wine | IL rewrite: pass `AppDomain.CurrentDomain.BaseDirectory` instead of `null` | `bakinplayer.exe` (managed) |
+| 3  | White screen, "Vertex Attribute Problem", freeze | Mesa cross-stage DCE marks `vin_*` vertex inputs inactive, so `glGetAttribLocation` returns -1 | keep-alive term in the shader that references every input, scaled by a uniform Mesa can't fold to 0 | game GLSL (text) |
+| 4a | Characters, menus and all 2D UI invisible | The engine sets up 2D-sprite/UI vertex attributes on the default VAO (0). Legal in an OpenGL compatibility profile, but Wine gives it a core profile where `glVertexAttribPointer` then fails with `GL_INVALID_OPERATION` and the driver rejects every default-VAO draw | `LD_PRELOAD` binds a real VAO whenever the game would use VAO 0 | host GL (preload) |
+| 4b | Freeze right after the map loads | WASAPI init fails (`SndError 923`); a thread waits forever on an "audio ready" event | `LD_PRELOAD` caps INFINITE `NtWaitForSingleObject` at 10 s | Wine/ntdll (preload) |
+| 5  | Mouse works, keyboard doesn't (Wayland) | Window uses ICCCM "Globally Active" input model; compositor withholds keyboard focus | Wine registry `UseTakeFocus=N` (passive model) | Wine registry |
+
+Fixes 4a and 4b, plus the IBL BRDF LUT lighting texture, live in one small
+`LD_PRELOAD` hook:
+[`patches/04-runtime-hook/bakin_minimal.c`](patches/04-runtime-hook/bakin_minimal.c).
+Fix 4a is the one that makes the characters and menus appear.
+
+Full technical write-up: [docs/TECHNICAL.md](docs/TECHNICAL.md).
+
+## How to review it
+
+Everything is source or plain text:
+
+- Fix 1, [`patches/01-kmycore-diskfs.sh`](patches/01-kmycore-diskfs.sh):
+  verifies the full 5-byte `call` instruction (`e8 58 92 fd ff`) plus a hash
+  check, then changes one byte (`0x58` to `0xB8`).
+- Fix 2, [`patches/02-pluginpath/Program.cs`](patches/02-pluginpath/Program.cs):
+  the Mono.Cecil IL rewrite, ~30 lines.
+- Fix 3, [`patches/03-shader-vin-keepalive.patch`](patches/03-shader-vin-keepalive.patch):
+  a unified diff of the shader include.
+- Fixes 4a/4b, [`patches/04-runtime-hook/bakin_minimal.c`](patches/04-runtime-hook/bakin_minimal.c):
+  the VAO fix is `ensure_vao()` plus the `glVertexAttribPointer` /
+  `glEnableVertexAttribArray` / `glBindVertexArray` wrappers; the audio fix is
+  the `NtWaitForSingleObject` splice; plus the IBL LUT drop.
+- Fix 5, [`patches/05-wayland-keyboard.sh`](patches/05-wayland-keyboard.sh):
+  a single registry value.
+
+## How the VAO bug was found
+
+Every observable GL state on the character's draw was identical to the visible
+3D models: same program, framebuffer, thread, depth/blend/stencil/cull,
+viewport, even the attached textures. Still, nothing rendered. The break was
+found by calling `glGetError` immediately after the character's draw (it
+returned `GL_INVALID_OPERATION`) and then installing a
+`glDebugMessageCallback`, which reported the exact reason:
+`glVertexAttribPointer(no array object bound)`. When a draw is invisible
+despite matching a visible one in every state you can read, the draw itself
+may be getting rejected; check `glGetError` on that draw and turn on GL debug
+output.
+
+## Known limitations
+
+A few street props (`bus`, `Traffic light`, `Swing_ch1`, `ParkClock_ch1`, ...)
+log a non-fatal "Vertex Attribute Problem" during map load and may show wrong
+or missing texture mapping. The game runs at full speed and everything else,
+including characters and UI, renders fine.
+
+## Applying this to other Bakin games
+
+Some of these fixes carry over to other games built on the same old Bakin engine
+(the OpenGL build, `kmyCore.dll` around 5.4 MB). Others are tied to this one
+game's files.
+
+Newer Bakin games don't need any of this. They ship `kmyDX12.dll` and render with
+DirectX 12 through vkd3d, which avoids the OpenGL bugs. The game 貢げ!女神様, on a
+newer build, runs on Proton with no patches at all.
+
+What carries over unchanged:
+
+- The VAO fix, which is the main one. It's a plain LD_PRELOAD hook that binds a
+  real VAO whenever the game uses the default VAO 0, and it never references this
+  game. Load it into any old-Bakin OpenGL game under Wine or Proton and it should
+  bring back the invisible characters, menus and UI. Any old OpenGL Windows game
+  that assumes the default VAO in a Compatibility profile hits the same bug and
+  takes the same fix.
+- The audio wait cap and the Wayland `UseTakeFocus` registry value. Both act on
+  Wine, not on the game.
+
+What's tied to this build:
+
+- Fix 1 (the one-byte `kmyCore.dll` edit) and Fix 2 (the `bakinplayer.exe` IL
+  rewrite) are pinned to one `kmyCore.dll` (md5 `68590231...`). The same build
+  patches the same way, a different version has different offsets. The scripts
+  check the exact bytes and stop if they don't match, so running them on the wrong
+  build does nothing bad. It just refuses.
+- Fix 3 (the shader keep-alive) only applies if the game ships the same
+  `v2_vpcommon.cgh`.
+- The audio hook's `ntdll` offset (`0x5b530`) follows the Proton build, not the
+  game.
+
+To try it on another old-Bakin game:
+
+1. Start with the hook by itself (VAO fix and audio cap). Change the hardcoded
+   `APPID=2326780` in `install.sh` to the new game's ID so `@TMPBASE@` resolves to
+   its prefix, then set the launcher as that game's launch options. This is
+   usually enough to get the characters and menus back.
+2. If the game also opens to a grey, empty scene, you also need fixes 1 and 2,
+   with their offsets re-derived from that game's `kmyCore.dll`.
+3. Fix 3 is only worth doing if the log fills with "Vertex Attribute Problem".
+
+None of this is guaranteed on a build nobody has tested, but the VAO fix is
+general enough to be the first thing to reach for.
+
+## Legal
+
+This exists for interoperability: making a legally purchased game run on
+Linux. No copyrighted game content is redistributed; the tools operate on your
+own files. The bundled `ibl_brdf_lut.bmp` is a standard precomputed BRDF
+lookup table (the Karis split-sum function), not game content.
+`bakin_minimal.c` and the scripts are MIT-licensed (see `LICENSE`). "RPG
+Developer Bakin" and the game are trademarks of their respective owners.

+ 151 - 0
docs/TECHNICAL.md

@@ -0,0 +1,151 @@
+# Technical write-up
+
+Reverse-engineering notes behind each fix. Addresses are for the shipped v1.1.0
+build: `kmyCore.dll` = 5,423,536 bytes, md5 `68590231c9418eeab8b9d70203e1f08d`.
+
+## Architecture
+
+- `shina.exe` is a 32-bit launcher. The real game is `data/bakinplayer.exe`
+  (x64, .NET/`Yukar.Player`), which loads the native engine `data/kmyCore.dll`.
+- Assets live in `data/data.rbpack` (a packed archive) plus loose files under
+  `data/` (models, shader *source* in `data/lib/sysresource/shader/`).
+- Asset loaders are `.dlp` plugins (`FBXLoader`, `PNGLoader`, `BMPLoader`,
+  `HDRLoader`, `OGGLoader`, `WAVLoader`, `BulletPhysics`): PE32+ DLLs the
+  engine `LoadLibrary`s at startup after a `scanPlugin` sweep.
+
+## Fix 1: plugin filesystem (grey screen)
+
+`kmyPlugin::PluginMgr::scanPlugin` (RVA `0xD1700`) builds a search dir and calls
+`kmyIO::FS::newFS(type=0, path=NULL)` (RVA `0xAA9B0`), then enumerates `*.dlp`
+through the FS vtable. In packaged mode `newFS(0,...)` returns the in-memory
+`data.rbpack` filesystem, which does not contain the `.dlp` files, so the scan
+finds zero loaders and no models/textures/audio ever load. The result is an
+empty scene (grey). Under `WINEDEBUG=+file` there is no `*.dlp` access at all.
+
+The disk-FS creator lives at RVA `0xAAA10` and safely handles a NULL path. The
+`call` at file offset `0xD0B53` (bytes `e8 58 92 fd ff`) encodes its target in
+a rel32 displacement whose low byte, at file offset `0xD0B54`, is `0x58`;
+changing it to `0xB8` retargets `0xAA9B0 -> 0xAAA10`. One byte. Steam does not
+re-validate `kmyCore.dll`, so the change persists.
+
+## Fix 2: plugin path (grey screen, cont.)
+
+Even with fix 1, `scanPlugin` needs a valid directory. The managed side calls:
+
+```
+Yukar.Player.Program ...  ->  SharpKmy.Entry.initialize("", null)   // pluginpath = null
+```
+
+With `pluginpath == NULL`, native `scanPlugin` falls back to
+`GetModuleFileNameW(NULL)` to locate the exe dir, which under Wine does not
+resolve to the on-disk `data/` dir where the `.dlp` live. The IL rewrite
+(`patches/02-pluginpath/Program.cs`, Mono.Cecil) replaces the `ldnull` before
+the 2-arg `Entry.initialize` with `AppDomain.CurrentDomain.BaseDirectory`, so
+`scanPlugin` takes its explicit-path branch and finds the loaders. Fixes 1 and
+2 are both required.
+
+## Fix 3: vertex attributes (white screen / freeze)
+
+The engine queries attribute locations by GLSL name, e.g.
+`glGetAttribLocation(prog, "vin_position")` / `"vin_uv0"`. Mesa's GLSL linker
+runs cross-stage dead-code elimination: any vertex input not statically used in
+a way that survives optimization is marked inactive and its location becomes
+-1. The engine treats -1 as a fatal "Vertex Attribute Problem", refuses the
+draw, and after enough failures aborts the frame loop
+(`KMY MAIN LOOP BREAK BY NO TASK`), leaving a white screen.
+
+`data/lib/sysresource/shader/include/v2_vpcommon.cgh` is the common
+vertex-program include (the game copies `data/lib` to a temp dir at launch and
+compiles from there, so editing the on-disk source is picked up). The fix adds
+`VIN_KEEPALIVE`, a term folded into `gl_Position` that references every
+declared `vin_*` input, each guarded by the same `#ifdef` as its declaration
+and multiplied by `float(drawCount == -9999)`. `drawCount` is a uniform Mesa
+cannot constant-fold, and it is always `>= 0` at runtime, so the term is
+exactly `0.0`: attributes stay "used" (never DCE'd) with zero effect on
+output. This drops the Vertex Attribute Problem count from 200+ to 0 on the
+title screen and the vast majority of map models. The `_USE_GPROGRAM` path is
+intentionally left alone; geometry programs re-emit position downstream.
+
+## Fix 4a: the default VAO in a core profile (invisible characters and menus)
+
+This is the fix that makes the 2D layer appear, and it was the hardest to find.
+
+Symptom: the 3D world renders, but the player/NPC character billboards
+(`kmyGfx::BillboardChr`, `2dchar_lit` shader), the title and in-game menus,
+and the 2D UI are completely invisible.
+
+Diagnosis: instrumenting the character's draw with an `LD_PRELOAD` GL hook
+showed its draw call (`glDrawArraysInstanced`) executing on the same thread,
+to the same framebuffer (same physical color/depth textures), with identical
+GL state to the visible 3D models (depth func, blend, cull, stencil, scissor,
+rasterizer-discard, viewport, color mask, draw buffers) and a valid compiled
+and linked program. Forcing the character's shader to output solid magenta at
+the near plane still produced nothing. Calling `glGetError()` immediately
+after the character's draw returned `GL_INVALID_OPERATION` (`0x502`), and
+`glDebugMessageCallback` gave the exact reason:
+
+```
+GL_INVALID_OPERATION in glVertexAttribPointer(no array object bound)
+```
+
+Root cause: the engine sets up its 2D-sprite/billboard/UI vertex attributes
+against the default vertex array object (name 0). That is legal in an OpenGL
+compatibility profile (what the game targets on Windows), but Wine/Proton give
+the process a core profile, where the default VAO does not exist. So
+`glVertexAttribPointer` fails, the attributes are never established, and every
+draw that relies on the default VAO is rejected by the driver with
+`GL_INVALID_OPERATION` and never rasterizes. The 3D models load their own VAOs
+from FBX data, so they were unaffected, which is exactly why only the 2D layer
+vanished.
+
+Fix: in the `LD_PRELOAD` hook, interpose `glVertexAttribPointer`,
+`glEnableVertexAttribArray`, and `glBindVertexArray` (resolution reaches them
+via the interposed `dlsym` / `glX*GetProcAddress`). `ensure_vao()` queries
+`GL_VERTEX_ARRAY_BINDING`; if it is 0, it lazily `glGenVertexArrays` one
+persistent VAO and binds it. `glBindVertexArray(0)` from the game is
+redirected to that same VAO. Whenever the game believes it is on the default
+VAO, a real VAO is actually bound, so the attribute setup and the draws are
+valid. Confirmed: the character's `glDrawArraysInstanced` then returns
+`glGetError() == 0` and the character, menus and UI render.
+
+## Fix 4b: audio deadlock (freeze after map load)
+
+`kmySound` init fails under Wine's WASAPI with `SndError 923` (no matching
+device format; non-fatal to the engine, which is supposed to continue). But a
+game thread then blocks in `NtWaitForSingleObject(event, INFINITE)` on an
+"audio ready" event the failed audio thread never signals, and all threads
+idle at 0% CPU.
+
+`bakin_minimal.c` (`LD_PRELOAD`) splices ntdll's `NtWaitForSingleObject` and
+substitutes a 10 s timeout for INFINITE waits, returning `STATUS_SUCCESS` on
+expiry. Real waits (vsync fences, message events) complete far under 10 s and
+are untouched. ntdll is mapped by Wine's preloader after our constructor runs,
+so a poller retries the splice every 10 ms until ntdll appears.
+
+Note: do not also patch `NtWaitForAlertByThreadId`. Bypassing its INFINITE
+waits corrupts the render thread's synchronization and produces a blank/black
+screen. `NtWaitForSingleObject` only is the right scope.
+
+## Fix 5: Wayland keyboard focus
+
+On KDE Plasma Wayland the game runs as an X11 client via Xwayland. Its top
+window advertises the ICCCM "Globally Active" input model
+(`WM_HINTS: input = False` + `WM_PROTOCOLS: WM_TAKE_FOCUS`), Wine/WinForms'
+default. The compositor never grants it Wayland keyboard focus (mouse is
+position-based so it works; keyboard is focus-based so it doesn't). Neither
+focus-follows-mouse, `_NET_ACTIVE_WINDOW`, nor `XSetInputFocus` delivered
+keys.
+
+Setting `HKCU\Software\Wine\X11 Driver` -> `"UseTakeFocus"="N"` disables the
+take-focus model; the window then advertises the passive model (`input=True`)
+and the compositor grants keyboard focus. Harmless on native X11. Must be
+written with the prefix's wineserver stopped so it sticks.
+
+## Environment notes
+
+- `WINEDLLOVERRIDES="WebView2Loader="`: the bundled WebView2Loader hangs Wine.
+- `mesa_glthread=false`: avoids a NULL-vtable crash in Mesa GL worker threads.
+- `WINE_DISABLE_FULLSCREEN_HACK=1`: Wine's fullscreen-hack gamma shader fails
+  to compile here and crashes early.
+- Benign: `GfxError 1282` on editor "pickup"/manipulator shader compiles; does
+  not affect gameplay.

BIN
docs/working.png


+ 130 - 0
install.sh

@@ -0,0 +1,130 @@
+#!/usr/bin/env bash
+# Installer for the Embarrassed Shina-chan Linux/Proton fix pack.
+#
+# Modifies only files inside the game folder and the game's Proton prefix; no
+# game binaries ship with this repo. Every modified file is backed up
+# (*.orig / user.reg.bak-*) so uninstall.sh can revert.
+#
+# Usage:
+#   ./install.sh [/path/to/steamapps/common/Embarrassed Shina-chan~ .../]
+# If no path is given it tries common Steam library locations.
+set -euo pipefail
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+APPID=2326780
+
+find_game() {
+  local c
+  for c in \
+    "$HOME/.steam/steam/steamapps/common/Embarrassed Shina-chan~ the Naked Wandering College Girl" \
+    "$HOME/.local/share/Steam/steamapps/common/Embarrassed Shina-chan~ the Naked Wandering College Girl"; do
+    [ -f "$c/data/kmyCore.dll" ] && { echo "$c"; return 0; }
+  done
+  # scan libraryfolders.vdf for extra libraries
+  local vdf="$HOME/.steam/steam/steamapps/libraryfolders.vdf"
+  [ -f "$vdf" ] || vdf="$HOME/.local/share/Steam/steamapps/libraryfolders.vdf"
+  if [ -f "$vdf" ]; then
+    local lib g
+    while IFS= read -r lib; do
+      g="$lib/steamapps/common/Embarrassed Shina-chan~ the Naked Wandering College Girl"
+      [ -f "$g/data/kmyCore.dll" ] && { echo "$g"; return 0; }
+    done < <(grep -oE '"path"[[:space:]]*"[^"]+"' "$vdf" | sed -E 's/.*"([^"]+)"$/\1/')
+  fi
+  return 1
+}
+
+GAME_ROOT="${1:-$(find_game || true)}"
+if [ -z "${GAME_ROOT:-}" ] || [ ! -f "$GAME_ROOT/data/kmyCore.dll" ]; then
+  echo "!! Could not locate the game. Pass its folder explicitly:"
+  echo "   ./install.sh \"/path/to/steamapps/common/Embarrassed Shina-chan~ the Naked Wandering College Girl\""
+  exit 1
+fi
+echo "Game folder: $GAME_ROOT"
+echo
+
+# check every required tool up front so the install never stops halfway through.
+# all of these are needed for a working game: gcc builds the runtime hook, and
+# dotnet builds the IL patcher for the plugin-path fix, without which no asset
+# plugins load and the game stays a grey screen.
+missing=""
+for tool in gcc patch od dd md5sum dotnet; do
+  command -v "$tool" >/dev/null 2>&1 || missing="$missing $tool"
+done
+if [ -n "$missing" ]; then
+  echo "!! missing required tools:$missing"
+  echo "   gcc            - builds the runtime hook (fix 4)"
+  echo "   patch/od/dd/md5sum - apply and verify the byte/shader patches (fixes 1-3)"
+  echo "   dotnet         - the .NET SDK, builds the IL patcher for the plugin-path"
+  echo "                    fix (fix 2); needs network access once to fetch Mono.Cecil"
+  exit 1
+fi
+command -v python3 >/dev/null 2>&1 || \
+  echo "   note: python3 not found, only needed for the optional Wayland fix 5"
+# LD_PRELOAD treats colons and spaces as separators, so a repo path containing
+# them would silently break the runtime hook
+case "$HERE" in
+  *' '*|*:*)
+    echo "!! this repo's path contains a space or ':', which LD_PRELOAD cannot handle."
+    echo "   Move the repo, e.g.:  mv \"$HERE\" ~/shina-linux-patch   and re-run."
+    exit 1;;
+esac
+
+echo "[1/5] kmyCore disk-FS"
+bash "$HERE/patches/01-kmycore-diskfs.sh" "$GAME_ROOT"
+
+echo "[2/5] bakinplayer plugin path"
+bash "$HERE/patches/02-pluginpath/apply.sh" "$GAME_ROOT"
+
+echo "[3/5] shader vin_* keep-alive"
+SH="$GAME_ROOT/data/lib/sysresource/shader/include/v2_vpcommon.cgh"
+SHPATCH="$HERE/patches/03-shader-vin-keepalive.patch"
+if grep -q "VIN_KEEPALIVE" "$SH"; then
+  echo "== Fix 3: already applied"
+else
+  # dry-run first; a partially applied patch would leave every shader
+  # referencing an undefined VIN_KEEPALIVE
+  if ! patch -p1 -d "$GAME_ROOT" -s -f --dry-run < "$SHPATCH" >/dev/null 2>&1; then
+    echo "!! Fix 3: shader source does not match this patch (different game build?)."
+    echo "!! Not touching it. v2_vpcommon.cgh left unmodified."
+    exit 3
+  fi
+  [ -f "$SH.orig" ] || cp "$SH" "$SH.orig"
+  patch -p1 -d "$GAME_ROOT" -s -f < "$SHPATCH"
+  echo "== Fix 3: shader patched, backup at v2_vpcommon.cgh.orig"
+fi
+
+echo "[4/5] runtime hook (VAO fix + audio-deadlock cap + IBL LUT)"
+HOOKDIR="$HERE/patches/04-runtime-hook"
+IBL_SRC="$HOOKDIR/ibl_brdf_lut.bmp"
+# the game's per-launch shader/texture extraction lives in the Proton prefix:
+TMPBASE="$(cd "$GAME_ROOT/../.." && pwd)/compatdata/$APPID/pfx/drive_c/users/steamuser/AppData/Local/Temp/bakin_engine_tmp"
+# both paths end up inside C string literals, so refuse characters that break them
+for p in "$IBL_SRC" "$TMPBASE"; do
+  case "$p" in
+    *'"'*|*'\'*) echo "!! cannot embed a path containing \" or \\ into the hook: $p"; exit 1;;
+  esac
+done
+sed_esc() { printf '%s' "$1" | sed 's/[&|]/\\&/g'; }
+sed -e "s|@IBL_SRC@|$(sed_esc "$IBL_SRC")|g" -e "s|@TMPBASE@|$(sed_esc "$TMPBASE")|g" \
+    "$HOOKDIR/bakin_minimal.c" > "$HOOKDIR/bakin_minimal.installed.c"
+gcc -O2 -shared -fPIC -o "$HOOKDIR/bakin_minimal.so" "$HOOKDIR/bakin_minimal.installed.c" -ldl -lpthread
+echo "== built $HOOKDIR/bakin_minimal.so"
+
+echo "[5/5] launcher"
+HOOK_SO="$HOOKDIR/bakin_minimal.so"
+LAUNCHER="$HERE/launcher.installed.sh"
+sed "s|@HOOK_SO@|$(sed_esc "$HOOK_SO")|g" "$HERE/launcher.sh" > "$LAUNCHER"
+chmod +x "$LAUNCHER"
+
+echo
+echo "======================================================================"
+echo "Done. Two manual steps remain:"
+echo
+echo "1) Steam -> Embarrassed Shina-chan -> Properties -> Launch Options:"
+echo "     \"$LAUNCHER\" %command%"
+echo
+echo "2) Wayland desktops only (keyboard focus). Fully close the game, then:"
+echo "     bash \"$HERE/patches/05-wayland-keyboard.sh\" \"$GAME_ROOT\""
+echo "   (harmless to skip on X11.)"
+echo
+echo "Then launch from Steam. To revert everything: ./uninstall.sh \"$GAME_ROOT\""
+echo "======================================================================"

+ 28 - 0
launcher.sh

@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# Steam launch wrapper for Embarrassed Shina-chan on Linux/Proton.
+# Set this as the game's Steam Launch Options:
+#     /full/path/to/launcher.sh %command%
+#
+# install.sh rewrites @HOOK_SO@ below to the built bakin_minimal.so path.
+
+# Runtime hook: VAO rebind, audio wait cap and the IBL BRDF LUT drop
+# (see patches/04-runtime-hook). For diagnostics, set BAKIN_HOOK_LOG=1 before
+# launching to have it write /tmp/bakin-hook.<pid>.log; off by default.
+HOOK_SO="@HOOK_SO@"
+[ -f "$HOOK_SO" ] && export LD_PRELOAD="${HOOK_SO}:${LD_PRELOAD}"
+
+# The bundled WebView2Loader deadlocks under Wine (no Edge WebView2 runtime);
+# disabling the DLL avoids a hang in CreateCoreWebView2Environment.
+export WINEDLLOVERRIDES="WebView2Loader="
+
+# mesa_glthread=false: avoids a NULL-vtable crash in Mesa's GL worker threads.
+export mesa_glthread=false
+
+# Disables Wine's fullscreen-hack gamma vertex shader, which fails to compile
+# here and crashes early.
+export WINE_DISABLE_FULLSCREEN_HACK=1
+
+export WINEDEBUG="-all"
+export PROTON_LOG=0
+
+exec "$@"

+ 46 - 0
patches/01-kmycore-diskfs.sh

@@ -0,0 +1,46 @@
+#!/usr/bin/env bash
+# Fix 1: make kmyCore.dll's scanPlugin use the on-disk filesystem.
+#
+# kmyPlugin::PluginMgr::scanPlugin calls kmyIO::FS::newFS(0, NULL) which, in
+# packaged mode, hands back the in-memory data.rbpack filesystem. The engine's
+# .dlp asset loaders (FBXLoader/PNGLoader/OGGLoader/BulletPhysics/...) do NOT
+# live inside data.rbpack, so the scan finds none of them and the game loads an
+# empty scene (grey screen). This flips the target of that one `call` so it goes
+# to the disk-FS creator at RVA 0xAAA10 (which safely handles a NULL path).
+#
+# The change is a single byte in the rel32 displacement of the call:
+#   file offset 0xD0B54 : 0x58 -> 0xB8   (retargets 0xAA9B0 -> 0xAAA10)
+# Before writing, the FULL 5-byte call instruction at 0xD0B53 is verified
+# (e8 58 92 fd ff), so a different build can't pass on a coincidental byte.
+#
+# Reversible: a .orig backup is written next to the file the first time.
+set -euo pipefail
+
+GAME_ROOT="${1:?usage: 01-kmycore-diskfs.sh <game-root-dir>}"
+DLL="$GAME_ROOT/data/kmyCore.dll"
+OFF=$((0xD0B54))          # the rel32 low byte we flip
+CALL_OFF=$((0xD0B53))     # start of the 5-byte call instruction
+ORIG_CALL="e85892fdff"    # call -> disk/pack FS dispatcher RVA 0xAA9B0
+NEW_CALL="e8b892fdff"     # call -> disk-FS creator      RVA 0xAAA10
+# md5 of the known-good v1.1.0 unpatched kmyCore.dll (5,423,536 bytes)
+KNOWN_ORIG_MD5="68590231c9418eeab8b9d70203e1f08d"
+
+[ -f "$DLL" ] || { echo "!! not found: $DLL"; exit 1; }
+
+cur=$(od -An -tx1 -j "$CALL_OFF" -N5 "$DLL" | tr -d ' \n')
+if [ "$cur" = "$NEW_CALL" ]; then
+  echo "== Fix 1: already applied (call @0x$(printf %x $CALL_OFF) = $NEW_CALL)"; exit 0
+fi
+if [ "$cur" != "$ORIG_CALL" ]; then
+  echo "!! Fix 1: unexpected bytes $cur @0x$(printf %x $CALL_OFF) (expected $ORIG_CALL)."
+  echo "!! This kmyCore.dll is a different build than this patch targets. Aborting."
+  exit 3
+fi
+
+md5now=$(md5sum "$DLL" | cut -d' ' -f1)
+[ "$md5now" = "$KNOWN_ORIG_MD5" ] || \
+  echo "   (note: kmyCore.dll md5 $md5now != known $KNOWN_ORIG_MD5; byte matches, continuing)"
+
+[ -f "$DLL.orig" ] || cp -n "$DLL" "$DLL.orig"
+printf "\xb8" | dd of="$DLL" bs=1 seek="$OFF" count=1 conv=notrunc status=none
+echo "== Fix 1: patched kmyCore.dll (disk-FS), backup at kmyCore.dll.orig"

+ 42 - 0
patches/02-pluginpath/Program.cs

@@ -0,0 +1,42 @@
+using System;
+using Mono.Cecil;
+using Mono.Cecil.Cil;
+
+class P {
+  static int Main(string[] a) {
+    string src = a[0], dst = a[1];
+    var m = ModuleDefinition.ReadModule(src);
+    var tsAppDomain = new TypeReference("System", "AppDomain", m, m.TypeSystem.CoreLibrary, false);
+    var getCurrentDomain = new MethodReference("get_CurrentDomain", tsAppDomain, tsAppDomain){ HasThis = false };
+    var getBaseDir = new MethodReference("get_BaseDirectory", m.TypeSystem.String, tsAppDomain){ HasThis = true };
+    var mGetCurrent = m.ImportReference(getCurrentDomain);
+    var mGetBase = m.ImportReference(getBaseDir);
+    int patched = 0;
+    foreach (var t in m.GetTypes())
+      foreach (var meth in t.Methods) {
+        if (!meth.HasBody) continue;
+        var il = meth.Body.Instructions;
+        for (int i = 1; i < il.Count; i++) {
+          var ins = il[i];
+          if (ins.OpCode == OpCodes.Call && ins.Operand is MethodReference mr
+              && mr.Name == "initialize" && mr.DeclaringType.Name == "Entry"
+              && mr.Parameters.Count == 2 && il[i-1].OpCode == OpCodes.Ldnull) {
+            // assumes the ldnull is not a branch or handler target; Replace
+            // does not retarget operands pointing at the removed instruction
+            var proc = meth.Body.GetILProcessor();
+            var ldnull = il[i-1];
+            var call1 = proc.Create(OpCodes.Call, mGetCurrent);
+            var call2 = proc.Create(OpCodes.Callvirt, mGetBase);
+            proc.Replace(ldnull, call1);
+            proc.InsertAfter(call1, call2);
+            patched++;
+            Console.WriteLine($"Patched {t.FullName}.{meth.Name}: ldnull -> AppDomain.CurrentDomain.BaseDirectory");
+          }
+        }
+      }
+    if (patched == 0) { Console.WriteLine("No patch site found"); return 2; }
+    m.Write(dst);
+    Console.WriteLine($"Wrote {dst}, patched={patched}");
+    return 0;
+  }
+}

+ 41 - 0
patches/02-pluginpath/apply.sh

@@ -0,0 +1,41 @@
+#!/usr/bin/env bash
+# Fix 2: make bakinplayer.exe pass a real plugin path to the native engine.
+#
+# Yukar.Player.Program calls SharpKmy.Entry.initialize("", null), so pluginpath
+# is NULL. The native scanPlugin then tries GetModuleFileNameW(NULL) to derive a
+# search dir, which under Wine does not resolve to the on-disk data dir where the
+# .dlp loaders live, so nothing is found. We rewrite the managed IL so the second
+# argument becomes AppDomain.CurrentDomain.BaseDirectory (the folder containing
+# bakinplayer.exe), which makes scanPlugin take its explicit-path branch.
+#
+# This is a pure IL rewrite done with Mono.Cecil (see Program.cs). It needs the
+# .NET SDK (`dotnet`) available on PATH to build the tiny patcher. Reversible via
+# the bakinplayer.exe.orig backup.
+set -euo pipefail
+
+GAME_ROOT="${1:?usage: apply.sh <game-root-dir>}"
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+EXE="$GAME_ROOT/data/bakinplayer.exe"
+KNOWN_ORIG_MD5="dc76b6ee233efaa1270d845b47fd4e3d"   # unpatched v1.1.0 (589,312 bytes)
+
+[ -f "$EXE" ] || { echo "!! not found: $EXE"; exit 1; }
+command -v dotnet >/dev/null || { echo "!! 'dotnet' (.NET SDK) not found on PATH, needed to build the IL patcher"; exit 2; }
+
+md5now=$(md5sum "$EXE" | cut -d' ' -f1)
+if [ "$md5now" != "$KNOWN_ORIG_MD5" ]; then
+  echo "   (note: bakinplayer.exe md5 $md5now != known-original $KNOWN_ORIG_MD5)"
+  echo "   If it was already patched by this tool, that is expected; continuing."
+fi
+
+echo "== Fix 2: building IL patcher (dotnet) ..."
+dotnet build -c Release -o "$HERE/bin" "$HERE/patcher.csproj" >/dev/null
+
+[ -f "$EXE.orig" ] || cp -n "$EXE" "$EXE.orig"
+TMP="$(mktemp --suffix=.exe)"
+if dotnet "$HERE/bin/patcher.dll" "$EXE.orig" "$TMP"; then
+  cp "$TMP" "$EXE"; rm -f "$TMP"
+  echo "== Fix 2: patched bakinplayer.exe (pluginpath=BaseDirectory), backup at bakinplayer.exe.orig"
+else
+  rm -f "$TMP"
+  echo "!! Fix 2: patcher found no patch site; bakinplayer.exe may be a different build"; exit 4
+fi

+ 11 - 0
patches/02-pluginpath/patcher.csproj

@@ -0,0 +1,11 @@
+<Project Sdk="Microsoft.NET.Sdk">
+  <PropertyGroup>
+    <OutputType>Exe</OutputType>
+    <TargetFramework>net8.0</TargetFramework>
+    <Nullable>disable</Nullable>
+    <ImplicitUsings>disable</ImplicitUsings>
+  </PropertyGroup>
+  <ItemGroup>
+    <PackageReference Include="Mono.Cecil" Version="0.11.5" />
+  </ItemGroup>
+</Project>

+ 56 - 0
patches/03-shader-vin-keepalive.patch

@@ -0,0 +1,56 @@
+--- a/data/lib/sysresource/shader/include/v2_vpcommon.cgh
++++ b/data/lib/sysresource/shader/include/v2_vpcommon.cgh
+@@ -12,7 +12,7 @@
+ #ifdef _USE_GPROGRAM
+ #define END_VP_MAIN VOUT_VALUE(instanceID) = gl_InstanceID;}
+ #else
+-#define END_VP_MAIN gl_Position = vfio_position;VOUT_VALUE(instanceID) = gl_InstanceID;}
++#define END_VP_MAIN gl_Position = vfio_position + VIN_KEEPALIVE;VOUT_VALUE(instanceID) = gl_InstanceID;}
+ #endif
+ 
+ #extension GL_ARB_explicit_attrib_location : enable
+@@ -47,6 +47,44 @@
+ layout(location = 6) in vec4 vin_weights;
+ #endif
+ 
++/* Keep vin_* attributes alive so Mesa's cross-stage DCE cannot mark them
++ * inactive (glGetAttribLocation would return -1 -> engine "Vertex Attribute
++ * Problem" -> white screen). Each term is scaled by float(drawCount == -9999),
++ * a uniform Mesa cannot constant-fold; drawCount is always >= 0 at runtime so
++ * the terms are exactly 0.0 and rendering is unaffected. Guarded by the same
++ * #ifdefs as the declarations so only declared attributes are referenced. */
++#ifdef _USE_NORMAL
++#define _VKA_N + vin_normal.x
++#else
++#define _VKA_N
++#endif
++#ifdef _USE_NORMALMAP
++#define _VKA_T + vin_tangent.x
++#else
++#define _VKA_T
++#endif
++#ifdef _USE_VP_TEXCOORD0
++#define _VKA_U0 + vin_uv0.x
++#else
++#define _VKA_U0
++#endif
++#ifdef _USE_VP_TEXCOORD1
++#define _VKA_U1 + vin_uv1.x
++#else
++#define _VKA_U1
++#endif
++#ifdef _USE_VP_COLOR
++#define _VKA_C + vin_color.x
++#else
++#define _VKA_C
++#endif
++#ifdef _USE_SKINNING
++#define _VKA_W + vin_weights.x
++#else
++#define _VKA_W
++#endif
++#define VIN_KEEPALIVE vec4((vin_position.x _VKA_N _VKA_T _VKA_U0 _VKA_U1 _VKA_C _VKA_W) * float(drawCount == -9999), 0.0, 0.0, 0.0)
++
+ /*----------- Outpt --------------*/
+ 
+ #ifdef _USE_GPROGRAM

+ 353 - 0
patches/04-runtime-hook/bakin_minimal.c

@@ -0,0 +1,353 @@
+/*
+ * 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.<pid>.log.
+ *
+ * README has the long version. build:
+ *   gcc -O2 -shared -fPIC -o bakin_minimal.so bakin_minimal.c -ldl -lpthread
+ */
+#define _GNU_SOURCE
+#include <stdio.h>
+#include <stdlib.h>
+#include <stdint.h>
+#include <string.h>
+#include <strings.h>
+#include <unistd.h>
+#include <link.h>
+#include <sys/mman.h>
+#include <sys/syscall.h>
+#include <sys/stat.h>
+#include <dirent.h>
+#include <fcntl.h>
+#include <time.h>
+#include <dlfcn.h>
+#include <pthread.h>
+
+/* 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. */
+}

BIN
patches/04-runtime-hook/ibl_brdf_lut.bmp


+ 86 - 0
patches/05-wayland-keyboard.sh

@@ -0,0 +1,86 @@
+#!/usr/bin/env bash
+# Fix 5 (Wayland only): let the game window receive keyboard focus.
+#
+# On KDE Plasma Wayland (via Xwayland) the game's top window advertises the ICCCM
+# "Globally Active" input model (WM_HINTS input=False + WM_TAKE_FOCUS), so the
+# compositor never hands it Wayland keyboard focus; mouse works (position-based)
+# but the keyboard does not. Setting Wine's X11 driver to not use take-focus makes
+# the window advertise the passive model (input=True) and the compositor grants
+# keyboard focus.
+#
+#   HKCU\Software\Wine\X11 Driver  ->  "UseTakeFocus"="N"
+#
+# Harmless on native X11 sessions. Must be applied with the prefix's wineserver
+# stopped so it sticks. This edits the Proton prefix's user.reg directly (no wine
+# tooling needed). Backup written as user.reg.bak-<timestamp>.
+set -euo pipefail
+
+GAME_ROOT="${1:?usage: 05-wayland-keyboard.sh <game-root-dir>}"
+APPID=2326780
+PFX="$GAME_ROOT/../../compatdata/$APPID/pfx"
+REG="$PFX/user.reg"
+[ -f "$REG" ] || { echo "!! prefix not found: $REG (launch the game once so Proton creates it)"; exit 1; }
+
+if grep -qa 'UseTakeFocus"="N"' "$REG"; then
+  echo "== Fix 5: already applied (UseTakeFocus=N)"; exit 0
+fi
+
+# stop only this prefix's wineserver, matched via WINEPREFIX in its
+# environment, so the value sticks; other prefixes are left alone
+kill_prefix_wineserver() {  # <pfx-dir>
+  local pfx pid env_pfx
+  pfx="$(cd "$1" 2>/dev/null && pwd)" || return 0
+  for pid in $(pgrep -x wineserver 2>/dev/null || true); do
+    env_pfx="$(tr '\0' '\n' < "/proc/$pid/environ" 2>/dev/null \
+               | sed -n 's/^WINEPREFIX=//p' | head -1 || true)"
+    if [ -n "$env_pfx" ] && [ "${env_pfx%/}" = "$pfx" ]; then
+      kill -9 "$pid" 2>/dev/null || true
+    fi
+  done
+}
+kill_prefix_wineserver "$PFX"
+cp "$REG" "$REG.bak-$(date +%s)"
+
+python3 - "$REG" <<'PY'
+import sys, time
+path = sys.argv[1]
+sec = r'[Software\\Wine\\X11 Driver]'   # literal text in user.reg (backslashes doubled)
+key = '"UseTakeFocus"="N"\n'
+
+def is_global_x11_header(line):
+    # the global section only, not AppDefaults\...\X11 Driver; Wine may append
+    # a modification time after the bracket
+    s = line.strip()
+    return s == sec or s.startswith(sec + ' ')
+
+with open(path, 'r', encoding='utf-8', errors='surrogateescape') as f:
+    lines = f.readlines()
+
+out, i, done = [], 0, False
+while i < len(lines):
+    line = lines[i]
+    if not done and is_global_x11_header(line):
+        out.append(line); i += 1
+        if i < len(lines) and lines[i].startswith('#time='):   # keep the timestamp line
+            out.append(lines[i]); i += 1
+        out.append(key)
+        while i < len(lines) and not lines[i].lstrip().startswith('['):
+            if not lines[i].strip().startswith('"UseTakeFocus"'):  # drop any stale copy
+                out.append(lines[i])
+            i += 1
+        done = True
+        continue
+    out.append(line); i += 1
+
+if not done:                                                   # section absent: append it
+    now = int(time.time())
+    filetime = (now + 11644473600) * 10000000                  # Wine #time= is a hex FILETIME
+    out.append('\n' + sec + ' %d\n' % now)
+    out.append('#time=%x\n' % filetime)
+    out.append(key)
+
+with open(path, 'w', encoding='utf-8', errors='surrogateescape') as f:
+    f.writelines(out)
+print("wrote UseTakeFocus=N")
+PY
+echo "== Fix 5: applied (Wayland keyboard focus). Relaunch the game."

+ 49 - 0
uninstall.sh

@@ -0,0 +1,49 @@
+#!/usr/bin/env bash
+# Revert every change made by install.sh, restoring the *.orig backups.
+set -euo pipefail
+GAME_ROOT="${1:?usage: ./uninstall.sh <game-root-dir>}"
+D="$GAME_ROOT/data"
+APPID=2326780
+
+restore() {  # <file>
+  if [ -f "$1.orig" ]; then
+    mv -f "$1.orig" "$1"; echo "restored $(basename "$1")"
+  else
+    echo "no backup for $(basename "$1") (skipped)"
+  fi
+}
+
+# kill only this prefix's wineserver, matched via WINEPREFIX in its
+# environment; a blanket pkill would lose unflushed registry changes in
+# unrelated Wine prefixes
+kill_prefix_wineserver() {  # <pfx-dir>
+  local pfx pid env_pfx
+  pfx="$(cd "$1" 2>/dev/null && pwd)" || return 0
+  for pid in $(pgrep -x wineserver 2>/dev/null || true); do
+    env_pfx="$(tr '\0' '\n' < "/proc/$pid/environ" 2>/dev/null \
+               | sed -n 's/^WINEPREFIX=//p' | head -1 || true)"
+    if [ -n "$env_pfx" ] && [ "${env_pfx%/}" = "$pfx" ]; then
+      kill -9 "$pid" 2>/dev/null || true
+    fi
+  done
+}
+
+restore "$D/kmyCore.dll"
+restore "$D/bakinplayer.exe"
+restore "$D/lib/sysresource/shader/include/v2_vpcommon.cgh"
+
+# fix 5 added a single registry value, so remove just that line; restoring a
+# whole user.reg backup would wipe every registry change made since install
+PFX="$GAME_ROOT/../../compatdata/$APPID/pfx"
+REG="$PFX/user.reg"
+if [ -f "$REG" ] && grep -qaxF '"UseTakeFocus"="N"' "$REG"; then
+  kill_prefix_wineserver "$PFX"   # so wineserver can't flush over our edit
+  tmp="$(mktemp)"
+  grep -vaxF '"UseTakeFocus"="N"' "$REG" > "$tmp"
+  cp "$tmp" "$REG"; rm -f "$tmp"
+  echo "removed UseTakeFocus=N from user.reg (.bak-* backups kept as fallback)"
+else
+  echo "UseTakeFocus=N not present in user.reg (nothing to revert)"
+fi
+
+echo "Done. Also clear the game's Steam Launch Options if you set them."