wintf 0.0.3

Windows Tategaki Framework - Rust UI library with Japanese vertical text support
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
//! ペアの重なりの維持系——再断行の要求を受け、実測して判断し、重なりだけを動かし、
//! 次の巡で実測と突き合わせる。
//!
//! design.md「System Flows > 維持系の判断と適用(共通・1 フレーム 1 巡)」の手順を
//! そのまま実装したものである。
//!
//! ```text
//! トリガ(ReassertZOrder)→ 観測の組立(ペア解決と GetWindow 実測)
//!   → decide_pair_fix(純関数)
//!       → 見送り: 理由つきの記録(要件 6.3)で終わり
//!       → 是正 : SetWindowPosCommand を積む(位置・寸法・活性化は伴わない)
//!                → tick 後の flush(既存経路)
//!                → 次巡で実測照合 → 一致なら是正の記録/不一致なら error(要件 6.1/6.2)
//! ```
//!
//! # 受動性——トリガ駆動のみ
//!
//! 本 system は毎フレーム走るが、[`ReassertZOrder`] を持つ窓が 1 つも無ければ判断そのものを
//! 行わない。タイマも定期巡回も持たず、自分から窓を前面へ上げることは決してない
//! (design.md「Responsibilities & Constraints」——これが要件 4.1/4.2 の受動性の実装である)。
//!
//! 唯一トリガを持たないのが破棄時の owner 切離し
//! ([`detach_owner_links_for_lost_peers`])である。破棄には要求を挿してくれる相手が居らず、
//! 「相手が消えた」こと自体を見つけるほかないためであり、見るのは owner を張った窓の集合に
//! 限られる。ここでも窓の重なりは動かさない——外すのは owner の関係だけである。
//!
//! 沈降観測([`observe_marked_pair_sinks`])も本 system の中で回る。こちらもトリガ駆動で
//! あり、目印を置くのは `WM_ACTIVATE` の非活性化枝である。実測を非活性化の巡ではなく
//! **次の巡**で行う理由は [`zorder_pair_sink`](super::zorder_pair_sink) の doc を参照
//! (活性化の途中で測ると偽の失敗を記録するため)。読み取りだけを行い、窓の挙動は変えない。
//!
//! # 収束の二重の停止条件
//!
//! ① 適用は [`SetWindowPosCommand`] の flush 経由であり、誘発される `WM_WINDOWPOSCHANGED` は
//! [`is_self_initiated`](super::is_self_initiated) でエコーと判定される。
//! ② [`decide_pair_fix`] は実測が既に隣接なら必ず見送る(同値ガード)。
//! 加えて検証は一致・不一致のどちらでも要求を消し、**再試行の輪を作らない**
//! (design.md「Error Strategy」の検証不一致)。
//!
//! # 複数ペアが同じ巡で是正を求めたとき——動かすのは高々 1 ペア
//!
//! 上の 2 つは 1 つのペアだけを見た停止条件であり、ペアが複数あるときの**観測の陳腐化**は
//! 塞がない。実測は巡の中で採るが実際の書込はその巡の後の flush であるため、同じ巡で
//! 2 つの是正を積むと、後から適用される指令の挿入位置は先の適用で既に古い——指した窓の
//! 隣に別の窓が割り込み、要件 1.1 の「すぐ手前」が成立しない(実機の 2 スコープ起動で
//! 毎回 `verify-failed` になっていた形)。
//!
//! よって **1 巡で実際に指令を出すのは高々 1 ペア**とする。残るペアの要求は消さずに残し、
//! 次の巡の**新しい実測**から計算し直す。持ち越しは見送りではないので記録も出さない
//! ——要求は entity に残り次巡で必ず処理されるため、記録の無い握り潰しにはならない。
//!
//! 巡数はペア数だけ要るが、ペアはスコープ数(実運用で 2)を超えない。しかも
//! **適用した是正が、既に隣接している別のペアを壊すことはない**——是正は自分のキャラ窓の
//! すぐ手前へ 1 枚だけ挿し込む操作であり、他ペアのバルーンとキャラの間へ割り込めるのは
//! そのペアが元から隣接していない場合だけだからである。よって単調に収束する。
//! **前提は 2 つ**——ペアどうしがキャラ窓を共有しないこと(areka の spawn はスコープごとに
//! キャラ 1 枚・バルーン 1 枚を作るので成立する)、および**キャラ窓が可視であること**
//! (隣接を「最も近い可視の隣」で測る以上、この関係が可視窓どうしで対称に成り立つことが
//! 論証の土台になる)。design.md「維持系の判断と適用」の収束の項と同じ論証である。
//!
//! # 書き込まないもの
//!
//! 位置・寸法・窓スタイル・表示状態には一切書き込まない(要件 1.6/5.x)。指令は必ず
//! [`pair_fix_command`] が組み、そこで `SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE` に固定される。
//!
//! # 本モジュールが担わないもの(後続タスクの担当)
//!
//! - 切離しの適用時点を早める強化——相手の消滅を検知した巡ではなく、窓の登録が実際に
//!   取り崩される直前で外す形。実機ゲート G8 が「切離しが間に合わない順序」を FAIL と
//!   判定した場合にのみ後続タスクが足す(design.md「破棄経路」)。
//! - 重なりが動いたことの検知(案 B/補助浮上のトリガ供給)。要求さえ届けば本 system は
//!   トリガ種別によらず同じ手順で処理する。
//!
//! 記録を出すマクロは隣の [`zorder_pair`](super::zorder_pair) に置く(`tracing` の出力先は
//! 呼び出し元の module path が既定であり、分けるとサインオフの grep 対象が分裂する)。
//! 本モジュールは記録用の関数を呼ぶだけである。

use bevy_ecs::prelude::*;
use bevy_ecs::system::NonSendMarker;
use windows::Win32::Foundation::HWND;

use super::transition_diag::{ORIGIN_ZORDER_PAIR, WriteTag};
use super::zorder_pair::{
    InsertSpec, PairFixDecision, PairObservation, PairTrigger, PeerLoss, SkipReason,
    decide_pair_fix, log_orphan_request_dropped, log_owner_detach_failed, log_owner_detached,
    log_unbacked_verification_dropped, measure_window_above, measure_window_below,
    measure_window_is_always_on_top, record_decision, record_verification,
};
use super::zorder_pair_sink::{SinkMarkQuery, observe_marked_pair_sinks};
use super::{
    ExpectedOrder, KeepDirectlyAbove, OwnerLink, ReassertZOrder, SetWindowPosCommand, WindowHandle,
    WindowPos, ZOrder, ZOrderPairStrategy,
};
use crate::api::clear_window_owner;

// ============================================================================
// IssuedPairFix - 出した指令の次巡への持ち越し
// ============================================================================

/// 適用済みの是正が**どの挿入位置を指令したか**の持ち越し(バルーン窓へ付与)。
///
/// 是正の記録(`fix`)は「出した指令」と「その後の実測」を同じ 1 行に載せる(要件 6.1)が、
/// その行が出るのは適用の**次の巡**である。よって適用時に
/// [`PairFixDecision::insert_spec`](super::zorder_pair::PairFixDecision) の射影をここへ預け、
/// 次巡の [`record_verification`] へ渡す。
///
/// [`ReassertZOrder`] に相乗りさせないのは、あちらが**他クレートから挿入される公開の
/// 契約点**(要件 2.6 のシーム)であり、その形を wintf 内部の都合で変えないためである。
/// 預けるのも外すのも本モジュールだけであり、要求が消えるときは必ず一緒に外れる。
///
/// 公開到達性を持つのは、維持系(`pub` な system)の引数に現れるためだけである
/// ([`OwnerEstablishFailed`](super::OwnerEstablishFailed) と同じ理由)。フィールドは
/// 公開しない——他クレートが組み立てる意味を持たない。
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
pub struct IssuedPairFix {
    /// 出した指令の挿入位置(記録の `insert_after` 欄になる値)
    pub(crate) insert_after: InsertSpec,
}

// SAFETY: `IssuedPairFix` は [`InsertSpec`] を保持し、その `After` バリアントが `HWND`
// (windows-rs では `*mut c_void` の newtype)を内包するため自動では Send/Sync が導出されず、
// `Component`(ECS は Send+Sync を要求)にするためこの手動 impl は必須である。
// 健全性: 保持するのは**出した指令の写し**にすぎず、窓の所有権も破棄責務も伴わない。
// この値を Win32 へ渡すのは本モジュールの維持系だけであり、当該 system は NonSend
// パラメータで UI スレッド固定されている。[`OwnerLink`](super::OwnerLink) と同根の
// crate 標準の HWND 取り扱い方針。
unsafe impl Send for IssuedPairFix {}
unsafe impl Sync for IssuedPairFix {}

// ============================================================================
// PairFixPlan - 是正の適用計画(純関数の出力)
// ============================================================================

/// 是正を実際に出すために要る 3 つの値。
///
/// 判断(どう直すか)と適用(何をどこへ)を分ける境界であり、World も Win32 も要らない
/// 純関数 [`plan_pair_fix`] が組む。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct PairFixPlan {
    /// 動かす窓(宣言ペアの 2 枚のいずれか 1 枚・要件 3.4)
    pub target: HWND,
    /// 挿入位置(記録に載る「出した指令」)
    pub insert_after: InsertSpec,
    /// 次巡で照合する期待隣接
    pub expected: ExpectedOrder,
}

/// 判断と観測から適用計画を組む(Win32 も World も触らない)。
///
/// 動かす窓は腕が一意に決める——手前へ寄せる腕(要件 1.2)ならバルーン窓、背後へ寄せる腕
/// (要件 1.3)ならキャラ窓であり、**第三の窓を指す余地が型に無い**(要件 3.4)。
/// 期待隣接はどちらの腕でも同じ「バルーンがキャラのすぐ手前」に収束するため 1 形で足りる。
///
/// `None` を返すのは、見送りだった場合と、両窓のハンドルが揃っていない観測だった場合である。
/// 後者は [`decide_pair_fix`] が先に見送りを返すため実際には届かないが、
/// **`None` を返せる形にしておくことで適用側に panic 経路を作らせない**(要件 6.4)。
pub(crate) fn plan_pair_fix(obs: &PairObservation, fix: PairFixDecision) -> Option<PairFixPlan> {
    let above = obs.above_hwnd?;
    let below = obs.below_hwnd?;
    let target = match fix {
        PairFixDecision::Skip(_) => return None,
        PairFixDecision::PlaceAboveOverBelow { .. } => above,
        PairFixDecision::PlaceBelowUnderAbove { .. } => below,
    };
    Some(PairFixPlan {
        target,
        insert_after: fix.insert_spec()?,
        expected: ExpectedOrder { above, below },
    })
}

/// 是正の指令を組む(重なりだけを動かす形に固定する)。
///
/// 位置・寸法を持たない [`WindowPos`] から組むのは、フラグの意味づけ(`SWP_NOMOVE`/
/// `SWP_NOSIZE`/`SWP_NOZORDER` の自動判定)と `ZOrder` → `hWndInsertAfter` の写像を
/// **既存の 1 箇所に委ねる**ためである。ここで二重に持つと、片方だけが変わったときに
/// 静かに食い違う。結果として常に `SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE` になる
/// ——位置・寸法は不変(要件 1.6)で、活性化も伴わない。
///
/// [`InsertSpec::TopEdge`] は `ZOrder::Top`(最上位)であって `ZOrder::TopMost`
/// (常時最前面)ではない——本フィーチャーは常時最前面を決して指さない(要件 4.3/8.1)。
///
/// [`InsertSpec::TopOfNormalBand`] も同じ `ZOrder::Top`(`HWND_TOP`)へ写像する。実測で
/// 確かめた 2 つの候補のうち、`HWND_NOTOPMOST` は**既に常時最前面ではない窓には何も
/// 起こさない**(重なりが 1 枚も動かない)のに対し、`HWND_TOP` は対象を常時最前面の帯の
/// すぐ背後——通常帯の最上——へ置き、`WS_EX_TOPMOST` は付けない。よって「通常帯の最上」を
/// 作る指令は `HWND_TOP` である。
pub(crate) fn pair_fix_command(hwnd: HWND, insert_after: InsertSpec) -> SetWindowPosCommand {
    let pos = WindowPos {
        zorder: match insert_after {
            InsertSpec::After(after) => ZOrder::InsertAfter(after),
            // 「最上位へ」と「通常帯の最上へ」は指令としては同じ(区別は記録の側で保つ)。
            InsertSpec::TopEdge | InsertSpec::TopOfNormalBand => ZOrder::Top,
        },
        position: None,
        size: None,
        no_activate: true,
        ..WindowPos::new()
    };
    SetWindowPosCommand::new(
        hwnd,
        0,
        0,
        0,
        0,
        pos.build_flags_for_system(),
        pos.get_hwnd_insert_after(),
    )
    // 観測専用の札。どの経路が積んだ書込かを 1 行で見分けるためだけのもので、
    // 判定・挿入位置・フラグ・適用順のいずれも変わらない(要件 10.3)。
    // スコープと窓種別は表示基盤側では判らないため番兵のままにする。
    .with_tag(WriteTag {
        origin: ORIGIN_ZORDER_PAIR,
        ..WriteTag::UNTAGGED
    })
}

// ============================================================================
// detach_owner_links_for_lost_peers - 破棄時の owner 切離し
// ============================================================================

/// owner を張った窓(バルーン窓)をひと通り見るためのクエリ。
type OwnerLinkQuery<'w, 's> = Query<
    'w,
    's,
    (
        Entity,
        &'static KeepDirectlyAbove,
        &'static OwnerLink,
        Option<&'static WindowHandle>,
    ),
>;

/// すべての entity に当たるクエリ(相手の生存とハンドルの有無を分けて読むための形)。
///
/// 沈降観測([`observe_marked_pair_sinks`])も同じ形で読むため、型をここで 1 つに保つ。
pub(crate) type HandleQuery<'w, 's> = Query<'w, 's, Option<&'static WindowHandle>>;

/// 相手を失ったペアの owner を外す(要件 5.7/5.8/5.9・design.md「破棄経路」)。
///
/// # なぜ外すのか
///
/// owner を張ったままキャラ窓が `DestroyWindow` されると、OS はその窓が owner である
/// バルーン窓も一緒に破棄する。すると後から走るバルーン窓側の破棄が、もう無効になった
/// ハンドルへ `DestroyWindow` を撃つ。同一スコープの 2 窓が対で消えること自体は許容
/// されている(要件 5.8)——禁じられているのは、その重複した破棄で異常終了することだけ
/// である(要件 5.9)。相手が消えた時点で owner を外しておけば、両窓は本フィーチャー以前と
/// 同じ「独立して生成され独立して破棄される 2 枚」に戻り、連鎖そのものが起きない。
///
/// # いつ外すのか
///
/// 相手(`peer`)の実体が消えた巡、ないし相手の OS ハンドルが外れた巡である。どちらも
/// 「相手の窓はもう無い/今まさに無くなる」を意味し、owner を残す理由が消えている。
/// 実体の消滅(despawn)と実際の `DestroyWindow`(登録の取り崩し)の**間**に本処理が入る
/// のが標準の形であり、その間隔で塞ぎ切れない順序(同一巡で両者が消える・アプリ終了経路)は
/// 実機ゲート G8 の判定対象である。
///
/// # 他スコープへは届かない
///
/// 外す対象は「自分が張った owner」だけであり、その owner は宣言ペア
/// ([`KeepDirectlyAbove`])の相手にしか張られていない。**本機構はスコープをまたぐ owner を
/// 張りも外しもしない**ので、他スコープの窓が切離しにも破棄にも巻き込まれない(要件 5.7)。
///
/// ⚠ 2026-08-30 訂正: 以前ここには「スコープをまたぐ owner はそもそも存在しない」と書いてあったが、
/// これは `areka-P0-scope-zorder-pinning` の**所有の鎖**(`zorder_chain_apply.rs` の横断 edge)が
/// 着地して以降は偽である。跨ぎの owner は実在するが、それを張り外しするのは鎖の側の帳簿
/// (`CrossOwnerLink`)であって本機構ではない。結論(他スコープを巻き込まない)は変わらない。
///
/// # 失敗しても止まらない
///
/// `clear_window_owner` の失敗は記録するだけで伝播させない(要件 6.4)。いずれの経路でも
/// [`OwnerLink`] は外す——同じハンドルでの再試行は同じ失敗を繰り返し、記録を二重化する
/// だけだからである(design.md「Error Strategy」の確立失敗と同根)。
fn detach_owner_links_for_lost_peers(
    commands: &mut Commands,
    links: &OwnerLinkQuery,
    handles: &HandleQuery,
) {
    for (entity, declaration, link, handle) in links.iter() {
        let peer = declaration.peer;
        let loss = match handles.get(peer) {
            // 相手は健在——owner を残す(生きている窓の関係には触れない)。
            Ok(Some(_)) => continue,
            Ok(None) => PeerLoss::HandleRemoved,
            Err(_) => PeerLoss::Despawned,
        };

        // 外した先を記録に残せる形でのみ Win32 を呼ぶ。自分のハンドルが既に無ければ
        // 外す対象の窓が特定できない——黙って読み飛ばさず理由つきの見送りにする(要件 6.3)。
        match handle {
            Some(handle) => match clear_window_owner(handle.hwnd) {
                Ok(()) => log_owner_detached(entity, peer, handle.hwnd, link.owner_hwnd, loss),
                Err(err) => {
                    log_owner_detach_failed(entity, peer, handle.hwnd, link.owner_hwnd, &err)
                }
            },
            None => {
                record_decision(
                    entity,
                    peer,
                    PairFixDecision::Skip(SkipReason::HandleMissing),
                );
            }
        }
        commands.entity(entity).remove::<OwnerLink>();
    }
}

// ============================================================================
// apply_zorder_pair_maintenance - 維持系(トリガ→判断→適用→検証)
// ============================================================================

/// 維持系: 再断行の要求を消費し、重なりを是正し、次の巡で実測と突き合わせる。
///
/// # 要求の一生
///
/// 1. **未適用**(`pending_verify` が `None`): 実測を採って [`decide_pair_fix`] に諮る。
///    見送りなら理由つきの記録を残して要求ごと消える。是正なら指令を積み、
///    [`IssuedPairFix`] を預け、`pending_verify` へ期待隣接を入れて検証待ちへ進む。
/// 2. **検証待ち**(`pending_verify` が `Some`): 実測を照合し、一致なら是正の記録、
///    不一致なら error(要件 6.2)。どちらでも要求はここで消える。
///
/// 見送りでも要求を消すのは、一回限りの要求だからである。残せば毎巡同じ判断を繰り返し、
/// 記録が氾濫して「トリガ駆動のみ」という性質も失われる。取りこぼした是正は次のトリガで
/// 自然に処理される(再試行の輪を作らない・design.md「Error Strategy」と同根)。
///
/// ただし **1 巡で指令を出すのは高々 1 ペア**であり、同じ巡で是正を要する 2 つめ以降の
/// ペアは①のまま次巡へ持ち越される(module doc「複数ペアが同じ巡で…」)。持ち越しは
/// 見送りではない——要求は残り、次巡の新しい実測で必ずやり直される。
///
/// # 相手が居ない巡(要件 1.5)
///
/// 検証待ちであっても、相手の実体やハンドルが先に消えていれば実測照合へは進まない
/// ——正常な破棄が error を吐く経路を作らないためである。理由つきの見送りとして記録し、
/// 残る窓には一切書き込まない。
///
/// # 失敗しても止まらない(要件 6.4)
///
/// `SetWindowPos` の失敗は flush 側が warn で継続し(`command.rs`)、効かなかった事実は
/// 次巡の検証不一致として error に残る。本 system に panic 経路は無い。
///
/// # UI スレッド固定
///
/// [`NonSendMarker`] を取るのは Win32(`GetWindow` 走査)を呼ぶためである。この印が付いた
/// system をスケジュール実行器はメインスレッド以外で走らせない
/// ([`SetWindowPosCommand`] のキューもスレッドローカルで UI スレッドを前提とする)。
pub fn apply_zorder_pair_maintenance(
    _ui_thread: NonSendMarker,
    mut commands: Commands,
    strategy: Option<Res<ZOrderPairStrategy>>,
    mut requests: Query<(
        Entity,
        &KeepDirectlyAbove,
        &mut ReassertZOrder,
        Option<&IssuedPairFix>,
    )>,
    // ペア宣言を持たない窓に付いた要求。宣言を持つ側とは `Without` で交わらない。
    orphans: Query<Entity, (With<ReassertZOrder>, Without<KeepDirectlyAbove>)>,
    // owner を張った窓。破棄時の切離しが見るのはこの集合だけである。
    links: OwnerLinkQuery,
    // 沈降観測の目印が付いた窓(`WM_ACTIVATE` の非活性化枝が前の巡で付けたもの)。
    sink_marks: SinkMarkQuery,
    // すべての entity に当たるクエリ。`Err` は「その実体がもう無い」、`Ok(None)` は
    // 「実体はあるがハンドルがまだ無い」——見送りの理由を分けるためこの形にする
    // (確立系 `establish_owner_links` と同じ形)。
    handles: HandleQuery,
) {
    // 要求が 1 つでも残っている巡は、次の画面更新も回す(設計 C16 の `ZORDER`)。
    // 是正の指令は tick 後の flush で書かれ、実測照合は**次の巡**で行うため、ここで
    // 立て直さないと検証待ちの要求が省略された画面更新の向こうで足踏みする。要求が
    // 尽きれば旗も立たなくなる(自分で立て直す形は `drag` と同じ)。
    //
    // 見ているのは**この巡の頭**の状態である。同じ巡でこの system より後に挿入された
    // 要求はここには映らず、次に回る画面更新——最悪でも心拍(30 フレーム)——まで待つ。
    // 現状の確立系は本 system の前に走る(areka `placement/spawn.rs:642` の
    // `(establish_owner_links, apply_zorder_pair_maintenance).chain()`)ので、その巡の
    // うちに拾われる。後ろに置く生産者を足すときは、その側で旗を立てること。
    if !requests.is_empty() || !orphans.is_empty() {
        crate::ecs::world::tick_wake::mark(crate::ecs::world::tick_wake::ZORDER);
    }

    // ストラテジ未挿入は結線前の状態。既定(案 A)で動く(design.md「State」の既定値)。
    let strategy = strategy.map(|s| *s).unwrap_or_default();

    // 破棄の後始末を先に済ませる——相手が消えた窓の owner は、この巡のうちに外れていないと
    // 登録の取り崩し(`DestroyWindow`)に間に合わない。
    detach_owner_links_for_lost_peers(&mut commands, &links, &handles);

    // 前の巡に非活性化された窓の沈降を、いま実測して記録する(読み取りのみ)。
    // 是正の処理より先に置くのは、記録に載る実測が**この巡の是正より前の重なり**である
    // ことを明らかにするためである(是正の書込はいずれにせよ tick 後の flush で起きる)。
    observe_marked_pair_sinks(&mut commands, &sink_marks, &handles);

    for entity in orphans.iter() {
        log_orphan_request_dropped(entity);
        commands.entity(entity).remove::<ReassertZOrder>();
    }

    // この巡で既に是正の指令を出したか。実際に窓を動かす指令は 1 巡につき 1 本までとし、
    // 残る要求は次巡の新しい実測で計算し直す(module doc「複数ペアが同じ巡で…」)。
    let mut fix_issued_this_pass = false;

    for (entity, declaration, mut request, issued) in requests.iter_mut() {
        let peer = declaration.peer;
        let above_hwnd = handles.get(entity).ok().flatten().map(|h| h.hwnd);
        let peer_lookup = handles.get(peer);
        let below_alive = peer_lookup.is_ok();
        let below_hwnd = peer_lookup.ok().flatten().map(|h| h.hwnd);

        if let Some(expected) = request.pending_verify {
            // 検証は 1 巡限り。結果によらずここで要求と持ち越しを外す。
            commands
                .entity(entity)
                .remove::<(ReassertZOrder, IssuedPairFix)>();

            if !below_alive {
                // 指令を出した後・検証の前に相手が消えた。これは要件 1.5 の正常系であり
                // 検証不一致の error にしてはならない。
                record_decision(entity, peer, PairFixDecision::Skip(SkipReason::PeerMissing));
                continue;
            }
            if above_hwnd.is_none() || below_hwnd.is_none() {
                record_decision(
                    entity,
                    peer,
                    PairFixDecision::Skip(SkipReason::HandleMissing),
                );
                continue;
            }
            let Some(issued) = issued else {
                // 自分で適用した要求なら必ず持ち越しが付いている。外から検証段階の値だけを
                // 作られた場合であり、出してもいない指令を記録せずに落とす。
                log_unbacked_verification_dropped(entity, expected);
                continue;
            };

            // 隣接の正準判定は「手前側の最も近い可視の背後が背後側か」(design.md Invariants)。
            // 実測が採れなかった場合も一致とは扱わない(`record_verification` の約束)。
            let _verified = record_verification(
                entity,
                peer,
                issued.insert_after,
                expected,
                measure_window_below(expected.above),
            );
            continue;
        }

        // 未適用の要求。実測は両窓のハンドルが揃っているときだけ採る——揃っていなければ
        // 判断は実測を見るまでもなく見送りへ倒れる(無効なハンドルで Win32 を叩かない)。
        let (measured_below_of_above, measured_above_of_below) = match (above_hwnd, below_hwnd) {
            (Some(above), Some(below)) if below_alive => {
                (measure_window_below(above), measure_window_above(below))
            }
            _ => (None, None),
        };
        // 挿入位置の候補が常時最前面の帯に居るかを、指令を出す前に測る。判断は Win32 を
        // 呼ばないので、この事実は観測に載せて渡す(要件 8.1・design.md Invariants)。
        //
        // 判断の結果を待たずに測るのは意図的である。観測は判断へ渡す**巡の写し**であり、
        // どの腕が選ばれるかを知る前に組み上がっていなければならない——先に判断を覗いて
        // 測るかどうかを決めると、観測の組立が判断に依存し、決定論的テストで固定している
        // 「同じ観測なら同じ判断」の関係が崩れる。よって見送りや背後へ寄せる腕になって
        // 値が読まれない巡でも測る。費用は `GetWindowLongPtrW` 1 回であり、しかも実測を
        // 採るのは両窓のハンドルが揃った巡だけである。
        let measured_above_of_below_is_always_on_top = measured_above_of_below
            .map(measure_window_is_always_on_top)
            .unwrap_or(false);
        let observation = PairObservation {
            trigger: PairTrigger::Reassert,
            strategy,
            // 走査対象そのものなので実体は在る。ハンドルの有無は `above_hwnd` が表す。
            above_alive: true,
            below_alive,
            above_hwnd,
            below_hwnd,
            measured_below_of_above,
            measured_above_of_below,
            measured_above_of_below_is_always_on_top,
        };

        // 判断結果は必ず `record_decision` を通す——見送りはそこで理由つきの記録になり、
        // 返ってくるのは適用すべき是正だけである(要件 6.3 の規約)。
        let Some(fix) = record_decision(entity, peer, decide_pair_fix(&observation)) else {
            commands.entity(entity).remove::<ReassertZOrder>();
            continue;
        };
        if fix_issued_this_pass {
            // この巡では既に別のペアの窓を動かす指令を積んでいる。その書込は本巡の後の
            // flush で起きるため、いま手にしている実測(と、そこから決まる挿入位置)は
            // 適用の時点では既に古い。要求はそのまま残し、次巡でやり直す——ここは
            // 見送りではないので記録も出さないし、要求も消さない。
            continue;
        }
        let Some(plan) = plan_pair_fix(&observation, fix) else {
            // 是正の腕が返った以上ハンドルは揃っており、ここへは届かない。届いても
            // panic は作らず、指令を出さずに要求を落とす(要件 6.4)。
            commands.entity(entity).remove::<ReassertZOrder>();
            continue;
        };

        SetWindowPosCommand::enqueue(pair_fix_command(plan.target, plan.insert_after));
        fix_issued_this_pass = true;
        // 実際の書込は tick 後の flush である。ゆえに実測照合は次の巡で行う——同じ巡で
        // 測っても指令はまだ反映されていない。
        request.pending_verify = Some(plan.expected);
        commands.entity(entity).insert(IssuedPairFix {
            insert_after: plan.insert_after,
        });
    }
}

#[cfg(test)]
#[path = "zorder_pair_maintain_tests.rs"]
mod tests;

#[cfg(test)]
#[path = "zorder_pair_maintain_multipair_tests.rs"]
mod multipair_tests;

#[cfg(test)]
#[path = "zorder_pair_maintain_detach_tests.rs"]
mod detach_tests;

#[cfg(test)]
#[path = "zorder_pair_maintain_survivor_tests.rs"]
mod survivor_tests;

#[cfg(test)]
#[path = "zorder_pair_maintain_always_on_top_tests.rs"]
mod always_on_top_tests;