TECHNICAL CASE NOTE CMVS · セーブサムネイル · D3D9 / wined3d 解決済み・2026-07-14実機確認

セーブサムネイルが黒くなる

Present後に失われるフレームを、GetBackBuffer + LockRectへ供給する

§1

黒サムネイルと二次症状を分ける

CMVS(ビジュアルノベル用ゲームエンジン)で発生した直接の症状は、黒いセーブサムネイルだった。ほかの4症状は、黒を回避するために追加した補償処理から発生していた。

保存直後は一瞬正しく見える

要因: shadow-fillがlast-goodフレームを注入し、その場の表示だけを置き換えていた

開き直すとサムネが壊れる

要因: Swiftの後追いpatchが.datを古いスナップで書き換え、上下逆の旧patch結果も混在していた

メニューバー帯が混入する

確認結果: Windows実機でも帯とオーバーレイが写るため、CMVSの取得範囲として正常だった

新規セーブが他スロットを汚染

要因: Swift patchが最新mtimeのファイルを選び、コンパニオンファイルまで書き換えていた

上書きしてもサムネが更新されない

要因: last-goodキャッシュが最大120秒更新されず、同じフレームを再利用していた
ファイル確認: 調査開始時点でディスク上に残っていた47セーブをデコードすると、黒いサムネイルは存在しなかった。残っていたのは、凍結フレームの複製、上下逆の旧patch結果、メニューバー帯を含む画像の3種類だった。画面上の黒表示と、補償処理後に永続化されたデータを分けて調べる必要があった。
§2

検証した仮説

各仮説を、実ファイル、実行ファイル、実機ログの順に照合した。

2026-05 初旬

JPEG マーカー説棄却

.dat内のFF D8 FFをJPEGマーカーとみなしたが、デコードすると圧縮データ中の一致だった。

2026-05-13

GDI BitBlt キャプチャ説棄却

window DCへのBitBltを疑ったが、cmvs64.exe内のBitBltはクリップボードコピー用の1箇所だけだった。画面取得にGDIを使う経路は確認できなかった。

2026-05-26

GetRenderTargetData → LockRect 説半分正解

経路自体は存在したが、クリップボード/スクリーンショット用だった。セーブサムネイルはGetRenderTargetDataを通らない別の取得関数を使っていた。

2026-05 中旬

192×108 per-slot 注入撤退(正しい判断)

スロット描画順を数えて192×108のプレビューへ注入したが、描画順が変動して別スロットへ入ったため撤回した。encode入力を192×108サーフェスとみなした前提も一致しなかった。

2026-06-05

lockable RT shadow + last-good フィル場所は正解、意味論が誤り

黒画面は回避できたが、「黒なら明るいフレームを返す」という輝度と鮮度の判定だった。そのため凍結、複製、誤った上書きが発生し、Windows上の取得動作を再現できなかった。

2026-05 〜 06-05(再有効化)

Swift .dat 後追いパッチ退役

保存後に最新mtimeの.datをランチャー側で書き換える方式は、対象ファイルを誤判定し、CMVSのコンパニオンファイルまで変更した。根本経路の修正後は退役させた。

2026-06-11

Present 後のバックバッファ破棄確定

CMVSはセーブ/ロード画面を開くと、GetBackBuffer + LockRect(READONLY)で直前のフレームを読む。Wine/MetalではPresent後の内容が失われ、独立したGPU bltとCPU mapの両方が黒だった。

0x810定数を使う取得関数の静的解析、観測専用buildの実機ログ、Windows実機比較の3条件が一致した。

§3

3つの実行条件でフレーム経路を比較する

Renderから.dat保存までの5段階で、Present後のフレームがどこに残るかを比較する。

Windows、修正前、修正後の比較
🎨
Render
シーン描画
📤
Present
画面へ表示
🗂
BackBuffer
直前フレーム
🔒
LockRect
画面open時
💾
.dat
LZSS encode
last-presented copy
直前フレームを保存 ✓ Present後もバックバッファを読めるため、CMVSの直接LockRectが成立する。帯やオーバーレイを含む取得結果がWindows実機と一致した。
🎨
Render
シーン描画
📤
Present
画面へ表示
🗂
BackBuffer
内容を失う
🔒
LockRect
黒を読む
💾
.dat
LZSS encode
last-presented copy
LockRectの入力が黒 ✗ 補償を止めた観測条件では、Present後のバックバッファがGPU probeとCPU mapの両方で黒く、CMVSのencodeへ渡る入力も黒になった。
🎨
Render
シーン描画
📤
Present
画面へ表示
🗂
BackBuffer
内容を失う
🔒
LockRect
copyを供給
💾
.dat
LZSS encode
Presentごとに直前フレームを保持
保持したフレームを供給 ✓ Presentごとに保持したcopyを、CMVSのREADONLY LockRectへそのまま渡す。輝度、サイズ、鮮度による代替判定は使わない。
§4

調査手順

実ファイル、実行ファイル、実機ログ、Windows実機の順に、取得経路と期待値を確定した。

セーブ47件を全数デコードする

CSV2/LZSSデコーダで全サムネイルを可視化した。黒い画像が永続化されていないことと、正しい入力ならCMVSの圧縮・保存処理が成立することを確認した。

実行ファイルから取得経路を特定する

PE importとD3D9 vtable callを調べ、LockRectのflag 0x810を使う取得関数を3箇所に限定した。セーブ用経路がGetBackBuffer + LockRectであることを特定した。

補償処理を止めた観測buildで測る

補償処理を停止し、生成、転送、lockを同じ時系列で記録した。READONLY lock時にCPU mapと独立GPU bltを測り、両方が黒であることを確認した。

Windows 実機でリファレンスを確定

Windows実機でもメニューバー帯とオーバーレイを含む画像が保存された。追加加工せず、Windowsと同じ取得範囲を合格条件にした。

Windowsの取得動作を再現する

Presentごとにバックバッファを保持し、CMVSのREADONLY lockへ同じ範囲を供給した。CMVS専用gateの内側で動作し、輝度や鮮度による置換判定は使わない。

§5

観測結果

観測 A ── 修正前のreadback

[f   831] GetBackBuffer sc=0 idx=0 -> 7AA360
[f   831] LockRect surf=7AA360 1280x720
          flags=0x810 acc=0xd bind=0x20
[f   831] GPU_PROBE  blt_avg=0   ← GPU側でも黒
[f   831] LOCK_MAP   map_avg=0   ← CPU mapも黒
GetDC / GetRTData / StretchRect : 0 回

ロック対象はバックバッファ。内容は CPU に届かないのではなく、Present 時点で GPU からも消えていた。

観測 B ── cmvs64.exeの取得経路

0x14001c59a  call [rax+0x90]  ; GetBackBuffer
0x14001c617  call [rax+0x68]  ; LockRect(0x810)
             ; GetRTData を経由しない第3変種
0x14009095b  call BitBlt      ; exe 唯一のBitBlt
             ; → SetClipboardData(クリップボード)

mov r9d, 0x810 は exe 全体で3箇所のみ。すべてフレーム取得関数クラスタ内。GDI 画面キャプチャは存在しない。

観測 C ── last-presented供給後

[f   503] BB_SERVE_LAST_PRESENTED 1280x720
[f   503] LOCK_MAP map_avg=134  ← 明るい
save032.dat cs1=61703 native LZSS 喫茶店の場面
save033.dat cs1=66556 native LZSS 森の場面
save999.dat(コンパニオン)無傷

2スロットと上書きで別のシーンを保存し、CMVSのnative LZSS形式とコンパニオンファイルの無変更を確認した。

§6

再利用できる知見

資料より実体を優先する

記録上の完了状態を、実ファイル、実行ファイル、実際にロードされるDLLと照合する。配備物はhashとprovenanceで固定する。

補償処理と根本経路を分ける

5症状のうち4つは回避処理から発生していた。観測時は補償を止め、原因経路を確認してから不要なpatchを撤回する。

輝度判定ではなく取得動作を再現する

「黒なら別画像を返す」のではなく、Present後の直前フレームをREADONLY lockへ供給する。入力の意味に沿うと、凍結や誤上書きを避けられる。

結果が分かれる計測を先に置く

CPU mapと独立GPU bltを同じ時点で測ることで、「転送だけの問題」と「GPU側で既に消失」を区別できた。

リファレンス環境で期待値を決める

Windows実機でも帯やオーバーレイが保存された。見た目を独自に整えるのではなく、リファレンスと同じ取得範囲を合格条件にする。

§7

現在の状態と制約

現行構成
  • shared d3d9は、CMVS用のsnapshot writer(画像を書き出す経路)とread/inject経路を含む、採用済みの組み合わせを使用する。
  • CMVSのgame profileだけがMELAMMU_CMVS_THUMBSを有効にする。ほかのengineではdefault-off。
  • この組み合わせは2026-07-14に、CMVS回帰からの復旧を実機確認済み。
適用範囲
  • Wine側writerを除去してScreenCaptureKitへ一本化する案は、CMVSが黒へ回帰したため撤回済み。
  • 本稿のlast-presented経路を、DXVK、別engine、Melammu Galleryの手動スクリーンショットへ一般化しない。
  • 実際に配備するd3d9.dllは、runtime manifestで選択された採用済みの組み合わせを正とする。