Skip to content

パフォーマンス / 整合性 / SeedQueue 仕様整合の監査と是正(14ユニット)+ lock 重み対応 - #1

Closed
bafv4 wants to merge 55 commits into
mainfrom
dev
Closed

bafv4 wants to merge 55 commits into
mainfrom
dev

Conversation

@bafv4

@bafv4 bafv4 commented Aug 26, 2026

Copy link
Copy Markdown
Owner

main から dev を切り、パフォーマンス・内部整合性・SeedQueue フォーマット整合の3方向でコードベースを監査し、
見つかった問題を14の独立したユニットに分けて是正しました。あわせて監査中に判明した未実装機能(lock 画像の
重み付き抽選)と、ヘッダーのリリースリンク追加を含みます。

最重要: 実機で黙って壊れていたバグ

sounds.json のサウンド名に .ogg が付いていて、カスタム音が全イベントで無音になっていました。

SeedQueue 本体の assets/seedqueue/sounds.json"seedqueue:lock_instance"名前空間付き・拡張子なし
書かれています。Minecraft はサウンド名に .ogg を自動付加するため、従来の出力 "lock_instance.ogg"
assets/seedqueue/sounds/lock_instance.ogg.ogg を探しに行き、ファイルが見つからず無音になります。
REWRITE_SPEC.md:373 の記述自体が誤っていたので、コードと併せて仕様書も訂正しました。
既存パックとの後方互換のため、parsePackseedqueue:<event> / <event> / 旧 <event>.ogg の3形式を受け付けます。

SeedQueue 実ソースで裏を取った仕様

Layout.java / LockTexture.java / sounds.json を実際に取得して確認し、REWRITE_SPEC.md を訂正しました。

  • locked / preparing から rows/columns を省略すると NPE になり、カスタムレイアウト全体が破棄されます。
    Group.fromJsonmain にだけ SeedQueue.config.rows/columns を既定値として渡すため、
    省略してよいのは main だけです(省略すると SeedQueue 側のユーザ設定値にフォールバック)。
  • x/y/width/heighthas() 検査なしで読まれるため、1つでも欠けると NPE。
  • rows/columns が負だと NegativeArraySizeExceptionmain.cosmetic: trueIllegalArgumentException
    いずれもレイアウト全体が破棄されます。
  • lock.png が無いと以降を読まない」という記述は不正確でした。リソースパックは MOD のアセットに重なるため、
    パックが lock-1.png だけを入れると1枚目に MOD 既定の lock.png が使われます(実装方針は従来どおり)。

変更内容

SeedQueue フォーマット整合

  • sounds.json のサウンド名規約を修正(上記)
  • useGrid=false のとき main に限り rows/columns を省略。再インポート時の非対称も解消
  • rows/columns を export 境界でクランプ、positions[] に最小サイズクランプを追加
  • インポート時のフォールバック矩形が 1920×1080 決め打ちだったのを、実際の解像度から組むよう修正
  • パックフォルダごと圧縮された zip / 親フォルダ選択でのフォルダ読込に対応(ルート正規化)

整合性・データ保護

  • 非有限値(1e999 → Infinity、NaN)が state に入り custom_layout.jsonnull が出る経路を封鎖。
    既に壊れた state も hydrate 時に修復
  • 解像度の縮小で 0 サイズのエリアが出力される問題を修正
  • 永続化 JSON が「無い」ときに保存済みバイナリを全削除していた処理を停止(データ喪失)
  • write_pack_folder が任意のフォルダを無検証で remove_dir_all していたのにガードを追加
  • バイナリ復元失敗時の契約を統一し、欠けたパックを黙って書き出さないように。復元できなかったデータを通知
  • アップロードの未処理 Promise rejection 3件、背景画像の形式検証を追加
  • Desktop の保存先を tauri-plugin-store に記憶(再起動後も上書き保存が使える)
  • ハイドレート中のトーストが必ず握り潰されていた問題、Web のリセットボタンが処理中に無効化されない問題を修正

パフォーマンス

  • プレビュー canvas を実解像度から表示サイズ × DPR に変更し、rAF で再描画を合体。
    カラーピッカーのドラッグ1イベントごとの MB 級再確保とフル解像度再合成を解消
  • 永続化の書き込みを間引き(ドラッグ1秒あたり 60回 → 4回)
  • Tauri のバイナリ IPC を10進 JSON 配列から生バイトへ(20MB の画像が ~60-70MB の JSON になっていた)
  • LayoutEditor の全体購読を分割、AreaEditor を memo 化
  • ffmpeg の 32MB blob URL を revoke し、アイドル時に解放
  • タブパネルを遅延ロード。初期チャンク 398.55 kB → 349.73 kB(gzip 122.83 → 111.66)

機能追加

  • lock 画像の重み付き抽選.mcmetaseedqueue.weight / defaultWeight)。型だけあって
    読み書きされていなかったため機能していませんでした。UI に重み入力と抽選確率の表示を追加
  • ヘッダーのタイトル横に GitHub の最新リリースページへのリンクを追加

整理

  • i18n の未使用キー削除と共通 UI のハードコード日本語の解消(ja/en 241キー完全一致)
  • バージョンの単一ソース化(package.json"v3.1.0" は semver 違反だったので "3.1.0" に。表示は v 付きのまま)
  • 未使用依存 @tauri-apps/plugin-fs を削除し lockfile を更新

検証

  • tsc --noEmit エラー 0 / eslint error 0(既存 warning 2件のみ)/ vite build 成功 / cargo check 成功
  • dev サーバで core を直接呼び、実データで出力を確認:
    • pack_format: 5custom_layout.json に非整数 0件、内部フラグの漏れなし
    • useGrid=false → main のみ rows/columns 省略、locked は保持
    • sounds: ["seedqueue:lock_instance"] + ogg 配置、往復復元、旧形式の後方互換
    • 壊れた state(rows:NaN, columns:-3, width:0, x:1.7)から rows:1 / columns:1 / width:1 / x:1 を出力
    • 縮小バッキングストアの描画が 1920×1080 / 805×453 / 200×113 でピクセル一致
    • lock 重み 3/5/2 → 30%/50%/20%、.mcmeta の往復、animation セクションの保持

レビュー時の注意

  • Minecraft 実機での読み込み確認は未実施です。 CLAUDE.md が唯一の検証手段としている工程で、
    特に useGrid=false での rows/columns 省略は on-disk フォーマットを変えるため、マージ前の実機確認を推奨します。
  • Desktop(Tauri)の実機動作も未確認です。バイナリ IPC の形式を両方向とも変更しているため、
    ハイドレート・保存・インポートの一通りを確認してください。
  • defaultWeightLockImage から LockImages(コレクション側)へ移動しています。物理的には lock.png に
    載る値ですが、画像側に持たせると並べ替えで1枚目が変わったときに値が別画像へ移り、抽選確率が黙って変わるためです。
  • 遅延ロードしたチャンクの取得失敗(GH Pages 再デプロイ後など)に対する ErrorBoundary は未対応です。

🤖 Generated with Claude Code

bafv4 and others added 30 commits August 25, 2026 22:42
buildPack が custom サウンドを `"<event>.ogg"` として出力していたため、
MC がサウンド名に `.ogg` を自動付加した結果
`assets/seedqueue/sounds/<event>.ogg.ogg` を探しに行き、
カスタム音が全イベントで黙って無音になっていた。

mod 本体の assets/seedqueue/sounds.json(2026-08-25 確認)に合わせて
`"seedqueue:<event>"` を出力する。ogg の配置先
`assets/seedqueue/sounds/<event>.ogg` は変更なし。

parsePack は `seedqueue:<event>` / `<event>` / `<event>.ogg` の 3 形式を
受け付ける(最後は v3.1.0 以前の壊れた出力で作られた既存パックとの後方互換)。
REWRITE_SPEC.md 第6.6章の誤記も併せて訂正。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…にクランプ

REWRITE_SPEC 第6.3.2章「useGrid === false のグループは rows/columns を出さない」が
export に反映されておらず、トグルを切っても custom_layout.json は変わらなかった
(positions をアプリ内で生成する経路が無く、必ず rows/columns 側の分岐に落ちていた)。
main のみ省略し、SeedQueue 側のユーザ設定値へフォールバックさせる。
locked/preparing は第6.3章で rows/columns 必須のため従来どおり出力する。

あわせて rows/columns を 1 以上の整数にクランプする。replaceWallState / applyLayout /
永続化からのハイドレートは型検証を通らず 0・負・NaN が素通りするため、
ディスクに出る直前の buildPack を最後の砦として防御する。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- main の rows/columns が両方とも無いパックを useGrid=false として復元する。
  従来は useGrid=true の 1x1 グリッドに化け、自作パックの再インポートで
  レイアウトが壊れていた(buildPack の省略出力と非対称)。
  分割数は JSON に残らないため、グリッドを戻したときの初期値として
  既定プリセットの rows/columns を入れておく。
  locked/preparing は第6.3章で rows/columns 必須なのでグリッド扱いのまま既定 1 で復元する。
- import した positions[] の width/height を最低 1px にクランプする。
  parseArea 側は既に 1 以上へクランプ済みで、positions だけ 0 を通していた。
- parseMain / parseLocked のフォールバック矩形を createDefaultWallState()(1920x1080 決め打ち)から
  正規化済み resolution の既定プリセットに変える。2560x1440 のパックを locked 省略で読むと
  1920x1080 用の箱が復元され、表示に切り替えた瞬間に縮尺が合わなかった。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
isFullyTransparentImage は任意サイズの lock.png を全画素走査していた。
設計上の対象は 128x128 の透明プレースホルダだが、4096x4096 の lock.png では
67MB の ImageData と 1670 万回のループが import 中のメインスレッドで走る。
辺長 1024 を超える画像は走査せず「透明ではない」と判定して打ち切る。

縮小してから走査する案は採らない。ブラウザの縮小補間は大きな縮小率で画素を取りこぼし、
透明でない画像を透明と誤判定して images を捨てるデータ損失方向の誤りになり得るため。
打ち切り側の誤りは enabled=true で画像を保持するだけで、再エクスポートの結果は変わらない。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
store の hydration(persistAdapter の getItem)は React の初回レンダーより前に
走るため、そこで出す toast.error('toast.restoreFailed') は pusher が未設定で
console フォールバックに落ち、ユーザーには一度も表示されていなかった。
さらに ToastRoot が App のハイドレートゲートより後ろにあり、「読み込み中」画面の
あいだは表示先が存在しなかった。保存データの復元に全面的に失敗したユーザーが
無言でデフォルト状態に落とされる状態だった。

- マウント前のトーストをモジュールレベルのバッファに積み、ToastRoot の
  マウント時に flush する(上限 10 件。splice で空にするので StrictMode の
  二重 effect でも重複しない)。
- ToastRoot を App の外=React ルート(main.tsx)へ移し、ハイドレートゲートに
  依らず常にマウントされるようにした。バレルではなく Toast を直接 import して
  エントリチャンクが UI 一式を巻き込まないようにしている。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Import/Export には disabled={busy} があるのに Reset だけ無く、エクスポート中に
リセットできてしまっていた。worker は古い state のままパックを作り切るため、
エディタが空になったあとに「エクスポートできました」と成功トーストが出る。
Desktop 側の FileEditor のリセットボタンとも挙動が揃っていなかった。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Select の value を store の解像度からのみ導出しており customMode を無視していた
ため、1920x1080 の状態で「カスタム」を選ぶと入力パネルは開くのにドロップダウン
の表示だけが即 FHD に戻っていた。

- value を customMode 優先で導出する。
- あわせて、reset/import で解像度がプリセット値に変わったときに customMode を
  解除する(解除しないと今度は「カスタム」表示のまま実 state と食い違う)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Modal の閉じるボタン aria-label と、Select の placeholder 既定値 / 空リスト文言が
ハードコードされた日本語のままで、英語表示でも日本語が出ていた。
`common.close` / `common.selectPlaceholder` / `common.noOptions` を ja / en 双方に
用意して置き換える。Modal の aria-label は全モーダル共通で効くため、スクリーン
リーダー利用時の英語表示も併せて直る。

あわせて参照が 0 件の翻訳キーを整理した。動的キー生成(`sound.events.*` /
`sound.eventInfo.*` / `sound.modeOptions.*` / `background.type.*`)は grep で確認の上
すべて残している。`toast.restoreFailed` も別途対応中のため残す。

削除したキー:
- common.add / remove / delete / ok / open / clear / change / edit / save /
  processing / loading / yes / no
- fileEditor.save.currentTargetEmpty
- background.selectStopAlias
- toast.readZipFailed / toast.writeCanceled

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`vite.config.ts` のコメントは「tauri.conf.json も package.json 参照にしてある」と
書いていたが実態と異なり、バージョンが 3 箇所に散っていた。
また `package.json` の `"v3.1.0"` は先頭の `v` が semver 違反で、npm/pnpm のツールや
semver 比較が壊れる状態だった。**バージョン番号自体は上げていない(3.1.0 のまま)。**

- `package.json`: `"v3.1.0"` → `"3.1.0"`(素の semver)
- `src-tauri/tauri.conf.json`: `"version": "../package.json"` 参照に変更
  (Tauri 2 は `version` にパスを取れ、config のディレクトリ基準で解決される)
- `src-tauri/Cargo.toml`: Cargo に外部ファイル参照の手段がないため、
  package.json と手動で合わせる旨をコメントで明示
- 表示は `v3.1.0` のまま保つため、`v` 接頭辞は AppHeader / AboutModal の表示側で付与
- `vite.config.ts` / `vite-env.d.ts` のコメントを実態に合わせて修正

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
フロント側に import が 0 件、Rust 側にも `tauri-plugin-fs` の登録は無く
(`src-tauri/src/lib.rs` は `std::fs` のみ)、`src-tauri/capabilities/default.json`
にも `fs:` 権限を出していない。
CLAUDE.md の「fs プラグインのフロント API スコープを避け、書き込みは Rust 側
`write_pack` に寄せる」方針とも整合するため依存から外す。

`pnpm-lock.yaml` はまだ本パッケージを importer に含んでいる。
CI は `pnpm install --frozen-lockfile` なので、**統合時に `pnpm install` で lock を
再生成してからでないと `ERR_PNPM_OUTDATED_LOCKFILE` で落ちる。**
(並列作業中に node_modules を壊さないため、本コミットでは lock を触っていない)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
プレビューでエリアをドラッグしながらレイアウトタブを開いていると、1 フレームごとに
main + locked + preparing×N の AreaEditor が丸ごと 2 回再描画されていた。

- AreaEditor を memo 化し、area が変わっていないエディタの再描画を止める。
- props → ローカル入力へのミラーを 4 フィールド一括の effect からフィールド単位に分割し、
  値が変わったフィールドだけ set する。
- syncLocalNumber を追加。受け取った値がローカル入力のパース結果と一致する場合は
  同じ文字列を返して React の再描画を bail out させる。"1." や "01" のような
  入力途中の表記を巻き戻さない。

commit* のバリデーションロジックは変更していない。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`file.arrayBuffer()` が try ブロックの外にあり、ピック後・読み取り前にファイルが
消える(USB 取り外し / ネットワークドライブ切断 / 権限剥奪)と NotReadableError が
`void handleFile(f)` に飲み込まれ、ユーザには何も起きないように見えていた。
読み取りを try/catch で囲み `sound.readFailed` トーストで通知する。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LayoutEditor だけが useWallStore((s) => s.wall) でドメイン state 全体を購読しており、
背景・音声・lock 画像など無関係な更新でもレイアウトタブ全体が再描画されていた。

- layout.main / layout.locked / layout.preparing / resolution /
  replaceLockedInstances を個別に購読する。
- preparing[i] の onChange / onRemove を useMemo で index ごとに作り、件数が
  変わったときだけ作り直す。毎レンダー新しい無名関数を渡すと memo 済みの
  AreaEditor が全件再描画されるため。
- 「preparing を追加」ボタンと Select の options も安定参照にする。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
背景レイヤの画像だけが取り込み時の例外処理と形式チェックを持っておらず、
`accept="image/*"` を通り抜けた SVG や破損画像がそのまま state に入っていた。
そのバイト列は renderBackground の createImageBitmap で reject するため、
プレビューは無言で前フレームのまま・エクスポートだけが失敗する状態になっていた。

- 追加/差替の両経路で SVG/SVGZ を弾き、`createImageBitmap` によるデコード検証を通す。
- 読み取り・デコードの失敗を try/catch で受け、トーストで通知する
  (従来は `void handleAddImage(f)` が未処理 rejection として飲み込んでいた)。
- 検証中は追加/差替ボタンを無効化し、二重ピック時に後発の結果が先発に
  上書きされるのを防ぐ(LockImagesEditor の pickingNow と同じ方針)。

pack.png / lock.png と違い PNG 再エンコードはしない。背景レイヤの元バイトは
パックに入らず canvas 合成後の background.png だけが出力されるため、PNG 化しても
出力は変わらず state / IndexedDB が肥大するだけになる。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
配布されている SeedQueue パックはパックフォルダごと zip 圧縮されていることが多く、
エントリが `MyPack/pack.mcmeta` `MyPack/assets/seedqueue/...` になる。VirtualPack の
キーはパック内パス前提のため `PACK_PATHS.*` の参照が全て外れ、正当なパックなのに
parsePack が「custom_layout.json が見つかりません(SeedQueue パックではない可能性が
あります)」で失敗していた。Desktop でユーザがパックフォルダの親を選んだ場合も同様。

`core/packRoot.ts` に純関数 `normalizePackRoot` を新設し、zip / フォルダ両方の読込経路
(`zipFileToVirtualPack` / `folderRecordToVirtualPack`)から通す。

- キー整形(`\` → `/`、`.` / 空セグメントの畳み込み)と、ディレクトリエントリ・
  `..` を含むキー・`__MACOSX/` 等のノイズの除去。
- ルートに `pack.mcmeta` も `assets/` も無く、その階層のフォルダが 1 つに定まるなら
  その prefix を剥がす。多段ネストは最大 8 段まで繰り返す。同階層のファイル
  (README 等)は判定から除外して剥がさず残す。挙動は JSDoc に明記。
- Desktop のフォルダ読込では剥がした分だけ実際のパックルートが深くなるため、
  `PackReadResult.rootPath` として上位へ返す。ここを選択パスのままにすると
  「上書き保存」が親フォルダを丸ごと remove_dir_all してしまう。パック名の表示にも
  実フォルダ名を使う。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
プレビューの背景 canvas をこれまで実解像度(既定 1920×1080)で確保していたため、
カラーピッカーのドラッグ・不透明度/角度スライダー・transform の数値入力といった
高頻度な入力の 1 イベントごとに 8MB 級のバッキングストア再確保+ゼロ埋めと、
フル解像度での全レイヤ再合成が走っていた。実際の表示幅は 600〜900px 程度。

- バッキングストアを「表示サイズ × devicePixelRatio」(実解像度で頭打ち、DPR 上限 2)にする。
  エクスポートは buildPack が独立にフル解像度で描くのでプレビュー品質とは無関係。
- canvas.width/height はサイズが変わったときだけ代入する(代入自体が再確保を伴うため)。
- 再描画を requestAnimationFrame で 1 フレームに合体させ、追い越されたフレームは
  cleanup の cancelAnimationFrame + 既存の isCancelled で捨てる。
- devicePixelRatio の変化(別 DPI モニタへの移動 / 表示スケール変更)を matchMedia で
  購読する。CSS px サイズは変わらず ResizeObserver も鳴らないため、これが無いと
  古い DPR のまま取り残されてボヤける。
- renderBackgroundToCanvas はレイヤ座標を常に resolution 座標系で解釈し、
  canvas.width/resolution.width の scale を掛けて写すようにする。clear は
  無変換で行う(scale 適用後だと縮小時に消し残る)。buildPack 側は canvas を
  resolution そのままで確保しているので scale=1、出力は変化しない。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`a.click()` と同じ tick で `URL.revokeObjectURL` していた。Firefox と一部の Safari は
ダウンロード開始が非同期で blob URL がその後も生存している必要があり、即時 revoke すると
エラーも出さずにダウンロードが中断されることがある。実機検証は Firefox で行っており、
Web 版の export 経路はここしかないため実害が大きい。1 秒後に解放するよう遅延させた。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MP3 を 1 回変換しただけで、32MB の wasm blob URL とインスタンス化済み
wasm ヒープ(実測 RSS 100MB 超)をセッション終了まで抱え続けていた。

- `ffmpeg.load()` が解決した直後に core/wasm 両方の blob URL を revoke。
  load() は Worker 側の wasm インスタンス化まで終えてから解決するので安全。
- 最後の変換から 5 分アイドルなら `terminate()` し、`_ffmpeg` / `_loadPromise`
  を null に戻して次回要求で再ロードさせる。
- `terminate()` は未完了 Promise を同期 reject するため、キュー投入〜完了を
  `_pending` で数えて進行中は解放しない(発火時にも再確認)。
- `load()` 失敗時は Worker が生成済みのまま捨てられていたので terminate を追加。
  blob URL も途中失敗時に取りこぼさないよう try/finally に入れ直した。

直列変換キューと共有ロード promise の構造は従来どおり(変更なし)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
store の mergeAreaPatch は常に新しい area/layout オブジェクトを作るため、
整数に丸めた結果が前回と同じでも再レンダリング+永続化まで走っていた。
直前に dispatch した整数セルとスナップヒットを DragState に持ち、
すべて同値なら早期 return する。スナップ線に磁着している間(閾値内の移動が
すべて同じセルに丸められる)と、1 CSS px 未満しか動かない高レートの
ポインタ入力で効く。

あわせて dispatch / オーバーレイに渡す値を floorCell 済みのセルに統一する
(store 側の floorArea と同値、オーバーレイ枠が実際の描画位置と厳密に一致する)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`TabsContent` はアクティブなタブしか描画しないのに、6 つのエディタが静的
import されていたため `react-colorful`(BackgroundEditor → CropModal)と
`@ffmpeg/ffmpeg` ラッパ(SoundsEditor → audio/convert)まで初期チャンクに
入っていた。

6 タブすべてを lazy 化し、パネル領域に Suspense を 1 つ置く。フォールバックは
既存の i18n キー `common.loading` を使い、min-h でパネルが潰れないようにする。

初期チャンク: 396.11 kB → 339.51 kB(gzip 123.39 → 108.58 kB)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
記憶した上書き保存先が前回終了後もまだ存在するかを起動時に確かめるため、
指定パスが実在するフォルダかを返す読み取り専用コマンドを公開する。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
常時表示される「実解像度 …/プレビュー … Shift でスナップ無効」が
日本語ベタ書きだったため、preview.scale / preview.snapHint を ja/en に追加する。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
保存先パス 1 個だけを appDataDir/save-target.json に持たせる薄いラッパ。
@tauri-apps/* の静的 import は desktopSaveTarget.ts に閉じ、adapters/index.ts
からは動的 import でのみ到達させる(Web バンドルに混入させない)。
読み出しは path_is_dir で実在検証し、消えていれば記憶ごと破棄する。
store 操作は 1 本の Promise 鎖で直列化し、復元中の削除が新しい書き込みを
後から潰さないようにした。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
setSourceFolder を唯一の入口にまとめ、React state と tauri-plugin-store の
記憶を常に揃える。起動時に前回の保存先を復元することで、再起動後も「上書き
保存」が使えるようになった(従来は毎回「名前を付けて保存」からやり直し)。

復元した保存先への初回の上書きだけ確認ダイアログを挟む。編集内容の復元に
失敗して既定 state で起動した場合でも保存先は復元されうるため、無確認だと
write_pack_folder が root ごと削除してユーザのパックを消してしまうため。
記憶の書き込み/破棄に失敗した場合もトーストで知らせる。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
永続化からの復元に失敗した BinaryRef(未ロード)の扱いが renderBackground
(スキップ)と buildPack(throw)で食い違っていた。契約を 1 箇所に定義し、
「表示はスキップ/出力は中止」に揃える。

- core/binaryFields.ts を追加。BinaryFieldKind と
  collectUnresolvedBinaryFields(buildPack の出力条件と 1:1 で一致)を提供する。
- buildPack の resolveInline は throw を維持しつつ、到達=不変条件違反であり
  UI が事前検出する旨を明記する。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BinaryStorage から読み出せなかったフィールドは console.warn だけで黙って
落とされ、ユーザには「開き直したら背景画像とカスタム音が消えていた」としか
見えなかった。欠落を BinaryRestoreReport に集計し、UI が 1 回だけ回収できる
takeBinaryRestoreReport() を追加する。

- エントリ不在(復元不能)は missing、読み出し失敗(未ロード)は unresolved に
  分けて数える。
- walk の transform に種別(BinaryFieldKind)を渡し、集計単位を持たせる。
- walk 自体が落ちた場合は state 全体が既定に戻るため、途中の集計は捨てる
  (「一部だけ失われた」と誤解させない)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
復元失敗が誰にも伝わらず、しかも未解決 ref が残ると以降のエクスポートと保存が
生の英語メッセージで全部失敗していた。両方をユーザに分かる形に変える。

- ハイドレート完了後に takeBinaryRestoreReport() を 1 回だけ回収し、件数と種別を
  含む翻訳済みトーストで通知する。ToastRoot の登録順に依存しないよう 0ms タイマー
  越しに出す(StrictMode でも回収は 1 回)。
- 書き出し / 保存 / 上書き保存は collectUnresolvedBinaryFields を事前に通し、
  未ロードのフィールド名を示して中止する。欠けたパックは Minecraft 上で黙って
  効かなくなるため、そのまま書き出さない。
- 新規 i18n キー: binaryField.*, toast.restoreMissing / restoreUnresolved /
  unresolvedBinaries(ja / en 両方)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
reorderBackgroundLayers / reorderLockImages が「並び替え対象外を末尾に残す」
ループの中で ids.includes() を回しており、要素数の 2 乗に比例していた。
lock 画像は最大 255 枚(parsePack)まで増えうるため採用済み id を Set で持つ。

あわせて ids に重複が含まれたときに同じ要素が二重に並ぶ問題も解消する。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Math.max(1, Math.floor(Number(x) || 0))` は Infinity を素通しし、
`Math.max(1, NaN)` は 1 ではなく NaN を返す。`<input type="number">` は
"1e999" を正当な値として返すため、これが実際に state へ入っていた。
非有限値が layout に入ると JSON.stringify が null に潰すため、リロード後は
`x: null` のまま残り、どの経路でも修復できなくなる(永続化 state の恒久破壊)。

- `toSafeInt(value, fallback, min, max)` を追加。非有限・null・空文字は fallback、
  それ以外は floor して [min, max] にクランプする。上限は Canvas 実用上限の
  MAX_DIMENSION=16384(有限でも 1e9 は OOM する)。
- `floorInt` / `floorCell` / `floorArea` を toSafeInt 経由にし、幾何の不変条件
  (整数・非有限禁止・width/height >= 1・rows/columns >= 1・padding >= 0)を
  **floorArea / floorCell の一箇所**で担保する形にした。
- これにより `scaleArea` の 0 サイズ問題も解消する。縮小方向の解像度変更で
  `Math.floor(4 * 0.1667) = 0` となり width: 0 が state に焼き付いていた
  (元の解像度に戻しても 0 のまま復元されない)。
- 背景レイヤの transform スケールも floorCell 経由に統一。
- `safeResolution` を追加し、`scaleStateForResolution` は倍率を出す前に解像度を
  正規化する。0 や非有限は「不明」として既定解像度に倒す(0 を 1 にクランプすると
  to/from が桁違いになり別の壊れ方をするため)。
- `normalizeWallStateNumbers` を追加。既に壊れて永続化された state を
  hydrate 境界で修復するために使う。
- `DEFAULT_RESOLUTION` を state.ts に切り出して共有。
- 実装が無い ambient 宣言だった `validateWallState` / `isValidGridCount` /
  `isValidAreaGeometry` を実装した。宣言のみだと型は通るのに呼ぶと実行時に落ちる。
  正規化(coords.ts)と違い「壊れているかを判定するだけ」の述語として定義し、
  state.ts → coords.ts の循環 import を避けている。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
buildGroup は先頭で `floorArea(area)` の結果を `f` に持っているのに、
rows/columns/padding だけ生の `area` から `floorInt` していた。
floorInt は座標用で下限が負のため、rows: 0 や rows: -3 がそのまま
custom_layout.json に出力され得る。rows/columns は 1 以上の整数必須
(第6.3.2章 / CLAUDE.md)で、破ると実機で黙って壊れたパックになる。

不変条件を担保済みの `f.rows` / `f.columns` / `f.padding` を使う。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bafv4 and others added 24 commits August 25, 2026 23:01
`<input type="number">` は "1e999" を指数表記として受け付け、ブラウザが
Infinity 相当の値を返す。commit 側の `Math.max(1, Math.floor(Number(v) || 0))`
はこれを素通しし、`Math.max(1, NaN)` は NaN を返すため、どちらも state を壊す。

- AreaEditor の commitX/Y/Width/Height/Rows/Columns/Padding を `toSafeInt` に統一。
  空欄の扱い(x/y は 0、width/height/rows/columns は 1、padding は 0)は従来どおり。
- PackInfoEditor のカスタム解像度と ImportResolutionDialog の確定処理も同様。
  クランプされた場合に入力欄が古い値のまま残らないよう明示的に同期し、
  input に `max={MAX_DIMENSION}` を付けた。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
hydrate で localStorage の JSON が読めなかった場合、参照集合を空集合とみなして
BinaryStorage の全走査 sweep を走らせていた。JSON が「無い」ことは「参照が空」で
はなく「参照が不明」であり、Web でサイトデータを消した / Desktop で WebView の
プロファイルがリセットされただけで、IndexedDB・appDataDir 側に残るユーザの画像と
変換済み ogg が復旧不能に消えていた(軽量 state とバイナリは独立に消えうる)。

参照が不明なときは何も削除しない方針に変更し、lastReferencedKeys も空集合ではなく
null(不明)にして、次の書き込み以降の増分 GC に委ねる。孤児が残り続けるコストより
喪失回避を優先する。掃除はやめるが BinaryStorage の動的 import だけは温めておく。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
zustand persist は set() のたびに setItem を呼ぶため、pointermove / スライダー /
キーストローク 1 回ごとに「BinaryStorage 解決 → 全レイヤ・全 lock 画像・13 サウンドの
ref 抽出 → JSON.stringify → 同期 localStorage 書き込み → GC」がフルで走っていた。
永続内容が変わらない UI 専用アクション(selectBackgroundLayer など)でも同じコスト。

書き込みを最小間隔 400ms に畳む。静止状態からの 1 発目は即時に書き、以降クールダウン中の
更新は latest-wins キューに畳んで明けたときに 1 回だけ書く。純粋な trailing debounce に
しないのは、入力が途切れない限り書き込みが無期限に先送りされ、未書き込みの最新値を
抱えたまま落ちる窓が青天井になるため(画像 D&D のような単発更新も従来どおり即時に確定する)。

そのうえで pointerup / pointercancel / visibilitychange(hidden) / pagehide /
beforeunload で強制フラッシュし、ドラッグ終了・タブ非表示・離脱で保留を残さない。
既存の latest-wins 順序保証と、flushing 解除後の取りこぼし再チェックは維持している。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
removeItem は localStorage のキーを消すだけで、IndexedDB / appDataDir のバイナリを
残していた。呼び出し元(persist.clearStorage())は現状 0 件だが、「全データ削除」を
足したときに孤児が積み上がる罠になる。参照集合 ∅ の sweep で実体も回収する。

あわせて clearSeq(全消しの世代)を導入し、ensureFlushing が ref 抽出中に removeItem が
割り込んだ場合はその値を書かずに捨てる。これが無いと、消したはずの JSON が実行中の
書き込みで復活し、着地順によって「1 件も削除されない」か「全バイナリ削除済みの JSON が
残る」のどちらかに転ぶ。保留中の書き込みもキューごと破棄する。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Vec<u8>` を素直に往復させると serde が 10 進数値の JSON テキストに展開し、
20MB の画像が 60〜70MB のテキスト+2000万要素の JS 配列になっていた。
ハイドレート(binary_get)・保存(write_pack_folder / write_file)・
インポート(read_pack_zip / read_pack_folder)の全経路で発生していた。

- 戻り方向: `tauri::ipc::Response::new(bytes)` で生バイト応答にし、JS は
  ArrayBuffer をそのまま受ける。`binary_get` は生バイトに null を表す手段が
  ないため、先頭 1 バイトを在否フラグ(0=不在 / 1=実体あり)にする。
- 引数方向: `tauri::ipc::Request` の生ボディを使う。Tauri 2 の invoke は
  引数全体が TypedArray のときだけ octet-stream で送るため、メタ情報と
  バイト列を `[u32 LE metaLen][meta JSON][payload]` の 1 コンテナに詰める。
- custom protocol IPC が使えない webview の postMessage フォールバック
  (バイト列が数値配列になる)は両方向で互換受けする。
- write_pack_folder は長さ整合とパス検証を既存フォルダ削除より前に前倒しし、
  read_pack_folder は entries を所有権ごと回してピークメモリの二重確保を避ける。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`.zip としてエクスポート` と `保存先(親フォルダ)を選択…` がハードコード
日本語だったため、英語 UI のユーザにも OS ダイアログだけ日本語で出ていた。
adapters は React に依存できないので、`store/persistAdapter.ts` と同じく
i18n シングルトンの `i18n.t` を直接使う。ja / en 両方にキーを追加。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sweepOrphanKeys は storage.keys() のスナップショットを fire-and-forget で取り、
削除直前に localStorage を読み直して保護する。この窓の間にユーザが画像をドロップすると、
storage.put が keys() より先に着地し、localStorage への反映が readCurrentPersistedKeys
より後になった場合に、新しいキーが孤児と誤判定されて消える。ヘッダのコメントはこの窓を
クロスタブのものとしか書いていなかったが、同一タブでも成立する。

hydrate 時の参照集合が正しいのは「まだ 1 度も setItem が来ていない」間だけなので、
setItemSeq が動いていたら掃除ごと中断する。孤児は次回セッションで回収できるが、
消したバイナリは戻らないため、取りこぼす側に倒す。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts:
#	src/components/AreaEditor.tsx
…t-2026-08

# Conflicts:
#	src-tauri/src/lib.rs
#	src/adapters/index.ts
#	src/hooks/useFileOperations.tsx
#	src/i18n/locales/en.json
#	src/i18n/locales/ja.json
…26-08

# Conflicts:
#	src/hooks/useFileOperations.tsx
#	src/i18n/locales/en.json
#	src/i18n/locales/ja.json
…2026-08

# Conflicts:
#	src/i18n/locales/en.json
#	src/i18n/locales/ja.json
Layout.java / LockTexture.java / assets/seedqueue/sounds.json を実際に取得して確認した結果を
第6.3章・第6.5章に反映する。あわせて統合時に見つかった不整合を直す。

- locked/preparing で rows/columns を省略すると NPE でレイアウト全体が破棄されることを明記
- x/y/width/height の欠落、負の rows/columns、main.cosmetic なども同様に全体破棄になる旨を追記
- 「lock.png が無いと以降を読まない」という記述を訂正(MOD 同梱版が必ず解決するため)
- state.ts の SoundEntry doc コメントを新しいサウンド名規約に更新
- common.loading を復活(ユニット14が未使用として削除したが、ユニット12が Suspense で使用)
- write_pack_folder が任意のフォルダを remove_dir_all しないようガードを追加

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI が pnpm install --frozen-lockfile を使うため、package.json との
不一致があると ERR_PNPM_OUTDATED_LOCKFILE で落ちる。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Web 版ヘッダーのタイトル右隣に GitHub の /releases/latest への外部リンクを置く。
タグを固定しない URL を使うため、リリースのたびにアプリ側を更新する必要はない。
Desktop 版は AppHeader を描画しない(App.tsx)ので Web 版のみの追加。

- 検証: tsc --noEmit / eslint(error 0)通過。dev サーバでタイトルとの垂直中央一致
  (両方 cy=32・gap 12px)、href/target/rel、ja/en のラベル切替、コンソールエラー無しを確認。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`LockImage.weight` / `defaultWeight` は型だけあって buildPack / parsePack が
`.mcmeta` を扱っていなかったため、値を設定しても出力に出ず機能していなかった。
SeedQueue 本体には対応実装があるので、読み書きと UI を通す。

- 意味論: `LockTexture.java` / `LockTextureMetadata.java` を実ソースで確認(2026-08-25)。
  `defaultWeight` は lock.png のメタデータからのみ読まれ、読めなければ 1。各画像の
  `weight` が 0(GSON の未指定)なら defaultWeight で埋め、実効値は `Math.max(1, w)`。
  抽選は `random.nextInt(実効重みの総和)` の累積引きなので、確率は実効重み ÷ 総和。
- 実装: 意味論を `core/lockWeights.ts` に集約し、buildPack / parsePack / UI が同じ計算を使う。
  既定値どおり(defaultWeight が 1・個別重みが既定と同値)なら `.mcmeta` を出力しないので、
  重みを使わない既存パックの出力はバイト単位で不変。取り込んだ `.mcmeta` の `seedqueue`
  以外のセクション(`animation` 等)は `mcmetaExtra` に保持して書き戻すため、往復で失われない。
- `defaultWeight` を LockImage から LockImages(コレクション側)へ移動した。物理的には
  lock.png に載る値だが、画像側に持たせると並べ替えで 1 枚目が変わったときに値が別画像へ移り、
  パック全体の抽選確率が黙って変わってしまうため。
- UI: 行ごとの重み入力(空欄=既定に従う)、コレクションの既定重み入力、実効値と抽選確率の表示。
- 検証: tsc --noEmit / eslint(error 0、既存 warning 2 のみ)通過。i18n ja/en 241 キー一致。
  dev サーバで core を直接呼び、3 枚(既定 3 / 5 / 2)→ 30/50/20%、build→parse の完全往復、
  重み未使用時に .mcmeta を出さないこと、`animation` 付き .mcmeta の保持、壊れた .mcmeta で
  パック全体を落とさないこと、`weight: 0` の未指定への正規化を確認。UI 操作でも同じ確率、
  クリアで既定復帰、上限 10000 へのクランプを確認。Minecraft 実機での抽選確認は未実施。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
wall-maker Ready Ready Preview Aug 26, 2026 12:43pm

Request Review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6a0dae7af8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/core/lockWeights.ts
Comment on lines +145 to +146
const w = toSafeInt(s.weight, DEFAULT_LOCK_WEIGHT, 0, MAX_LOCK_WEIGHT);
if (w > 0) out.weight = w;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve negative explicit lock weights

When importing a pack whose lock.png.mcmeta has defaultWeight > 1 and an explicit negative weight, this clamp converts the weight to 0 and then drops it as inherited. SeedQueue instead applies Math.max(1, weight), so the original image has effective weight 1, while re-exporting it gives the larger default weight and silently changes its selection probability. Normalize negative explicit weights to 1 rather than treating them like the special zero/unset value.

Useful? React with 👍 / 👎.

@bafv4

bafv4 commented Aug 26, 2026

Copy link
Copy Markdown
Owner Author

バージョンを v3.2.0 に更新したうえで作り直します。

@bafv4 bafv4 closed this Aug 26, 2026
bafv4 added a commit that referenced this pull request Aug 28, 2026
/simplify レビュー(4 アングル)の指摘の適用(プレビュー側)。

- エリア選択を WallPreview のローカル state から store(ui.selectedAreas)へ移す。
  preparing の選択は index 参照のため、削除時に「配列が縮んだら preparing の選択を
  全部落とす」という応急処置を入れていたが、removePreparing が削除と同じ set() 内で
  index を詰め直す形に変えた(removeBackgroundLayer が selectedBackgroundLayerId を
  直すのと同じ流儀)。{#1, #3} 選択中に #1 を消しても #3 の選択が正しく追跡される。
- AreaTarget を store の公用語として export し、AreaMove = AreaTarget & {x,y} に。
  WallPreview 側の AreaRef は AreaTarget | layer の合成になり、moveAreas への
  変換に使っていた再タグ付け switch(到達不能な layer 分岐込み)が消えた。
- selectedRefs の identity を安定化(何も落ちなければ入力をそのまま返す)。
  ドラッグ中は layout.preparing の identity が毎フレーム変わるため、従来は
  選択が不変でも Set の再構築とハンドラの再生成が毎イベント走っていた。
- initDragDerived を items 配列を直接受ける形に、二重ネストの if を一本化、
  クリック縮退ガードの冗長条件(pressedRef が含意する mode/件数)を削除。
- 検証: tsc / eslint 0 エラー。dev サーバで store を直接呼び、removePreparing の
  選択詰め直し({#0,#2} 選択で #0 削除 → 旧 #2 を index 1 として追跡)、
  空選択の DEFAULT_AREA_SELECTION への縮退、reset 時の初期化を確認。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant