Răsfoiți Sursa

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 2 luni în urmă
comite
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."