File size: 46,309 Bytes
880dff9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
# ActionRoPE

在 Wan2.2-TI2V-5B 上做动作条件世界模型:动作**不走文本**,走 RoPE。
数据是《恶意不息》BOT 漫游录像重建的 per-frame 动作条件 latent。

* 训练分辨率 **832×480**,**81 帧 @16 fps**,对应 latent `[48, 21, 30, 52]`
* infra 用 [DiffSynth-Studio](https://github.com/modelscope/DiffSynth-Studio) 的全量微调链路
* 8×H200 (143 GB)

## 目录

| 路径 | 内容 | 来源 |
|---|---|---|
| `actionrope/` | 本项目代码 | — |
| `tools/` | 数据体检脚本 | — |
| `scripts/` | shell 入口 | — |
| `data/` | 数据集,189 GB(解包后) | `hf download teawhite/EYBX-processed` |
| `models/Wan2.2-TI2V-5B/` | 基座权重,32 GiB | `hf download Wan-AI/Wan2.2-TI2V-5B` |
| `DiffSynth-Studio/` | 训练 infra,v2.1.6 | `git clone` |
| `outputs/` | checkpoint / 日志 | — |

`data/` `models/` `DiffSynth-Studio/` `.venv/` 都在 .gitignore 里。

## 环境

```bash
python3.12 -m venv .venv
.venv/bin/pip install torch torchvision --index-url https://download.pytorch.org/whl/cu128
.venv/bin/pip install transformers "imageio[ffmpeg]" safetensors einops modelscope ftfy \
                      pandas accelerate peft deepspeed tensorboard av
.venv/bin/pip install -e ./DiffSynth-Studio --no-deps
```

实测版本:torch 2.11.0+cu128 · accelerate 1.14.0 · deepspeed 0.19.6 · peft 0.20.0 ·
transformers 5.16.1(CUDA 13.2 driver 595.71.05)。

## 数据

```bash
hf download teawhite/EYBX-processed --repo-type dataset --local-dir data
bash data/unpack.sh "$PWD/data"      # 必须给绝对路径,脚本内部 cd 过
```

解包后:

```
data/latent/pool/          22,782 个 .pt   [48,21,30,52] bf16
data/latent/train_eybx/    20,983 软链
data/latent/val_eybx/       1,175 软链     按 leg 整体留出,不要用哈希划分
data/latent/pool_heldout/     159 软链     组合泛化评测参照
                           三个划分合计 22,317,与 pool 差的 465 条是
                           buffer leg,按 HANDOFF 的划分策略两边都不进
data/clips/                22,782 个 mp4   832×480 / 16fps / 81 帧
data/meta/*.jsonl          逐 clip 一行,含 21 个 cell 的逐 cell 真值
data/logs/<session>/       state.npz(20Hz 世界坐标) / input.npz(摇杆) / offsets.json(世界→屏幕)
```

### 每个 latent .pt 的内容

```python
d["latent"]            # bf16 [48, 21, 30, 52]
d["prompts"]           # list[str] 21 条,含动作从句(老方案,ActionRoPE 不用)
d["prompts_bossdrop"]  # list[str] 21 条,场景 dropout
d["actions"]           # list[int] 21 条,9 类动作索引
d["scenes"]            # list[str] 21 条
d["actions"] / d["purity"] / d["rt_ratio"] / d["video_t0"] / d["start_cell"] ...
```

## 文本表

数据集自带的 `text_table_eybx.pt`(1,253 键)每一条都含 `the player is {动作}`,
是动作走文本的老方案。ActionRoPE 的动作走 RoPE,所以要重建一张只含场景的表:

```
老:  In a 2.5D top down view, {scene}, the player is {action}.
新:  In a 2.5D top down view, the player is {scene}.
```

```bash
.venv/bin/python -m actionrope.build_text_table   # -> data/latent/text_table_actionrope.pt
```

361 键(18 纯场景 + 342 转场 + 1 空条件串),`dict[str, bf16[512,4096]]`。
编码走 DiffSynth 自己的 tokenizer + WanTextEncoder,与
`WanVideoUnit_PromptEmbedder` 逐字一致,训练时不必再挂 11 GB 的 UMT5。

## 会静默出错的地方

摘自数据集的 `HANDOFF.md`,加上本仓库实测:

1. **分辨率只能 832×480。** 848×480 会静默炸:52→53 是奇数,DiT 的 stride-2 丢掉最右一列 latent。
2. **不要用训练脚本自带的哈希划分做 val。** clip 步长 84 帧、本身 81 帧,相邻两个 clip 只差
   3 帧,哈希划分会把同一段素材切到训练和验证两边。验证走离线的 `val_eybx`。
3. **`unpack.sh` 要传绝对路径。** 它 `cd "$ROOT"` 之后又用 `$ROOT/latent` 拼路径,
   传相对路径时划分那一步会静默失败(tar 解包正常,软链 0 个)。
4. **号段不能撞**:1e8(s1) / 2e8(s2) / 3e8(burn) / 4e8(hardcut),1e7 是 vfx 段。
5. **低 rt_ratio 片段**(世界降速)没有被过滤,s1 里 rt<0.8 占 27.6%。
   做控制服从评测时要排除,否则「模型不听话」和「世界本来就慢」会混在一起。

## 数据体检

```bash
.venv/bin/python -m tools.inspect_dataset          # 全部检查
.venv/bin/python -m tools.inspect_dataset --check transitions
```

已核对过的结论:

* 22,782 个 clip / 478,422 个 cell 的 prompt 全部能剥离动作从句,零失败
* 94% 的 clip 内部逐 cell prompt 不同(变的只有动作从句),场景从句 90% 的 clip 内恒定
* 转场 1,900 条 **100% 是 `A → 过渡 → B`**,没有 `A → 过渡 → 过渡`
* 转场起始 cell 均匀落在 **3..9**(各 12.9%~15.9%),cell 0-2 / 10-20 无正样本 ——
  成因是 `burn_corpus.py:44` 的 `N_PRE_CHOICES = [9,13,17,21,25,29,33]` 帧,
  经 `cell_of()` 映射即 3..9。已确认按现状使用(要求是「不全在同一个 cell」)。
* 三个划分两两无交集,0 断链。但**转场 clip 是按 heldout 标志而不是按 src 归属划的**:train_eybx 里 1,741 条转场中
  257 条的 `src_a` / `src_b` 在 val_eybx(123 条是 `src_b` ⇒ 该 val clip 的帧 n_pre..80 逐帧出现在训练集里,像素核对
  mean|Δ| 0.69 灰度级),涉及 56 个 val clip(36 个 sidecar valid)。`dataset.py` 按 `data/meta/transition.jsonl` 把这些
  转场剔出训练集(`exclude_src_in=val_dir`,sidecar valid 的有 73 条),划分软链本身不动。
* 200 条 latent 抽样:形状 `[48,21,30,52]` bf16 全对,无非有限值,
  std 均值 1.247 —— 与 Wan2.2 VAE 的 mean/std 归一化后应有的量级一致

## ARoPE 模型补丁(`actionrope/arope.py`)

动作走 RoPE 的模型侧实现,不改 DiffSynth 源码,接口见 `actionrope/SPEC.md`「模型契约」:

* `build_world_freqs(dit, f, h, w, offset_tok)`:f 轴查 `dit.freqs[0]`,h/w 轴按世界坐标
  `(i_h + dy_tok, j_w + dx_tok)` 用 `polar(1, pos·inv_freq)` 现算(允许小数)。
  `offset_tok=None` 时与原查表逐位相同;零偏移走现算路径时与查表只差 ~1e-15
  (表在 CPU 上算、现算在 GPU 上,cos/sin 库函数的 ULP 差;`dit.freqs` 实际是 complex128)。
* `known_mask` / `loss_weight_map`:世界坐标落在首帧足迹 `[-0.5, h-0.5]×[-0.5, w-0.5]`
  (闭区间)内 ⇒ known,否则 new(loss × `new_weight`)。
* `install_arope(dit, mask_channel)`:`mask_channel=True` 只新增零初始化的
  `arope_mask_embedding`(Conv3d(1, 3072, (1,2,2)),15,360 个参数);`False` 时 ckpt 键集与原模型完全一致。
* `arope_forward(...)`:复刻 `model_fn_wan_video` 的 `seperated_timestep + fuse_vae_embedding_in_latents`
  分支(帧 0 的 t=0)。context 可以是整条 `[b,512,4096]` 或逐 cell `[b,21,512,4096]`(每帧 token
  只 cross-attend 自己 cell 的文本,`context_ids=[b,21]` 给出去重分组)。

自测(一张 H200,约 1 分钟):

```bash
CUDA_VISIBLE_DEVICES=0 PYTHONPATH=$PWD .venv/bin/python -m pytest tests/test_arope.py -s -v
```

实测:`offset_px=None` 时与 `model_fn_wan_video` **逐位相等**;逐 cell 文本(21 份相同)与整条模式逐位相等;
全参数训练一步(batch 1、全尺寸、梯度检查点开)峰值显存 ~22 GB、前向+反向 ~4 s。

## 相机偏移 sidecar(`actionrope/geometry.py` / `sidecar.py` / `verify_sidecar.py`)

动作走 RoPE 需要每帧的相机(玩家屏幕投影)位移,存在 `data/latent/arope_sidecar.pt`
(结构见 `actionrope/SPEC.md`)。生成与校验:

```bash
.venv/bin/python -m actionrope.sidecar                  # 未修正版(gain=[1,1]),约 10 s
.venv/bin/python -m actionrope.verify_sidecar           # 对视频实测符号/增益并重写 sidecar,约 75 s(默认取全部 636 个候选 clip)
.venv/bin/python -m pytest tests/test_geometry.py       # 几何单测(纯 CPU)
```

实测结论(`outputs/sidecar_check/gain_fit.json`,636 个高速直行 clip、帧 0 vs 帧 16/32/48、
SIFT+RANSAC 仿射场在玩家位置取值):

* **日志 M 的符号是反的**:实测背景位移 ≈ **+**(日志预测的玩家投影位移),两 session、
  两轴、1,251 个样本 ≥96% 一致。旧流水线 `align_flow.flow_series` 把相位相关峰取反了一次
  (峰本身已经是 −位移),M 用它拟合出来符号整体翻转;`verify_actions` 的闭环用的是同一个
  测法,所以没抓到。**后果**:meta / prompt 里的动作标签是镜像的 ——「moving right」的 clip
  里玩家实际向屏幕左走,「moving up」实际向下(2026-09-04 人工看视频确认:`clip_Eybx_100000022_000210`
  标签 "moving left",画面里场景向左滑、人物朝右跑)。sidecar 已把符号乘进 `gain_correction`
  (负值),ARoPE 臂不必再处理;`plain` 臂由 `prompts.mirror_action` 把动作词翻回真实方向
  (训练 `--mirror_labels` 默认开,文本表用 `text_table_eybx_mirror.pt`,见下)。`data/HANDOFF.md`
  里「世界 +x → 屏幕左、+z → 屏幕下」是这个翻转的产物,实际是 +x → 右、+z → 上(未改数据集文档)。
* 横向增益:g_x = 1.120 / 1.124(两 session 相差 0.4%,R² 0.99/0.98)⇒ `gain_correction[0] = −1.122`,
  即 832 画幅下横向 29.8 px/世界单位(日志 26.6)。
* 纵向:g_y = 1.094 / 1.117(相差 2.1%)但 R² 0.90 / 0.84 < 0.9 ⇒ 按 SPEC 规则幅值保持 1.0,
  `gain_correction[1] = −1.0`。原因是相机是斜视角透视,纵向位移随屏幕行变化 ~40%/100 px
  (横向 ~15–20%/100 px),单一平移只是近似,k=48 时 R² 掉到 0.75。
* `valid`:最近邻日志时间差 ≤ **0.5 s**(`--max_sample_dt 0.5`,2026-09-04 人定放宽)+ 跨 offset 台阶(`log_jump`,
  只中 `clip_Eybx_200000340_000168`)+ 传送 ⇒ n_valid = **22,781** / 22,782。原 0.15 s 规则只留 13,855(s1 丢一半);
  放宽前做过实测:只在 0.15–0.5 s 规则之间的 clip(160 条纯日志缺口 + 160 条 rt<0.8 降速)过同一套视频校验,
  x 增益 −1.117 / −1.101、R² 0.98 / 0.98、中位残差 6.9 / 5.9 px,与 valid 基线(−1.122 / 0.99 / 4.7 px)一致。
  世界降速不再靠 valid 规则兜,改用 `rt_ratio` 闸(训练 <0.8 剔、val/评测 <0.9 剔,见训练一节)。
* `walk_speed_px_s = [69.2, 41.1]`(已乘 |gain|),供推理造指令用。

## 推理(`actionrope/infer.py`)

首帧 + 场景从句 + 动作脚本 ⇒ 81 帧 832×480 mp4,并对生成视频实测背景位移。示例见 `scripts/infer_demo.sh`:

```bash
# replay 一条 val clip:指令 = sidecar 真值,首帧 = 该 clip 帧 0,文本 = 原 prompt(剥掉动作),与 GT 比 PSNR
CUDA_VISIBLE_DEVICES=4 .venv/bin/python -m actionrope.infer --replay clip_Eybx_200000958_000273 \
    --ckpt outputs/<run>/step-N.safetensors --out outputs/samples/replay.mp4
# 自造指令:玩家向屏幕右走 1.0× 步速、21 个 cell;速度取 sidecar meta 的 walk_speed_px_s
CUDA_VISIBLE_DEVICES=4 .venv/bin/python -m actionrope.infer --first_frame clip_Eybx_200000958_000273 \
    --scene tidal_flats --actions "right:1.0:21" --out outputs/samples/right.mp4
```

* `--actions "dir:mult:cells,..."`(8 向 + idle,cell 数合计 21)或 `"dx,dy:cells"`(px/s);
  **方向名指玩家在屏幕上的真实运动方向**(right ⇒ 背景左移)。`--trigger burn:<B>:<start>:<n>` 把 cell
  换成转场从句、之后切成 B(文本表里有全部 342 条转场串,查不到才回退加载 UMT5)。
* `--arm plain`:普通 RoPE + 文本带动作。默认 `--label_mode literal`:动作词写真实屏幕方向(真实向右 ⇒
  "moving right"),配 `--mirror_labels` 训出来的模型;`--replay` 时把数据集的镜像 prompt 也翻回来。
  `--label_mode mirror` 只给 `--no-mirror_labels` 的旧约定模型用(原标签、原表)。
* `--ckpt` strict 加载,mask 通道按 ckpt 键集自动判断;不给就是基座权重。采样 50 步 shift 5,
  帧 0 每步写回首帧 latent(首帧用 fp32 编码再存 bf16,与数据集 latent 同精度;bf16 编码会差 ~1%)。
  `--cfg` 的负条件:arope 臂 `NULL_PROMPT`;plain 臂逐 cell 裸动作串 `In a 2.5D top down view, the player is {action}.`
  (该臂训练 dropout 用的就是这 9 个串,`NULL_PROMPT` 不在 eybx 表里)。plain + CFG 未实测。
* 最后一行打印实测位移:帧 0→80 的背景位移(`verify_sidecar.measure_shift`,SIFT+RANSAC 仿射场在
  玩家位置取值;直接测不出时每 16 帧链式累加,打印里标明采用的是 direct 还是 chain 及段数)与指令
  `−frame_offset_px[80]` 对比;同名 `.json` 里有全部数字(相位相关值只作参考,见已知问题 4),`_frame0.png` 是首帧。
  大位移场景(≥300 px)下 0→80 直接测基本都失败,头条数字来自 5 段链式累加。

自测(一张 H200,约 1.5 分钟):`CUDA_VISIBLE_DEVICES=4 .venv/bin/python -m pytest tests/test_infer.py -s -v`。
测试产物写到 `outputs/test_artifacts/`(`AROPE_TEST_DIR` 可改)。
实测:推理侧 VAE 编码与数据集 latent 相对误差 0.8%(bf16;fp32 编码 0.3%,数据集是 fp32 编码后存 bf16),
首帧 fp32 编码(默认)cell 0 相对误差 0.38%(bf16 编码 0.96%),首帧单独编码与整段同精度编码的 cell 0 逐位相同;GT clip 实测位移 (+385, +4) vs sidecar 的 −frame_offset[80] (+370, +2);
20 步采样 5.7 s、解码 2 s、峰值 16.4 GB。**基座权重(未微调)+ ARoPE** 对 right 1.0× 指令实测位移
(−353, +3) vs 指令 (−356, 0),replay 实测 (+332, +29) vs (+370, +2) —— 世界坐标 RoPE 零样本就把背景
推到了指令位置(画面有拖影,需微调);plain 臂基座对 "moving right" 文本实测 (−6, +1),基本不动。

## 训练(`actionrope/dataset.py` / `train.py`,配置 `configs/`,入口 `scripts/`)

只训 DiT(bf16,全参数),latent 与文本都预计算,不挂 T5 / VAE。两个原生臂(四个 baseline 臂见「五臂 baseline」一节):

| 臂 | RoPE | loss 权重 | mask 通道 | 文本 |
|---|---|---|---|---|
| `--arm arope` | 世界坐标(sidecar `offset_px`) | new 区 × `--new_weight`(默认 2) | 零初始化 conv(`--no_mask_channel` 关) | `text_table_actionrope.pt`(剥动作从句) |
| `--arm plain` | 普通 | 全 1 | 无 | `text_table_eybx.pt`(原 prompt,含动作从句) |

两臂都走逐 cell 文本 cross-attn(`context [b,21,512,4096]` + `context_ids` 去重),loss 与 DiffSynth
`FlowMatchSFTLoss` 同形(`FlowMatchScheduler("Wan")` 1000 个训练时间步均匀抽、帧 0 用干净 latent 替换并剔除、
`Σw·(pred−target)²/Σw` × `training_weight(t)`)。场景 dropout `--scene_dropout 0.1`:arope 臂 21 个 cell 全换
`NULL_PROMPT`,plain 臂换成 .pt 自带的 `prompts_bossdrop`。

```bash
bash scripts/smoke_train.sh arope        # 4 卡 ZeRO-2,64 clip,12 步,第 6/12 步存权重 + 验证(约 3 分钟)
bash scripts/smoke_train.sh plain
bash scripts/train_arope.sh              # 8 卡正式:40k 步、save_every 1000、val_every 500(RUN=xxx 改输出名)
bash scripts/train_plain.sh
.venv/bin/python tests/check_ckpt.py outputs/<run>/step-N.safetensors --arm arope   # 键集 strict 核对(CPU)
```

* 数据:`train_eybx` 20,983 个 .pt 里 sidecar `valid=False` 剔 1,`src_a`/`src_b` 在 val_eybx 的转场剔 257,
  `--min_rt_ratio 0.8`(默认)剔世界降速 2,197 ⇒ 可用 **18,528**(plain 17,044 / burn 1,162 / hardcut 322);
  val_eybx 1,175 按 `--val_min_rt_ratio 0.9`(默认)剔 193 ⇒ **982**。40k 步 × 8 卡 ≈ 17 个 epoch。
* 输出 `outputs/<run>/`:`step-N.safetensors`(10.0 GB,只含 DiT 键,arope 臂多 `arope_mask_embedding.*`,
  能直接 `load_state_dict(strict=True)`)、`train_log.csv`(loss/lr/grad_norm/step_time/mem)、`val_log.csv`
  (固定 val clip × 固定 t∈{200,500,800} × 固定噪声)、`tensorboard/`、`config.json`(全部参数 + sidecar 增益 + 环境)。
* `--resume x.safetensors` 热启动权重(step 从 0 起,Adam 动量与 warmup 归零,且 bf16 导出会丢掉 fp32 master 里
  小于 1 ULP 的更新 —— RMSNorm 权重 30 步内在 bf16 里逐位不变就是这个原因);`--save_state`(正式脚本默认开)时另存
  DeepSpeed 状态到 `state_A` / `state_B` 两槽轮转(8 卡每份约 70 GB),`<output>/state` 软链指向最新完整槽,
  `--resume outputs/<run>/state` 续训(数据顺序不复现)。
* 随机数:`set_seed(seed, device_specific=True)`,各 rank 的噪声 ε 与场景 dropout 抽签独立(2 进程实测:分片不相交、
  ε 逐步不同、跨 epoch 重排);不加 device_specific 时 8 张卡每步共用一份 ε。
* `--grad_clip`(默认 1.0)在 DeepSpeed 下写进 `gradient_clipping`(启动时打印生效值);日志里的 `grad_norm` 是**裁剪前**的全局范数。
* 学习率手动 step(不交给 `accelerator.prepare`,否则每步会被 step `num_processes` 次),`--warmup_steps` 线性 warmup。
* 单进程 `python actionrope/train.py` 不走 DeepSpeed:AdamW 直接在 bf16 参数上更新、无 fp32 主权重(lr 1e-5 的更新大多被
  bf16 舍入吞掉),只适合 smoke,数字不代表训练效果。

实测(4×H200,ZeRO-2,每卡 batch 1,梯度检查点开):**1.22 s/步**,峰值显存 32.8 GB/卡;单卡无 DeepSpeed 1.13 s/步、46.7 GB。

## ActionRoPE 实现

上面各节按模块写了细节,这一节是总览:模块表、各阶段命令、8 卡端到端 smoke 的实测数字与已知问题。
所有模块的接口契约在 `actionrope/SPEC.md`,不改 DiffSynth 源码,只在 `actionrope/` 里以函数 / 子类扩展。

### 模块

| 文件 | 作用 | 自测 |
|---|---|---|
| `actionrope/prompts.py` | `strip_action` 剥动作从句、`NULL_PROMPT` | `tools/inspect_dataset.py --check prompts` |
| `actionrope/build_text_table.py` | 只含场景的文本表 `text_table_actionrope.pt`(361 键) | `--check text_table` |
| `actionrope/build_mirror_table.py` | plain 臂用的文本表 `text_table_eybx_mirror.pt`(1,783 键 = eybx 表 + 530 条翻回真实方向的转场串,7.5 GB,GPU 0 约 1 分钟) | — |
| `actionrope/geometry.py` | 常量、cell↔帧、日志→屏幕投影、`sample_frame_offsets`、known/new 掩码 | `tests/test_geometry.py`(CPU) |
| `actionrope/sidecar.py` | 生成 `data/latent/arope_sidecar.pt`(逐 clip 的 `offset_px[21,2]` / `frame_offset_px[81,2]` / `valid`) | — |
| `actionrope/verify_sidecar.py` | 对视频实测符号 / 增益(SIFT+RANSAC 仿射场),把 `gain_correction` 乘回 sidecar | `outputs/sidecar_check/` |
| `actionrope/arope.py` | 世界坐标 RoPE、逐 cell 文本 cross-attn、mask 通道、`arope_forward` | `tests/test_arope.py`(GPU) |
| `actionrope/dataset.py` | latent + sidecar + 文本表 ⇒ 样本;valid 剔除;场景 dropout | — |
| `actionrope/train.py` | 各臂全参数微调(accelerate + DeepSpeed ZeRO-2),定步验证 / 导出(arm 参数带 `arm.` 前缀同文件) | `tests/check_ckpt.py` |
| `actionrope/infer.py` | 首帧 + 场景 + 动作脚本(或 `--replay`)⇒ mp4,并实测背景位移;臂按 ckpt 自动识别 | `tests/test_infer.py`(GPU) |
| `baseline/` | 四个对照臂 linear / xattn / prompt / adaln(`ActionArm` 钩子),`build_arm` / ckpt 键集识别;见「五臂 baseline」 | `tests/test_arm_*.py`(GPU)、`tests/test_baseline_integration.py`(CPU) |
| `configs/accelerate_zero2*.yaml` | 8 卡 / 4 卡 DeepSpeed ZeRO-2 bf16 配置 | — |
| `scripts/*.sh` | `smoke_all.sh`(端到端)、`smoke_train.sh`、`train_arope.sh`、`train_plain.sh`、`train_baseline.sh <arm>`、`smoke_baselines.sh`、`infer_demo.sh` | — |

### sidecar 生成与校验

```bash
.venv/bin/python -m actionrope.sidecar                                        # 未修正版(gain=[1,1]),~10 s
.venv/bin/python -m actionrope.verify_sidecar                                 # 实测符号 / 增益并重写 sidecar,~75 s
.venv/bin/python -m actionrope.sidecar --gain_fit outputs/sidecar_check/gain_fit.json --max_sample_dt 0.5   # 当前交付用的就是这条(0.5 s 规则)
.venv/bin/python -m pytest tests/test_geometry.py -q
```

当前 sidecar:`gain_correction = [-1.1223, -1.0]`(符号反转 + 横向 1.122 增益,806 个候选 clip 重测)、
`walk_speed_px_s = [69.24, 41.08]`、`max_sample_dt = 0.5`、`n_valid = 22,781 / 22,782`(1 条 log_jump)。

### 训练

```bash
bash scripts/smoke_all.sh                # 8 卡端到端 smoke:单测 → 体检 → arope 30 步(含 save_state)→ plain 10 步 → 推理 → check_ckpt → 续训 2 步,约 9 分钟
FROM=c bash scripts/smoke_all.sh         # 从第 c 步开始(a 单测 / b 体检 / c arope / d plain / e arope 推理 / f plain 推理 / g check_ckpt / h resume)
bash scripts/smoke_train.sh arope        # 4 卡 12 步 smoke
bash scripts/train_arope.sh              # 8 卡正式:40k 步、lr 1e-5、warmup 500、save_every 1000(+save_state)、val_every 500(RUN=xxx 改输出名)
bash scripts/train_plain.sh              # 对照臂,同样节奏;崩溃后 bash scripts/train_arope.sh --resume outputs/arope_40k/state
.venv/bin/python tests/check_ckpt.py outputs/<run>/step-N.safetensors --arm arope
```

正式训练的 `train.py` 关键参数:`--arm {arope,plain,linear,xattn,prompt,adaln}`(后四个见「五臂 baseline」,`scripts/train_baseline.sh <arm>`)、`--mask_channel/--no_mask_channel`、`--new_weight 2.0`、
`--scene_dropout 0.1`、`--lr 1e-5`、`--grad_clip 1.0`、`--max_steps`、`--save_every --save_state`、`--val_every --val_n`、
`--resume`(safetensors 热启动 / `state` 软链续训,后者需 `--save_state`)、`--min_rt_ratio`、`--limit`(smoke)。

### 推理

```bash
# replay:指令 = sidecar 真值,首帧 = 该 clip 帧 0,与 GT 比 PSNR
CUDA_VISIBLE_DEVICES=0 .venv/bin/python -m actionrope.infer --ckpt outputs/<run>/step-N.safetensors \
    --replay clip_Eybx_200000958_000273 --steps 50 --out outputs/samples/replay.mp4
# 自造指令:玩家向屏幕右走 1.0× 步速 21 个 cell(right ⇒ 背景左移 ≈ 356 px)
CUDA_VISIBLE_DEVICES=0 .venv/bin/python -m actionrope.infer --ckpt outputs/<run>/step-N.safetensors \
    --first_frame clip_Eybx_200000958_000273 --scene tidal_flats --actions "right:1.0:21" --out outputs/samples/right.mp4
# 对照臂(动作走文本,动作词 = 真实屏幕方向)
CUDA_VISIBLE_DEVICES=0 .venv/bin/python -m actionrope.infer --arm plain --ckpt outputs/<plain_run>/step-N.safetensors \
    --first_frame clip_Eybx_200000958_000273 --scene tidal_flats --actions "right:1.0:21" --out outputs/samples/right_plain.mp4
```

### 8 卡端到端 smoke 实测(`scripts/smoke_all.sh`,2026-09-04 审查修复后重跑,一次通过,总耗时 587 s)

| 步骤 | 结果 | 耗时 |
|---|---|---|
| a `pytest tests/ -x -q`(GPU 0) | 26 passed | 60 s |
| b `inspect_dataset --check layout text_table` | 通过 | 4 s |
| c arope 臂 8 卡 ZeRO-2,256 clip,30 步,val_n 8,`--save_state` | 见下 | 182 s(含 2 次 10 GB 导出 + 2 次 66 GB state) |
| d plain 臂 8 卡 ZeRO-2,10 步 | 见下 | 89 s |
| e arope step-30 推理:replay + right×1(50 步) | 见下 | 88 s(两条) |
| f plain step-10 推理:right×1(50 步) | 见下 | 42 s |
| g `check_ckpt` step-30(CPU) | strict_load_ok,827 张量,675 张量有改动,max\|Δ\| 4.9e-4 | 23 s |
| h 从 `outputs/smoke_arope/state`(→ `state_B`)续训 2 步 | 从 step 30 起到 32,step-32.safetensors | 99 s |

* **8 卡 step time**:arope 臂首步 5.68 s(含编译 / 分配),其后 **1.20 s/步**;plain 臂 1.20 s/步。
  与 4 卡实测(1.22 s/步)一致 —— 每卡 batch 1、ZeRO-2 通信不是瓶颈。
* **显存**:8 卡下每卡峰值 **22.7 GB(主进程)/ 23.9 GB(各进程最大)**,比 4 卡的 32.8 GB 低 —— ZeRO-2 优化器状态按 8 份切。
* **arope 30 步 loss**(乘过 `training_weight(t)`,每步 t 随机、各 rank 噪声独立,单样本噪声大):
  `0.299 0.331 0.294 0.265 0.177 0.210 0.278 0.259 0.305 0.237 0.168 0.265 0.254 0.197 0.213 0.158 0.235 0.264 0.275 0.343 0.109 0.110 0.345 0.182 0.320 0.252 0.261 0.329 0.237 0.169`;
  lr warmup 5 步(2e-6 → 1e-5)正确,grad_norm(裁剪前)0.94 → 0.1–0.4;`[env] grad_clip=1.0(生效值 1.0)` 印证 CLI 值写进了 DeepSpeed。
* **val(8 个固定 val clip × t∈{201,501,800} × 固定噪声)**:arope step 15 → 30:loss_w 0.514/0.273/0.220 → 0.493/0.263/0.212
  (均值 0.336 → 0.322);plain step 5 → 10:0.483/0.253/0.201 → 0.485/0.255/0.201(均值 0.312 → 0.314)。
  两臂的 val 数值不可直接比:arope 的 loss_w 含 new 区 ×2 权重、文本剥了动作。
* **推理**(GPU 0,50 步 shift 5,首帧 fp32 编码):采样 12.8 s、VAE 解码 2.0 s、峰值 16.4 GB、单条含加载约 33–37 s。
  * arope step-30 replay:实测背景位移 (+372.0, +32.5)(0→80 直接 SIFT;链式 (+367.3, +33.6))vs 指令 (+370.2, +2.1)
    (GT 实测 (+384.9, +3.8),链式),帧 0 PSNR 31.4 dB,帧 1–80 平均 16.5 dB。dy 偏差 +30 px 是生成视频真实的纵向漂移
    (模板匹配独立核对 GT 为 +4、生成为 +43),不是链式测量误差。
  * arope step-30 right×1:实测 (−356.6, +22.3) vs 指令 (−355.7, 0),误差 (−0.9, +22.3) px。
  * plain step-10 right×1(当时按旧约定:文本 "moving left" 镜像标签):实测 (−5.5, +2.3) vs 指令 (−355.7, 0) —— 30 步以内文本动作基本无响应。
  这些数字与未微调基座上的结果同量级(基座 replay (+331.6, +28.7)、right×1 (−352.7, +2.7)),30 步微调不足以改变画质
  (PSNR 16 dB 仍是拖影),只证明链路通。产物在 `outputs/samples/smoke_e2e_*.{mp4,json,png}`。
* **续训**:`state_A`(step 15)/ `state_B`(step 30)各 66 GB,`state` 软链指向 `state_B`;步骤 h 从软链 load_state 后 step 31 的
  loss / grad_norm 与训练末尾连续(0.174 / 0.164),warmup 不重来。
* **正式 40k 步预估**(8 卡 1.20 s/步):纯训练 **13.3 h**,加 40 次导出(每次 ~25 s)≈ 17 min、40 次 save_state(每次 ~30 s)≈ 20 min、
  80 次验证(16 clip × 3 t,~3 s)可忽略,合计约 **14 h/臂**;40k × 8 = 32 万样本 ≈ 12,579 个有效 clip 的 25 个 epoch。

### ARoPE 5k 步首次训练结果(2026-09-04,`outputs/arope_5k/`,`scripts/eval_5k_demo.sh`)

8 卡 ZeRO-2、lr 1e-5、warmup 200、训练集 18,528、1.20 s/步、1.7 h。val loss(16 clip,t=200/500/800 均值)0.2550 → 0.2482。
同一首帧(`clip_Eybx_200000958_000273`,tidal_flats)、50 步采样、文本只有场景,实测帧 0→80 背景位移(SIFT 链式):

| 指令 | 指令位移 px | 实测 px | 说明 |
|---|---|---|---|
| right ×0.5 | −173 | −184 | |
| right ×1.0 | −346 | −366 | |
| right ×1.5 | −519 | −543 | 三档斜率 ≈1.05,线性 |
| left ×1.0 | +346 | +385 | 与 right 对称 |
| up ×1.0 | +205 | +264 | 纵向超 27%:sidecar 纵向增益仍是 1.0,模型按真实画面(≈1.2×)学的 |
| down ×1.0 | −205 | −259 | 同上 |
| right 10 cell → left 11 cell | +35 | +34 | 折返回到出发画面 |
| replay GT 偏移 | +370 | +397 | GT 视频实测 +385;帧 1–80 PSNR 17.3 dB(新区域是生成内容,PSNR 无意义) |

画面干净、场景连贯,玩家钉屏。训练中 replay 的横向超调随步数收敛:step-1000 +445 → 2000 +405 → 5000 +397。
下一步:纵向增益应按实测(k=16 拟合 1.20/1.27,全候选 1.12)写进 sidecar 重训;plain 对照臂未训。

### bg50 四科(step-5000,`actionrope/eval_bg50.py`,2026-09-04,`outputs/eval_bg50/step5000/`)

复现旧报告《世界RoPE·四科判决》的口径,只跑 ARoPE 臂:考场 `data/eval/eybx_bg_50/`(50 个 GPT 生成的未见背景,中央已合成玩家),
文本条件 `NULL_PROMPT`(训练时场景 dropout 用的串;这些背景不属于 18 个训练场景,不配场景从句,`--scene` 可覆盖),首帧 26:15 居中裁剪后缩放,
50 步 shift 5、bf16、seed 0。150 条视频(增益 10 景 × 5 档 + 折返 50 + 直行 50)8 卡 312 s 生成完,失败 0;
度量只读 mp4、CPU 48 进程 102 s。位移量法与 sidecar 校验相同(`verify_sidecar.measure_shift`:SIFT+RANSAC 仿射场在玩家位置取值;
帧 0→80 直接测不出时 16 帧链式累加)。度量代码先在合成平移视频上自检(8 项全过:位移 / 逐帧速度 / 注入倒退帧 / 折返残差与补偿 / 明暗漂移 / 增益斜率,
见 `report.md`)。折返 / 直行只用横向指令,纵向为 0(纵向增益已知偏大 ~25%,见已知问题 3)。

```bash
.venv/bin/python -m actionrope.eval_bg50 --ckpt outputs/arope_5k/step-5000.safetensors --run step5000 --stages gen,measure --gpus 0-7   # 可续跑
.venv/bin/python -m actionrope.eval_bg50 --run step5000 --stages measure --no_selfcheck                                            # 只重算度量
```

| 科目 | 指标 | ARoPE step-5000(本机 bg50) | 旧报告 世界RoPE@5k |
|---|---|---|---|
| 1 增益线性(0.5–1.5×,10 景) | 过原点斜率 / R² | **0.932 / 0.879**(剔除视差背景 01 后 0.960 / 0.962) | 1.06 / 0.95 |
| 2 折返闭环(1.5×,右 40 帧左 40 帧,50 景) | 终帧 vs 首帧 PSNR / SSIM / NCC 中位 | 19.17 dB / 0.401 / 0.787 | — |
| | 回归残差中位 / p90 · 补偿后 NCC 中位 | 8.4 / 20.4 px · 0.833 | — |
| | 守门:帧 0→40 实走距离中位(指令 256) | 240.5 px(0 条 <50%) | — |
| 3 运动平滑(1.0× 直行,50 景) | 倒退帧总数 | **16**(全部 ≤ 0.22 px 的停顿帧;>1 px 的 0) | 6 |
| | 归一速度波动 std/mean 中位 · 归一 jerk 中位 | 0.629(按 4 帧 cell 平均后 0.118)· 2.25 | — |
| | 平均速度 / 承诺 中位 | 1.014 | — |
| 4 方向服从(同一批直行) | \|角度误差\| 中位 / p90 · 幅度比中位 · 失控数 | 1.16° / 2.49° · 0.983 · 0 / 50 | — |

* 增益:各档幅度比中位 0.5× 1.015、0.75× 0.939、1.0× 0.973、1.25× 0.973、1.5× 0.908 —— 未见背景上高速档略欠冲。R² 被背景 01(赛博城市)拉低:
  生成画面带纵深视差(远景楼群带比地面带慢 ~15%),SIFT 仿射场在玩家位置取值时各段锁到不同层,16 帧链式给 −235 px 而逐帧速度累加给 −391 px(指令 −433),
  是量法在视差场景下失稳,不是模型不动;头条仍按旧报告口径全量报。
* 折返:终帧回到首帧 8 px 以内(中位),补偿位置后 NCC 只从 0.787 升到 0.833 —— 余下的差距是往返 80 帧里生成内容对首帧的重绘(PSNR 最低的 ukiyo-e / 水彩类背景整幅被重画)。
* 平滑:逐帧速度有稳定的帧内节奏(各视频均值 −6.2, −1.7, −4.3, −8.2, −1.3, −9.4 … px/帧,跨视频 std 0.4–1.1 px),来自因果 VAE 每 4 帧一个 latent cell 的时间压缩;
  按 cell 平均后波动 0.118,接近合成整数平移的下限 0.108。倒退帧 16 帧比旧报告的 6 多,但全是 ≤0.22 px 的停顿(集中在帧 20→21、42→43 的 cell 边界),没有一帧真往回走。
* 方向:角度误差全部来自纵向漂移(dy 中位 -6.5 px / 346 px 横向),无失控。

### 已知问题

1. **valid 规则已从 0.15 s 放宽到 0.5 s(2026-09-04 人定)**:原规则丢一半 s1(训练集 12,579),且日志缺口与世界降速
   高度相关,等于偷偷兼做了 rt 过滤。放宽前实测:只在两规则之间的 clip 过同一套视频校验,精度与 valid 基线一致
   (见 sidecar 一节)。缺口帧内偏移是"保持上一位置"(每条中位 13 帧),逐 cell 取均值后的误差已包含在残差里。
   世界降速改由 `rt_ratio` 闸处理:训练 `--min_rt_ratio 0.8`、val/评测 `--val_min_rt_ratio 0.9`(默认值),
   控制服从评测必须用 ≥0.9 的口径,否则"模型不听话"和"世界本来就慢"分不开。
2. **日志 M 符号反了**:数据集 meta / prompt 里的动作标签是镜像的("moving right" 实际向屏幕左走,已人工看视频确认)。
   sidecar 已用负 gain 修正,arope 臂不受影响;plain 臂训练默认 `--mirror_labels` 把动作词翻回真实方向
   (文本表 `text_table_eybx_mirror.pt`,`python -m actionrope.build_mirror_table` 生成),推理默认 `--label_mode literal`。
   `data/meta`、`data/HANDOFF.md`、`text_table_eybx.pt` 本身未改。
3. **纵向增益已改为 1.12(2026-09-04 人定,step-5000 推理 up/down 超调 27% 证实 1.0 欠标定;`outputs/sidecar_check/gain_fit_y112.json`)。以下是改前的记录:纵向增益保持 1.0,x / y 世界坐标尺度已不一致**:g_y 全候选拟合 1.094 / 1.117、R² 0.90 / 0.84 < 0.9(斜视角透视,纵向位移
   随屏幕行变化 ~40%/100 px,k=48 时 R² 0.75),按 SPEC 规则退回 1.0;但 k=16(透视影响最小)下 g_y = 1.20 / 1.27,独立
   SIFT 抽查也是 1.2–1.4。结果 ARoPE 的 dy/32 相对 dx/32 少算 10–25%,推理 up/down 指令用 `walk_speed_y = 42.25 px/s` 会系统性
   欠冲。是否把 SPEC 的 R² 判据改为按 k=16 / k=32 判定并重跑 verify(得 g_y≈−1.13~−1.2)由人决定。同一透视原因下 ARoPE 的
   "同一世界位置 ⇒ 同一编码" 对远离玩家行的 token 只是近似(顶部约 0.5×、底部约 1.5×)。
4. **位移指标不等于画质**:未微调基座 + ARoPE 已能把背景推到指令位置(SIFT 误差 < 10 px),但画面是拖影(PSNR 16 dB);
   控制服从评测必须同时看 PSNR / LPIPS。全图相位相关在真实画面上恒给 ≈0(48 个样本 |dx|,|dy|<0.6 px;合成平移能正确返回),
   json 里标了 `phase_note`,不是第二种实测。大位移下 0→80 直接 SIFT 都失败,头条数字来自 5 段链式累加(json 里有
   `sift_source` / `sift_chain_n_segments`);replay 的 dy 偏差(+30 px 量级)是生成视频真实的纵向漂移,不是测量误差。
5. **视频比日志领先 1–2 帧**(≈0.06–0.12 s):10 个动作有变化的 valid clip 上逐帧比对,最佳 lag 全为 −1 或 −2 帧
   (RMS 2.4–4.7 → 2.0–4.1 px)。来自 `data/logs/*/offsets.json` 的对齐精度,不是本仓库代码;折算 4–9 px 的系统偏差,与 x 拟合
   中位残差 4.6 px 同量级。要修可在 `geometry.sample_frame_offsets` 加 `time_bias_s`(约 −0.06~−0.1 s),未做。
6. **转场 clip 的 B 段 known 掩码只按几何定义**:530 个 valid 转场里 cell ≥ cell_of(n_pre) 的 latent 像素 72.9% 被判 known
   (首帧足迹几何上覆盖),但内容来自 src_b。SPEC 已明确这是有意的简化;要按语义把 B 段整体标 new 需在 dataset/train 里按
   n_pre 改掩码。
7. `--save_state` 的 DeepSpeed 状态 8 卡每份约 70 GB,`state_A` / `state_B` 两槽轮转(共 140 GB)、`state` 软链指向最新完整槽,
   不删旧槽;正式脚本默认开。
8. T5 回退(文本表查不到的串)已实现未实测;smoke 里所有串都命中文本表。plain 臂 + `--cfg` 的负条件已改为逐 cell 裸动作串
   (在表里),但 plain + CFG 未实测。
9. 单进程 `python actionrope/train.py` 不走 DeepSpeed:AdamW 直接在 bf16 参数上更新、无 fp32 主权重(std 0.02 的参数 lr 1e-5 走
   10 步只有 15% 元素变化),只能 smoke,数字不代表训练效果。
10. 模型看到的 timestep 是 bf16 舍入值(|Δt| ≤ 2),x_t 用精确 σ(t);DiffSynth 的 `FlowMatchSFTLoss` 是先转 bf16 再 add_noise
    (706/1000 个 id 落到相邻 id,max|Δσ| 2.5e-3),两者不逐位一致,但 train.py 的做法与推理 pipeline 一致,不改。
11. `val_eybx` 里 0 条转场 clip,`val_log.csv` 对触发 / 燃烧能力毫无反映;转场评测要另写脚本消费 `pool_heldout`(159 条)。
12. 单样本 `context [21,512,4096]` 88 MB 走 DataLoader 多进程 IPC,num_workers 2 反而比 0 慢(16 vs 21 样本/s),但每卡每步只需
    0.8 样本/s,不是瓶颈;要优化可让 Dataset 只返回去重后的 context + 下标。

## 五臂 baseline(`baseline/`,契约 `baseline/SPEC.md`)

同一个 Wan2.2-TI2V-5B、同一份数据、同一套配方,唯一自由变量是**动作以什么方式进模型**。四个对照臂都按上游代码移植
(`vender/` 只读),每个臂在 `baseline/<name>.py` 顶部 docstring 里逐条写了上游文件:行、对应关系、差异;
接入方式是 `actionrope/arope.py::arope_forward(arm=, action_inputs=)` 的固定钩子(`encode / modify_t_mod / extra_context /
after_patch / block_pre / block_mid / block_post`,`arm=None` 时与原实现逐位相同),所有新增路径零初始化——
装上瞬间输出与原模型逐位相同(各臂 `tests/test_arm_<name>.py` 测试 1 实测 max|Δ| = 0;`build_arm` 时再跑一次 `zero_init_check()`,不过就拒绝启动)。

### 机制表

| 臂 | 代表作 / 上游入口(文件:行) | 动作进模型的位置 | 用的信号 | 新增参数 | 文本 |
|---|---|---|---|---|---|
| `arope` | 本项目 `actionrope/arope.py` | h/w 轴 RoPE 位置 = 格坐标 + 累计偏移(token);known/new 掩码通道(零初始化 conv) | `offset_px` | 0(mask 通道 15,360) | scene |
| `linear` | ReactiveGWM `inference/models/dit.py` L207-209(`action_embedders`)、L228-234(`_inject_action`)、L284-286(每 block 前注入);`training/bidirectional/train.py` L134-137(zero/xavier 初始化) | 每个 block 入口残差流 `x += Linear_i(action)`(30 个 bias-free Linear,沿 h·w 广播),钩子 `block_pre` | `offset_tok` [b,21,2](上游是 10 键 one-hot 经 adaptive_max_pool 到 latent 帧) | 30×2×3072 = **184,320**(上游 10 键 921,600) | scene |
| `xattn` | Matrix-Game 3 `wan/modules/action_module.py` L39-333(ActionModule:键盘 L267-332 / 鼠标 L201-265 / 12 帧窗口 L207-214)、`wan/modules/model.py` L533-536(前 15 块)、L617-662(block 内注入)、L1097-1103(proj 置零);`utils/conditions.py` L73-107(KEYBOARD_IDX) | 前 15 块 `block_mid`(自注意力后):视频 token 作 q、逐 latent 帧键盘 token 作 k/v 沿时间轴 cross-attn;鼠标分支时间轴 self-attn;`proj_*` 零初始化 | 键盘 ← `action_idx`(9 类 → 4 位多热 up/down/left/right);鼠标 ← `delta_tok`(逐 cell 速度) | **299,531,520**(键盘 141.8M + 鼠标 157.7M;旧报告 98.6M 口径 = `--arm_kwargs '{"enable_mouse": false, "window_frames": 1}'`) | scene |
| `prompt` | Incantation `inference.py` :76-159(PromptGenerator,0.25 s = 1 latent 帧一条 prompt)、:247-260(唯一串编码一次)、:318-353(每帧只 attend 自己那帧的文本);`modules/causal_model.py` :434-450(无动作模块) | 逐 cell 文本 cross-attn(`arope_forward` 原生:q 重排 `(b f)(h w)`,k/v `(b f) L`),零钩子 | 文本(动作从句,`text_table_eybx_mirror.pt`,动作词翻回真实方向);不读 `action_inputs` | **0** | scene_action |
| `adaln` | AlayaWorld `alaya/control/action.py` :78-115(相对位姿 6 轴)、`ltx2/modules/model_ltx_2_3.py` :130-175(ActionAdaLNEmbedder 32 维正弦 + MLP)、:782-786(`action_adaln_projection` SiLU→Linear(dim,6·dim),std 1e-3)、:1171-1176 / :1252-1253(`timestep_emb += a0` 按帧广播) | `modify_t_mod`:`t_mod[b,S,6,dim] += proj(embedder(sinusoid(actions)))` 按帧广播,30 层共用(与 Wan `time_projection` 同路) | `[offset_tok, delta_tok]` 4 轴,每轴尺度 (10, 6.5, 0.6, 0.45) token,×freq_scale 1000 | **66,478,080**(embedder 9.8M + proj 56.6M;上游 LTX-2.3 dim 4096 约 118M) | scene |

`plain` 保留(= `prompt` 的旧名:`train.py --arm plain` 走 arm=None 的旧路径,`prompt` 走 `PromptArm` 的零钩子,前向逐位一致)。
`action_inputs` 由 `dataset.py` 统一给:`offset_px`(sidecar)、`offset_tok = offset_px/32`、`delta_tok`(相邻 cell 之差,cell 0 = 0)、
`action_idx`(.pt 的 `actions` 经 `prompts.MIRROR_ACTION` 翻回真实屏幕方向;64 条 clip 上与 `delta_tok` 方向的余弦均值 0.97)。
推理侧 `infer.py` 用 `baseline.make_action_inputs`(同一定义)从动作脚本生成,`action_idx = velocity_to_label(mirror=False)`,
replay 用 meta 标签经 MIRROR_ACTION 翻回。

### 接入(`baseline/__init__.py` / `train.py` / `infer.py`)

* `baseline.ARMS` / `build_arm(name, dit, arm_kwargs)`:建臂 → `install(dit)` → 搬到 dit 的 device / dtype;
  `detect_arm_from_keys / detect_arm_from_ckpt` 按 ckpt 的 `arm.*` 键集识别臂名与结构(xattn 的 `window_frames` / `enable_mouse` / `blocks`
  从张量形状反推),metadata 的 `arm` 名区分无 arm 键的 arope / plain / prompt。
* `train.py --arm {arope,plain,linear,xattn,prompt,adaln}`:新臂 = `install_arope(dit, mask_channel=False)` + 普通 RoPE + 权重全 1 +
  `text_mode` 按臂(prompt → scene_action,其余 scene);臂是 `AropeTrainModule.arm` 子模块(DeepSpeed 引擎里、同一个 AdamW、同 lr);
  导出的 safetensors = DiT 键(去 `dit.` 前缀)+ `arm.*` 键,metadata 记 `arm / arm_kwargs / n_new_params`,`config.json` 的 `model` 段
  也记;`--resume x.safetensors` 按前缀拆开热启动(ckpt 无 arm 键 ⇒ 臂保持零初始化;臂不符 ⇒ 报错)。`--arm_kwargs` 传 JSON 给臂的构造函数。
* `infer.py --arm` 可省略:按 ckpt 自动识别并 strict 装臂;`--arm plain/prompt` 文本带动作从句,其余剥掉;prompt 臂的 context 全同时也
  展成逐 cell(`PromptArm` 核对)。CFG 负条件:文本臂逐 cell 裸动作串,其余 `NULL_PROMPT`;正负两支都带动作。
* `tests/check_ckpt.py <ckpt>`(臂自动识别)核对 DiT + 臂都能 strict 装回并报告臂参数是否已非零。

```bash
bash scripts/train_baseline.sh linear                                   # 8 卡 ZeRO-2,与 train_arope.sh 同配方(GA 2、40k 步、每 1000 步存、每 500 步验证)
bash scripts/train_baseline.sh xattn --max_steps 5000 --warmup_steps 200   # 5k 步实验(与 arope_5k_ga2 同)
ARM_KWARGS='{"enable_mouse": false, "window_frames": 1}' RUN=xattn_report_5k bash scripts/train_baseline.sh xattn --max_steps 5000 --warmup_steps 200   # 旧报告 98.6M 口径
bash scripts/train_baseline.sh prompt                                   # = plain 臂(动作走逐 cell 文本)
CUDA_VISIBLE_DEVICES=0 .venv/bin/python -m actionrope.infer --ckpt outputs/linear_40k/step-N.safetensors \
    --first_frame clip_Eybx_200000958_000273 --scene tidal_flats --actions "right:1.0:21" --out outputs/samples/linear_right.mp4   # --arm 自动识别
bash scripts/smoke_baselines.sh                                         # 四臂单卡 2 步 + step-2 推理 20 步 + check_ckpt,汇总到 outputs/smoke_baselines/summary.json
.venv/bin/python -m pytest tests/ -q -x                                 # 含 tests/test_arm_*.py(GPU)与 tests/test_baseline_integration.py(CPU)
```

### 单卡 smoke 实测(`scripts/smoke_baselines.sh`,2026-09-05,GPU 4–7 轮流,机器上另有 8 卡训练在跑)

单进程 `train.py`(不走 DeepSpeed,AdamW 直接更新 bf16 参数)`--limit 8 --max_steps 2`,梯度检查点开;step-2 ckpt 推理 `right:1.0:21` 20 步;
`check_ckpt` strict 装回。数字只证明链路通(2 步的 ckpt 与基座无异,位移不响应指令属正常)。

| 臂 | 新增参数 | step 1 / step 2 耗时 (s) | 训练峰值显存 | loss (step 1 / 2) | 推理 20 步采样 / 峰值 | 实测背景位移 帧 0→80(指令 (−346.2, 0)) | ckpt |
|---|---|---|---|---|---|---|---|
| `linear` | 184,320 | 7.08 / **2.71** | 46.7 GB | 0.029 / 0.104 | 12.0 s / 16.4 GB | (−5.3, +2.0) direct | 10.00 GB,825 DiT + 30 arm 张量,30 个 arm 张量已非零 |
| `xattn` | 299,531,520 | 9.87 / **3.03** | 49.5 GB | 0.026 / 0.103 | 13.7 s / 17.0 GB | (−29.0, +81.8) chain | 10.60 GB,825 + 225,225 非零 |
| `prompt` | 0 | 6.87 / **2.91** | 46.7 GB | 0.030 / 0.104 | 13.3 s / 16.5 GB | (−6.3, +0.9) direct | 10.00 GB,825 + 0 |
| `adaln` | 66,478,080 | 6.74 / **2.78** | 47.3 GB | 0.038 / 0.075 | 11.7 s / 16.5 GB | (−5.3, +1.7) direct | 10.13 GB,825 + 6,6 非零 |

(对照:同一脚本单卡 arope 臂 1.13 s/步、46.7 GB,那时机器空闲;本次 8 卡训练同时在跑,稳态 2.7–3.0 s/步是共享 CPU / PCIe 的结果,
不是臂的开销——四臂互相之间只差 ≤ 0.3 s。)

* 第 1 步含 CUDA 初始化 / 首次分配,第 2 步才是稳态;单卡峰值显存 = 权重 + 梯度 + 两份 Adam 状态各 10 GB + 激活,臂的参数按同样比例叠加
  (xattn 300M ⇒ +2.4 GB)。8 卡 ZeRO-2 下每卡约 23 GB(`smoke_all.sh` 口径),臂参数分片后更小。
* `pytest tests/ -q -x`(GPU 4):**51 passed**,153 s(含 4 个 `test_arm_*.py`、`test_arope.py`、`test_infer.py` 各加载一次 DiT,新增 `test_baseline_integration.py` 4 个 CPU 用例)。
* `--resume outputs/smoke_linear_1gpu/step-2.safetensors` 热启动 linear 臂:`n_arm_tensors 30, arm_loaded True`,1 步后再导出 855 张量;另在 GPU 6,7 上跑了 adaln 臂 2 进程 DeepSpeed ZeRO-2(GA 2、val_n 2)2 步:每卡峰值 56.8 GB(2 卡分片,8 卡约 23 GB),导出 831 张量、`check_ckpt` strict 装回、6 个 arm 张量非零 —— 与正式配方同一条路。注意 accelerate 的 `--main_process_port` 默认 29500 被正在跑的 8 卡训练占着,另起训练要换端口。。