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
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
|
# 维护说明
结论先行:主分支保持 0 警告 0 错误 + Desktop 86/86、Kernel 79/79 全绿;性能基线
final_digest 固定 953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C。
本文记录模块职责、不变量与逐字可复制的验收命令。
## 模块职责
- `src/LivingVillage.Kernel/`:纯确定性模拟(需求/关系/谣言/年鉴/交易),无渲染依赖。
- `Sim.fs`:世界结构、`Sim.step` 主推进、对话/交易/谣言决策、地图边界
`configureBounds`(默认 64×48,可到 512×384)。
- `WorldSave.fs`:存档序列化。V2 头携带地图宽高,V1 旧档读入自动回退 64×48。
- `Personality.fs / Interaction.fs(fsm) / M6*.fs`:NPC 行为状态机与里程碑数据。
- `src/LivingVillage.Desktop/`:MonoGame 渲染与交互层。
- `VillageArt.fs`:图集加载与世界/人物/室内绘制;CC0 覆盖层(LV_ASSET_PACK=cc0)。
- `Interaction.fs`:M5 视图与情境解析(FloatingPrompt / panelLines / 情境提示)。
- `M6Presentation.fs`:昼夜光照纯函数(lightingBlend)与渲染档(Blend/LanternGlow)。
- `TitleScreen.fs / Game.fs`:标题布局与主循环;LV Autos(sample / flow / record / map tour)。
- `ProceduralMap.fs`:512×384 确定性地形(splitmix 随机游走河道 + 三倍频值噪声 + 泊松布点)。
- `SampleScript.fs`:自动演示脚本(靠近→提示→交互→关闭→离开全流程)。
- `src/LivingVillage.Headless/`:headless 入口 + PerformanceProbe(digest、基跑)。
- `scripts/make-jiangnan-art.py / make-cc0-art.py`:原创与 CC0 素材确定性生成。
- `third_party/`:Kenney Tiny Farm、Puny Characters(均 CC0 1.0,来源与 sha256 在 PROVENANCE.md)。
## 模拟状态不变量(验收依据)
1. **数组快照**:`Sim.world.Npcs` 数组与 `InteractionResolver.resolve` / `refreshPrompt` 的
调用之间必须保持恒定——选择/提示路径从不 mutation(PrototypeTests 快照回归)。
2. **确定性 RNG**:世界推进只随 `World.Rng`(splitmix)与 `(seed)` 派生,禁止墙钟与
`System.Random`。昼夜/波纹/入场/动画帧全是 `tick → 值` 纯函数。
3. **存档 v1/v2**:`WorldSave` v2 头携带 `width height`,V1 读入回退默认。`worldDigest`
对版本头与边界 token 不敏感(剥头后重构 V1 常量语义再 SHA256),摘要只绑定模拟状态。
4. **地图 512×384 大世界**:`ProceduralMap.generate` 同 seed 两次生成逐字节一致;
村核矩形(24..38 × 16..34)由生成器不变式保证可通行。
## 动画语义(P13,全部为 `tick → 帧` 纯函数)
- 角色图集 `Assets/jiangnan-characters.png`:3840×48,每个 (变体,方向) 占 **6** 格
(4 走路 + 2 待机呼吸),格宽 32 高 48。
- `cellIndex = (variantIndex * 4 + directionIndex) * 6 + frameIndex`;
方向序 North/South/West/East = 0/1/2/3,变体序 Indigo/Ochre/Jade/Grey/StrawHat = 0..4。
- frameIndex:Walk 0..3 = 0..3,IdleOne/IdleTwo = 4/5。
- `animationFrame = (tick / 4) % 4`(16 tick 一循环);`idleFrame = (tick / 24) % 2`(48 tick 一循环)。
- `characterFrame isMoving tick`:移动取走路帧,静止取呼吸帧。Avatar 用 `Game.fs` 的
`avatarMoving`(比较前后位置);NPC 用 `npcSpriteSpecAtTarget`(目标向量 ≥0.5px 视为移动)。
- 世界图集 `Assets/jiangnan-world.png`:800×32,25 格。水面 3 帧在 slot 1/13/21,
炊烟 3 帧在 slot 22/23/24。
- `waterFrameTickAt tick x y = (tick / 32 + x + 2y) % 3` → Water / WaterSpriteB / WaterSpriteC。
相位按**世界 tile 坐标**偏移,所以同一帧里相邻河面砖显示不同相位;`waterFrameTick` 仍保留为
全局相位(旧断言)。
- `smokeFrameTick = (tick / 40) % 3` → SmokeSpriteA/B/C。炊烟在 `drawWorld` 的**末尾单独一遍**
绘制,位置 `offsetTile (house door) 0 -3`(屋脊正上方),保证画在屋瓦、竹子等所有 prop 之上。
- 以上帧选择只用 `tick` 与「是否移动」,无墙钟/`System.Random`;同 tick 必得同帧。
## 预乘 alpha 与夜间光晕/炊烟接线(P14)
- **机理**:MonoGame `SpriteBatch` 默认 `BlendState.AlphaBlend` 按**预乘**语义合成
(source blend = `One`,destination = `InverseSourceAlpha`)。因此直传未预乘的
`Color(r,g,b,a)` 会把 rgb 按原值直接加进帧缓冲:多层暖色叠加在核心区饱和成
**不透明纯白方块**(P13 夜晚灯笼的 75×75 白块即由此而来;`Color(255,0,0,60)` 实测渲染为
`(255,63,68)`)。光晕、色调等任何 alpha<255 的前景绘制都必须**按自身 alpha 预乘 rgb**。
- **正确写法**:`VillageArt.premultiply r g b a`(每通道 `round(c * a / 255)`,四舍五入;
alpha=255 为恒等)。预乘后单通道 ≤ alpha,alpha<250 时不可能出现纯白。
- **夜间光晕**:`VillageArt.lanternGlowLayers cx cy radius rings r g b peakAlpha` 用同心圆
pixel run 近似径向衰减,每层色值预乘、核心多层叠加≈`peakAlpha`、边缘单层最暗;
`Game.fs` 只在 `renderPlan.Props` 的 `RiverLantern` 实体处绘制,无灯笼处不产生光斑。
- **炊烟**:见上「动画语义」——烟雾是独立的后置绘制遍,与灯笼光晕同理。
- **角色站位**:`characterDestination` 把精灵下移 `characterFeetOffset = tilePixels/2`(16px),
使脚底贴合地砖底线;`drawGroundShadow` 用 `ellipseRuns` 画预乘半透明椭圆接触阴影。
- **回归**:Desktop 单测 `PremultiplyScalesEachChannelByItsOwnAlpha`、
`LanternGlowLayersArePremultipliedAndRadial`、`WaterRipplePhaseIsDistributedPerTile`、
`CharacterFeetSitOnTheTileGroundLine`;图像级 `scripts/check-no-white-blocks.py`
对夜晚录帧断言不存在 ≥64×64、每通道 ≥250、alpha ≥250 的方块(P13 旧帧 42/69 命中,修复后 0 命中)。
## 性能基线命令
```bash
dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll --performance-baseline
```
- 口径:seed=42,npc=4,warmup=120,measure=6000,repetitions=3(见首行 `performance_config`)。
- 门槛:`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C` 三次一致 + `performance_determinism=PASS`。
- digest 变化处理:先比对 `WorldSave.save world` 全文文本(版本头变化不构成失败即 digest 规范化后等价);
再查 Sim.step 数值路径是否被改动(不允许)。历史归属见 `artifacts/perf-independent/*/README.md`。
## M3 批验:流式可观测性、门槛与长局成本(M3-1)
- **流式输出**:`--batch K D` 每个世界一完成就立即打印 `world=... OK elapsed_s=<秒>` 与
`[done k/N] elapsed=…s world=…` 并 flush,结束时 `batch_summary` 旧字段原样保留并追加
`workers=` 与 `wall_s=`(总墙钟)。完成序可能非升序,监督器按索引集合恰为 0..K-1 校验。
- **正式门槛缩尺 50×100 → 50×20**(`scripts/m3_supervise.py` 的 `PHASES.phase2`):
phase2(50×100)实测 11.4 小时仅完成 6/50 世界,单世界 100 天约 6.8 CPU 小时,
全量约 335 CPU 小时,不可行(诊断见
`artifacts/m3-acceptance/final-candidate-20260921T095500Z/logs/phase2_20260921T101500.log.diagnosis.txt`)。
Hermes 实测单 worker 成本:1 天 25.6s / 5 天 123s / 10 天 254s / 20 天 ≈900s /
100 天 ≈6.8 CPU-h。缩尺只改规模,判据语义 check a/b/c 未动。
- **成本探针**:`--cost-probe [D1,D2,...]`(默认 `1,5,10,20,50,100`)逐段真实测量单世界
`ticks_per_s`、墙钟、`allocated_bytes` 与 gen0/1/2,不估算、不写死。
本机(单 worker,npcs=30,seed=42)实测:
| days | elapsed_s | ticks_per_s | allocated_bytes | gen0 | gen1 | gen2 |
|---|---|---|---|---|---|---|
| 1 | 21.615 | 239837.5 | 19,001,478,648 | 2272 | 286 | 11 |
| 5 | 111.645 | 232165.0 | 98,066,613,520 | 11728 | 11 | 2 |
| 10 | 226.226 | 229151.8 | 202,369,225,952 | 24203 | 22 | 2 |
即约 **20.2 GB / 天·世界** 的分配;10 天内吞吐近乎平稳,超线性主要体现在更老的世界上
(Hermes 20 天 ≈45 s/天、100 天 ≈245 s/天)。
- **成本随年龄上涨的机理(只测不改)**:`World.Rumors: RumorEvent list` 永不裁剪,
每次谣言扩散 `rumor :: world.Rumors` 前插。实测 `rumor_trace count`:1 天 2025、
5 天 9821、10 天 19493(≈1950/天线性累积)。而 `latestRumorFor` / `rumorIsDuplicate` /
`rumorPath` 每次都 `world.Rumors |> List.filter/tryFind` 全表扫描,因此每次聊天/对话的
成本随世界年龄线性增长、整轮成本随时长超线性(≈平方)增长;同时每次扫描与前插都产生
列表分配,推高 GC。`Mind.Memory` 有 `memoryCapacity` 上限(实测恒 64),不是主因。
- **修复方向候选(本单不实施,避免动确定性语义)**:①按 `rumorFreshnessTicks`/半衰期
确定性裁剪工作集(谱系/annal 另存);②以 `Map<RumorId, RumorEvent>` 或逐接收者索引替代
全表扫描;③把 `latestRumorFor`/`rumorIsDuplicate` 限制到最近窗口。以上都需单开一单、
重跑 performance-baseline 与全部 digest 后由 Hermes 走冻结流程。
## M6a 批验:谣言工作集容量上界与按天淘汰
- 实施 M3 的三条修复方向:
①`RumorEvent` 新增派生字段 `DayIndex = Tick / ticksPerDay`,不参与存档序列化,读档由 `Tick` 纯函数
重建,故 v1/v2/v3 存档文本逐字节不变;
②新增可变参数 `rumorCapacity`(默认 16384)与 `rumorRetentionDays`(默认 3,>= 新鲜窗口 3 天),
纯函数 `trimRumors` 只保留最新 N 条并丢弃早于 `nowDay - rumorRetentionDays` 的条目;容量内且新鲜时
零拷贝原样返回;两个前插点 `chooseDialogue`/`chat` 统一走 `consRumor`;
③列表最新在前,`latestRumorFor`/`rumorIsDuplicate` 改早停递归(越窗即停、无中间列表),
`nextRumorId` 改读表头 O(1)。
- 等价性:保留窗口(>= 3 天)覆盖两个查询的新鲜判定,窗口外条目本就会因 `rumorStrengthAt <
rumorMinimumStrength` 被过滤,故裁剪不改变任何查询结果;「表头为最大 Id」不变量在裁剪后保持。
- 实测(本机单 worker,npcs=30,seed=42,`--cost-probe`;同机 M3 未裁剪基线见上节):
| days | 未裁剪 elapsed_s | M6a elapsed_s | 未裁剪 allocated_bytes | M6a allocated_bytes |
|---|---|---|---|---|
| 1 | 23.4(×3) | 23.4(×3) | 19,001,341,888 | 18,898,357,944 |
| 10 | 240.6 / 268.6 | 236.2 / 274.0 | 202,373,638,848 | 191,916,294,064 |
| 20 | ≈900(M3 记录) | 503.1 | — | 384,468,393,040 |
10 天分配/gen0 稳定下降约 5%;超线性项改善更大:M3 记录 20 天 ≈45 s/天,M6a 后 20 天 ≈25 s/天
(≈1.8×),单日成本回归平稳。
- 工作集实测(`evidence/` 探针,seed=42,npcs=30):第 5 天 `Rumors` 7796(收敛)vs 未裁剪 9821,
两者表头 `Id` 同为 9820,证明创建序列不变、仅淘汰已不可用的旧条目。
- 门槛:`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C` 三次一致,
`performance_determinism=PASS`;Kernel 89 / Desktop 94 全绿。
## P16 批验:启动画面 / 开始界面 / HUD 精美化
- ①启动画面:新增纯函数模块 `LaunchScreen.fs`(`durationFrames=150`、`fadeInFrames=45`、
`title/subtitle/skipHint`、`revealProgress/veilAlpha/isComplete/skipRequested`);`Game.DrawSplash`
以真实游戏内渲染绘制标题/副标题,Esc 或空格可跳过,`splashFrame` 单调推进。
- ②开始界面:`MenuState.items` 收敛为三项——无存档 `[NewGameItem; LoadItem; ExitItem]`、有存档
`[ContinueItem; LoadItem; ExitItem]`;标签 开始/继续/读档/退出;设置与操作说明不再占位(游戏内
F1/F2/F3 与 P 仍可开)。
- ③HUD:原 `DrawM5Overlay` 拆为 `DrawHudBar`/`DrawTransientStatus`/`DrawPanel`;顶部窄信息条承载
时段/时间/倍速/体力/钱币,无大黑块;`HudLayout.fs` 固化 `barHeight=36`/`accentHeight=3`/
`paddingX=16`/`segmentGap=20`/`textScale=2` 与昼/夜调色板(冬夜冷色)。`FloatingPrompt` 顶避让改为
全宽 `hudTopBarHeight=39`,移除 `hudTopRightWidth/Height`。
- ④文本:全部新 UI 文案走 CJK 图集;图集 228→238 字(补 `币 段 跳 过 空 格 清 晨 黄 昏`),行数仍 15、
PNG 仍 384×360;`Assets/cjk-glyphs.txt`、`CjkGlyphAtlas.characterTable`、`requiredUiLabels` 三者一致,无缺字。
- ⑤回归:新增 `P16UiTests.fs`(启动跳过/菜单导航/HUD 布局确定性);Desktop 94→104,Kernel 89 不变
(未触碰 Kernel/Sim.fs 数值路径)。
- ⑥证据:`/tmp/opencode/lv-p16-art/`(`frame-day-0{1,2,3}.png`、`frame-night-0{1,2,3}.png`、
`frame-splash-01.png`、`hud-bar-day.png`、`hud-bar-night.png`、`sample.mp4` 13.1 s、
`final_verification.txt`);`check-no-white-blocks.py` 对 92 帧 0 命中。
- ⑦(P16-FIX 证据补拍)`frame-menu-0{1,2}.png` 实为游戏内帧,不是开始界面;新增确定性截图模式
`LV_AUTOPLAY_MENU_SHOT=1`:跳过 hero 自动开始,停在开始界面按固定帧表取样。帧表抽为纯数据
`MenuShotScript`(splash 淡入中 30 / 初始高亮 130 / 向下 140 / 高亮读档 150 / 退出 170),附 1 例
回归;`Game.CaptureMenuShot` 复用 `SaveBackBuffer` 落图。产物:`menu-real-initial.png`(初始高亮
「开始」)、`menu-real-load.png`(按向下后高亮「读档」)、`frame-splash-mid-01.png`(淡入中帧)。
像素复验:初始帧仅第 1 行含高亮色 `(245,222,120)`(115 px),load 帧仅第 2 行含(111 px),
两帧差异 575 px,确证选择项迁移。
- 门槛:clean rebuild 0 警告 0 错误;`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C`
一致、`performance_determinism=PASS`。
## P17 批验:场景细节 / 人物动效 / 昼夜光照精修
- ①街边中式细节物:`scripts/make-jiangnan-art.py` 世界图集由 25 格扩到 28 格(新增 slot 25 石灯笼 /
26 水缸 / 27 摊贩摊位,全部烘焙进 `Assets/jiangnan-world.png`,尺寸 800x32→896x32),游戏侧只做
`tileSourceRectangle` 贴片、无程序几何。`VillageArt` 新增 `RenderPropKind.StoneLantern/WaterVat/
MarketStall` 与 `worldTileCount=28`;`sampleRenderPlan` 沿石路摆 4 件(石灯笼 4,14 / 16,14,水缸
12,11,摊位 3,11)。图集脚本二次运行 byte-identical(sha256 一致)。
- ②村民走路节奏差异化:`VillageArt` 新增 `NpcTrade`(Farmer/Peddler/Scholar,按 `NpcId mod 3` 纯函数
分配)与 `WalkRhythm`(走路/待机窗口 + 相位)。农夫 4/24、货郎 3/20(更快)、书生 6/36(更慢);
`npcSpriteSpec` 改走各自节奏,`animationFrame/idleFrame/characterFrame` 保持农夫节奏即旧语义。
- ③昼夜光照精修:`M6Presentation.lightingBlend` 过渡窗口参数化(清晨 4:00→6:30、黄昏 19:30→22:00)
并把小时改分数(原为整数小时,过渡只有 3 档),新增纯函数 `transitionWarmth`(晨昏中点火暖色峰值
1,正午/午夜 0)驱动 `backgroundAtWarmth/worldTintAtWarmth`,`M6RenderProfile` 增 `SkyWarmth`。
- ④回归:更新/新增确定性测试(行走节奏、光照锚点与暖色、图集 28 格与 PNG 尺寸、证据起时纯函数);
Desktop 107 / Kernel 89 全绿(未触碰 Kernel/Sim.fs 数值路径)。
- ⑤证据:`/tmp/opencode/lv-p17-art/`(`p17-day-12h-0{1,2}.png`、`p17-dusk-20h45-0{1,2}.png`、
`p17-night-00h-0{1,2}.png`、`p17-diff-before-dawn.png` vs `p17-diff-after-dawn.png`(同一 6:00 tick,
P17 前/后各 1 帧)、`sample.mp4` 13.1 s);`check-no-white-blocks.py` 对所选 6 帧 0 命中。起时用新增
环境钩子 `LV_AUTOPLAY_START_HOUR`(纯函数 `M6Presentation.startTickForHour`,桌面演示专用,不进 Kernel)。
- ⑥(P17-FIX 起时钩子修复)旧 `p17-day-0{1,2}` / `p17-dusk-0{1,2}` 帧的钩子在 sample/autoplay 取证路径下
失效:`StartNewGame` 重新创建世界时按 `daylightAutoplay` 把 Tick 重设为 0/6:00,覆盖了
`createInitialWorld` 设好的值,导致黄昏/昼取证实际全为「夜晚 00:00」,P17 光照精修未被真实帧展示。
修复:抽纯函数 `M6Presentation.resolveStartTick (startHour option) (startAtDaylight)`(显式小时优先,
否则 daylight 6:00,否则 0),`createInitialWorld` 与 `StartNewGame` 统一经 `evidenceStartTick`/`withStartTick`
取同一 startTick(占位/任务日一致),`new-game=ok` 日志追加 `tick=` 便于机器核验;非取证人工路径不受影响。
回归:`StartTickResolutionHonoursEvidenceHourThenDaylightThenMidnight`(Desktop 107→108),
实测 `tick`:昼 12.0→2,592,000、黄昏 20.75→4,482,000、夜 0→0,`sample.mp4` 为黄昏 20:45。
以上新文件名(`p17-day-12h-*` / `p17-dusk-20h45-*` / `p17-night-00h-*`)为准,旧名作废。
(P18 复核确认:这批帧起时虽已正确为 20:45,但画面仍为夜蓝冷调,原因是暖色渲染链缺失,
见 §P18;P17 黄昏/夜间帧的起时有效、但暖色未生效,最终以 P18 证据为准。)
- 门槛:clean rebuild 0 警告 0 错误;`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C`
一致、`performance_determinism=PASS`。
## P18 批验:黄昏暖色调真实渲染整改
- 根因(渲染调用链):①`lightingBlend` 黄昏暗化窗口 19:30–22:00,使 20:45 的 `blend=0.5`,`Mode` 直接落到
Night;②暖色仅经 `worldTintAtWarmth` 的**乘性** tint(R+10 / G−6 / B−26)施加于世界,而乘性通道无法把
低 R 的绿色植被染成橙色,地面依旧偏绿;③`timePeriodLabel` 20:00 起即为「夜晚」,与暖色窗口错位。
- 修复:黄昏**暗化**窗口改为 20:00→22:00(20:45 `blend=0.375`,仍 Day),暖色 bump 独立为 19:15–21:45
(峰值 20:30);`backgroundAtWarmth` 暖移加强(R+120 / G+20 / B−90),`worldTintAtWarmth`(R+70 / G−10 /
B−75);新增纯函数 `goldenVeilAlpha`(0..150,峰 150),`Game.DrawWorldView` 在世界与角色绘制后叠加
**预乘暖色整屏 veil**(`premultiply 255 150 70 veilAlpha`,预乘按自身 alpha 缩放、不会饱和成白块),
补上「加性暖光」这一缺失环节;`LanternGlow = max blend (warmth * 0.8)` 让黄昏灯笼同步暖亮;
`timePeriodLabel` 黄昏延到 21:00。
- 回归:更新光照锚点/单调/确定性用例并新增 veil 锚点(正午/午夜 0、20:30 峰 150、19:00 前 0),
Desktop 108 / Kernel 89(Kernel 未动)。
- 证据:`/tmp/opencode/lv-p18-art/`(`p18-dusk-20h45-0{1,2}.png`、`p18-night-deep-01.png`、
`p18-before-old-dusk-blue.png` vs `p18-after-new-dusk-warm.png`、`sample-dusk.mp4` 13.2 s)。
像素复验:旧黄昏画面均值 (55,81,54)(R−B≈1,冷蓝)→ 新黄昏 (153,115,57)(R−B≈95,暖橙),深夜
(34,60,56) 仍冷;`check-no-white-blocks.py` 0 命中。
- 门槛:clean rebuild 0 警告 0 错误;`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C`
一致、`performance_determinism=PASS`。
## P19 批验:白天偏黄过曝治理(daylight 色调路径)
- line-level 定位:offender 唯一是 `M6Presentation.worldTintAtWarmth`(`M6Presentation.fs:81-87`)的 **day 基线**
—— `lerp 248 136 blend`(:84)与 `lerp 224 184 blend`(:85)在 `blend=0`(正午)仍给出 `(255,248,224)`:蓝通道
被压约 12%,整场景乘性偏黄。黄昏暖色链在白天并不生效,已逐一排除:`transitionWarmth`(:51-60)bump 仅在
4.0–6.5 与 19.25–21.75;`goldenVeilAlpha`(:65)正午 = 0;`LanternGlow = max blend (warmth*0.8)`(:101)
正午 = 0;`lightingBlend`(:36-43)正午 = 0。故「白天偏黄」= day world-tint 基线,与黄昏暖窗无关。
- 修复:day 基线改中性白 `(255,255,255)`,乘性恒等(geometry 保留 true white balance,无伪色彩);night 基线
`(118,136,184)` 与 `warmth` 暖移不变,黄昏/清晨暖橙完全保留。
- 回归:新增 `NoonLightingIsWarmthFreeWithNeutralWhiteBalance`(DesktopTests);PrototypeTests 增白天
10:00–15:00 平台断言(warmth=0、通道中性、veil=0、glow=0);Desktop 108→109,Kernel 89 不变。
- 证据:`/tmp/opencode/lv-p19-art/`(`p19-noon-12h-0{1,2}.png`、`p19-dusk-20h45-01.png`、
`p19-night-00h30-01.png`、`p19-before-noon-yellow.png` vs `p19-after-noon-neutral.png`)。画面均值:
正午前 `(71,109,66)` R−B=+5.1(偏黄)→ 正午后 `(70,109,75)` R−B=−5.3(中性);黄昏 `(146,113,61)`
R−B=+85(暖橙保留);深夜 `(34,60,56)` 冷调;白块回归 0 命中。
- 门槛:clean rebuild 0 警告 0 错误;`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C`
一致、`performance_determinism=PASS`。
## P20 第一步:参数化确定性生成器与视口裁剪(大地图扩程前置)
- 新增纯函数模块 `src/LivingVillage.Desktop/MapGen.fs`:`generate : Params -> Result`,尺寸参数化
(默认仍是 64x48;可用 `generateWithSize 256 192 seed`)。组成:
splitmix64 随机游走河道(`RiverCount`/`RiverWidth`)、2-3 octave value noise 地形(草地/泥炭)、
泊松式最小间距布点(石头装饰);核心活动区先整平为草地,再由 **BFS(4 邻接、非水可走)**
从核心中心洪泛校验 5x6=30 个 spawn 全部可达,不可达则沿直线确定性修整并复验,结果写入 `ReachabilityOk`。
纯函数、无时钟/随机源:同 seed 两次生成逐字节一致(`Array.mapi` 校验 + checksum)。
- 视口裁剪纯函数 `MapGen.visibleTileRange`/`visibleTileCount`(与既有 `VillageArt.drawWorld` 的
`tx0..tx1/ty0..ty1` 窗口一致),`drawWorld` 已改为调用它,行为不变;`drawMapFitted`/`drawMapViewport`
为证据专用绘制(整图等比 + 1:1 裁剪),不进正常游戏循环。
- 存档兼容:沿用既有 v2/v3 的 `mapWidthTiles`/`mapHeightTiles` 边界字段(读旧档仍按 64x48),
P20 只补参数化边界测试,未改 Sim 数值语义;`final_digest` 与基线一致。
- 回归:`MapGenTests`(同 seed byte-identical、异 seed 不同、256x192 30 spawn 全可达、默认尺寸 64x48、
裁剪范围/端点钳制)6 条;Kernel `SaveRoundTripsParameterizedMapBounds`(256x192 v2 round-trip)1 条。
Desktop **109 -> 115**,Kernel **89 -> 90**。
- 实测性能(Xvfb,本机):生成 256x192 = **52 ms**、64x48 = **1.9 ms**;256x192 生成
`reachable_ok=true`、`reachable_tiles≈47684`;裁剪后单帧仅绘制 **1050/49152 = 2.1%**
(`culled=97.9%`),离屏单帧 **0.62 ms/帧**(implied ≈1610 fps,仅 harness 立即模式)。
- 证据:`/tmp/opencode/lv-p20-art/`(`mapgen-256x192-seed{4242,7,99}.png` 整图三 seed、
`mapgen-culled-256x192-seed{...}.png` 裁剪帧、harness 计数日志)。白块回归 0 命中。
- 门槛:clean rebuild 0 警告 0 错误;`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C`
3 次一致、`performance_determinism=PASS`。
## P20 第二步:河道连续性不变量 + 桥 / 石板路网 / 沿河民居
- 河道重做:由「`not (inCore)` 逐格随机游走」改为**贯穿全图的东西向连续水带**(`Rivers : River list`,
每条每列恰好一段、宽度恒 `>= max 3 RiverWidth`),置于核心区上下两侧且与核心不相交。
新增不变量测试 `RiversAreContinuousWithoutGapsOrIsolatedTiles`:逐列水段数 == `Rivers.Length` 且每段
`>= RiverWidth`、任意水格 4 邻接水邻居 `>= 2`(禁止断裂缺口与孤立水点)。
- 交通结构(`Result` 新增 `Bridges`/`Paths`/`Buildings`/`BridgeCrossingsOk`):
每条河 2 座桥(2 格宽,桥面仍记水但 `isWalkable` 放行);主石板路沿核心中心行横贯全图,
每座桥有垂直连接路贯通两岸与远岸路;远岸路两侧布置民居矩形(`Building`:白墙黑瓦两行色块,门朝路)。
`MapGen.isWalkable`/`MapGen.floodFill` 公开供测试:`ReachabilityOk` 现在同时要求 30 spawn、所有门、
所有桥面可达;`BridgeCrossingsOk` 要求每座桥两岸均被同一连通域覆盖。
- 回归新增 4 条:河道连续性、桥/路/民居存在且沿河、桥两岸可达(spawn/门/桥全连通)、结构逐字节确定性。
Desktop **115 -> 119**,Kernel **90** 不变。
- 渲染证据(`drawMapFitted` 每瓦片 6px 放大 + `drawMapViewport` 1:1 + 桥中心 detail 帧);
`p20b-map-*`(整图,含桥/路/白墙黑瓦民居)、`p20b-detail-*`(1:1 桥/路/民居近景)、
`p20b-culled-*`。白块回归 0 命中;整图 1536x1152。
- 实测(Xvfb,本机):256x192 生成 ≈ **63 ms**、64x48 ≈ **2.0 ms**;`reachable_ok=true`、
`reachable_tiles=47652`(= 49152 - 两河水面 1524 + 桥面 24,三 seed 恒定,印证连续性不变量);
裁剪帧 1050/49152 = 2.1%,1:1 单帧 ≈ 0.7 ms(implied ≈1300-1600 fps)。
- 门槛同上:clean rebuild 0/0;`final_digest` 3 次一致、`performance_determinism=PASS`。
## P20 第三步:河景与可玩接入收口
- 默认可玩世界改走 **256x192 生成器**:`Game` 启动时 `Sim.configureBounds 256 192` 并
`MapGen.generateWithSize 256 192 42UL`,用 `WorldBootstrap.initialWorldInMap` 以生成器核心可达区站位
(30 NPC 全落可达核心,avatar 落核心中心);`LV_LEGACY_MAP=1` 或 `LV_MAP_SCALE>1`/`LV_MAP_TOUR=1`
回退旧路径(64x48 样例世界 / ProceduralMap 大世界)。`LV_AUTOPLAY_SAMPLE=1`(非巡游)也保持 64x48
以确保既有录像可复现,只有显式 `LV_RIVERSCAPE_TOUR=1` 才启用生成器世界;启动日志打印 `world-mode=`。`WorldBootstrap.initialWorldWithPlacement`
为显式站位入口,旧 64x48 函数字节口径不变(Kernel/Desktop 既有断言不动)。
- 渲染接入:`VillageArt.drawWorld` 新增 `pixel` 与 `mapGen : MapGen.Result option`——
Some 时按生成器瓦片绘制地面(1 水 / 2 石板路 / 其余草地)、叠加 `drawMapStructures`(桥面 + 白墙黑瓦民居 + 门)
与桥头 `LanternSprite`,并跳过 64x48 样例平面/道具;水格沿用既有 `waterFrameTickAt` 多相位波纹。
- 河景灯笼:新增 `VillageArt.bridgeLanternTiles`(每桥每列最上/最下格 = 桥两端);夜间 `LanternGlow`
沿用既有预乘同心圆 `lanternGlowLayers ... 255 214 150`(软暖盘,无过曝白芯),生成器世界用桥头灯笼位置。
- 确定性:`MapGen.serialize` 对尺寸/seed/瓦片/河道/桥/路/民居做序列化,同 seed 逐字节一致;
`SameSeedSerializationIsByteIdentical` + `MapWorldPlacementUsesReachableCoreSpawns` 两条新测试。
- 回归:Desktop **119 -> 121**,Kernel **90** 不变;clean rebuild 0 警告 0 错误;
`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C` 3 次一致、
`performance_determinism=PASS`(未触 Kernel/Sim 数值路径)。
- 证据(`LV_AUTOPLAY_SAMPLE=1 LV_RIVERSCAPE_TOUR=1`,`LV_RECORD_NAME=p20c-*`):真人走位巡游沿桥/路/民居门
逐帧录制,`/tmp/opencode/lv-p20-art/p20c-day-*.png`(`LV_AUTOPLAY_START_HOUR=12.0`)与
`p20c-night-*.png`(20.75)各若干;挑出 `p20c-day-bridge-01.png`、`p20c-day-buildings-01.png`、
`p20c-night-bridge-01.png`、`p20c-night-lantern-01.png` 等代表性帧;716 张全过白块回归 0 命中。
## 大世界(P21 性能与镜头收口)
- 默认世界:`Game` 启动即 `Sim.configureBounds 256 192` + `MapGen.generateWithSize 256 192 42UL`
(`MapGen.defaultParams`:`RiverCount=2`、`RiverWidth>=3`、`CoreSide=14`、5x6=30 spawn、
splitmix64 河道 + 2-3 octave value noise + 泊松布点)。核心区先整平,再 BFS 校验 30 spawn
与桥/路/民居门全部连通(`ReachabilityOk`/`BridgeCrossingsOk`);站位由
`WorldBootstrap.initialWorldInMap` 取核心可达域,avatar 落核心中心。
- `world-mode` 回退开关(启动日志会打印实际模式与 bounds):
- `LV_LEGACY_MAP=1` -> 旧 64x48 样例世界;
- `LV_MAP_SCALE=N`(N>1)-> ProceduralMap 大世界 `64N x 48N`;
- `LV_MAP_TOUR=1` -> ProceduralMap 巡游;
- `LV_AUTOPLAY_SAMPLE=1`(非巡游)-> 保持 64x48,保证既有录像可复现;
- `LV_RIVERSCAPE_TOUR=1` -> 显式启用 256x192 生成器世界巡游。
- 视口裁剪:`MapGen.visibleTileRange` 只绘制可见瓦片(`drawWorld` 已接入),256x192 下
1280x720 视口约绘制 `1050/49152 = 2.1%`,`culled=97.9%`(P20 第二步离屏实测)。
- 实测性能(本机 Xvfb 1280x720x24,Desktop Release,256x192 可玩世界,`LV_UNLOCK_FPS=1`
放开 vsync 以测真实上限):
- 帧率:`LV_RIVERSCAPE_SECONDS=60` 实测 58 个有效秒采样(丢 5 秒 warmup),
**mean 230.4 FPS / min 199.5 FPS / max 302.8 FPS**;默认 vsync+固定步长为稳定 60 FPS。
- 内存:运行中 `VmRSS` 181.9–183.4 MB(≈182.7 MB 均值,含 MonoGame 运行时与图集),
60s 内平稳无泄漏。
- 生成:256x192 约 52–70 ms;64x48 约 2 ms(P20 实测)。
- headless:`--performance-baseline` 3 次 `ticks/s` 区间 **189,595–445,377**(宿主负载相关),
`allocated_bytes≈3.03 MB/6000 ticks`、`gen0=0`,`final_digest=953775FA…`、
`performance_determinism=PASS`(未触 Kernel/Sim 数值路径)。
- 取证:`/tmp/opencode/lv-p21-art/`(P22 重挑后的游戏内代表帧 9 张:
`p21-day-bridge-01`/`p21-day-river-01`/`p21-day-buildings-0{1,2}`、
`p21-dusk-bridge-01`/`p21-dusk-street-01`、`p21-night-bridge-01`/`p21-night-street-01`/
`p21-night-lantern-01`;原始帧 `raw-day/`、`raw-night/`、`raw-night2/`),白块回归 0 命中。
## 大世界默认开启评估(P22)
- 结论:**256x192 生成器世界已经是默认可玩世界**(P20 第三步即已切换,`riverscapeActive`
在非 legacy/非 map-scale/非 sample 时成立),`LV_LEGACY_MAP=1` 等回退开关保留。P22 复核
证据支持维持现状,未再改默认值。
- 实测对比(本机 Xvfb 1280x720x24,Desktop Release,`LV_UNLOCK_FPS=1` 放开 vsync,
丢 5s warmup 后按秒采样;两者工作负载不同,仅作量级对比):
- 256x192 生成器(巡游,1x 模拟):`mean 230.4 / min 199.5 / max 302.8 FPS`(58 样本)。
- 64x48 样例世界(sample 脚本,2x 模拟):`mean 163.5 / min 113.9 / max 224.3 FPS`(65 样本)。
小世界无裁剪收益、屏内 NPC 更多且 2x 模拟,反而更慢;两种世界都远高于 60 FPS 目标。
- 内存:256x192 运行中 `VmRSS` 181.9–183.4 MB;64x48 为 179.5–182.3 MB —— 差异在噪声内,
内存由 MonoGame 运行时/图集主导,与地图尺寸基本无关。
- 生成耗时:256x192 约 52–70 ms、64x48 约 2 ms(一次性,发生在进入世界前,无可见卡帧)。
- 风险:唯一成本是启动时约 60 ms 一次性生成,无逐帧开销(视口裁剪 97.9%);headless
`final_digest` 与 `performance_determinism` 不受影响(未触 Kernel/Sim 数值路径)。回退路径
完整,可随时 `LV_LEGACY_MAP=1` 或 `LV_MAP_SCALE=N`。**评估结论:维持 256x192 默认。**
- 遗留观察(仅记录,留给后续单):
- 初始 NPC 站位是核心区内的**规则网格**(`MapGen.fs` 第 253 行「30 个 spawn:核心区内的
规则网格」),P20 巡游首帧会看到整齐排布;巡游开始后自然散开,暂不改。
- 个别帧右侧有**孤立砖石小块**(地面 `GroundTile.Stone=2` 单格,`VillageArt.fs:664` 绘
`StonePathSprite`),疑似路网/门阶的单格支路残留,需后续单用定点帧确认后再修。
## 大世界扩程 512x384(P23)
- Kernel/Sim:边界本就是可变参数(`Sim.configureBounds w h`,默认仍 64x48);512x384 直接可用。
存档 v2/v3 头两字段即 `bounds.w|bounds.h`,v1 旧档仍按 64x48 读入(`WorldSave.parse`)。
512x384 存档 round-trip、同 world 两次保存逐字节一致、长跑 20000 tick 的 avatar/NPC 边界
与有限性均由 Kernel 用例覆盖(Kernel 90 -> 92)。
- MapGen 尺寸自适应(`paramsForSize`):
- 河道数量约每 96 行一条:64x48/256x192 仍 2 条,512x384 得 4 条;第 3 条起在核心区上/下方
逐层外扩,互不重叠、不压核心,仍满足「每列恰好一段、宽度 >= RiverWidth、水格 >=2 水邻居」。
- 噪声特征尺度封顶 4x(`noiseWidthScale`),避免大图退化成过度平滑的放大图;泊松布点间距
`w/24`、尝试数 `(w*h)/16` 本就随尺寸缩放。
- **64x48 / 256x192 生成结果逐字节不变**(新增 checksum 锁定用例:64x48=6042052、
256x192=1284656962)。默认世界仍是 256x192,本轮不切换。
- 生成耗时(本机实测,单次):64x48 ≈ 3 ms;256x192 ≈ 55 ms;512x384 ≈ 134-248 ms
(一次性、进入世界前)。
- 512x384 巡游帧率(Xvfb 1280x720x24,Desktop Release,`LV_UNLOCK_FPS=1` 放开 vsync):
单趟巡游 161.7 s,`perf-summary samples=156 mean_fps=153.84 min_fps=51.64 max_fps=228.81`;
运行中 `VmRSS` 185.9-201.9 MB。默认 vsync+固定步长为稳定 60 FPS。
- 取证:`/tmp/opencode/lv-p23-art/`(`p23-map-512x384-seed{4242,7}.png` 整图真实渲染、
`p23-culled-*`/`p23-detail-*` 视口/近景、`tour512/` 巡游帧),白块回归 0 命中。
- 取证钩子:`LV_RIVERSCAPE_WIDTH`/`LV_RIVERSCAPE_HEIGHT`(默认 256x192)把生成器世界扩到
512x384 跑巡游/性能;`LV_MAPGEN_SHOT=1` + `LV_MAPGEN_WIDTH/HEIGHT/SEED` 出整图。
## 民居密度参数(P24)
- `MapGen.Params.ExtraRiversideHouses`:大图额外沿河民居数量,默认 0;`paramsForSize` 仅对
`width>256 || height>192` 设为 **10**。512x384 在原有 8 座桥点民居上再按现有路网泊松散布
10 座(`structureRng` 确定性),**共 18 座**,门全部接入路网且 BFS 可达;64x48/256x192 仍
4 座、checksum 6042052 / 1284656962 不变。
- 民居渲染改用 256x192 同款部件:黛瓦(`RoofRidgeSprite`/`RoofEaveSprite`)+ 木墙/窗
(`WallWoodSprite`/`WallWindowSprite`)+ 门(`DoorSprite`),替换原黑白矩形色块。
- 村民可见性证据钩子 `LV_NPC_VISIBLE_LOG=1`:每秒打印 `visible-npcs=K of 30`;512x384 巡游中
观测到最大 30/30 全部在屏(`/tmp/opencode/lv-p24-tour.txt`)。
- 取证:`/tmp/opencode/lv-p24-art/`(`p24-map-512x384-seed{4242,7}.png` 整图、
`p24-zoom-houses.png`/`p24-zoom-villagers.png` 局部放大含民居与村民、`p24-tour-0{1,2,3}.png`
tour 页、`tour512/` 全量帧),白块回归 0 命中。
## 巡游村民可见性 + 孤立石板治理(P25)
- **成因**:NPC 行动目标 `Sim.actionTarget` 固定在遗留四个活动点(厨房 10,10 / 家 52,10 /
集市 32,24 / 工位 52,38),512x384 世界里它们落在左上区域,NPC 出生后从核心走向那里;
原巡游路点走桥/门/核心,故大多数采样帧 `visible-npcs` 只有 0-3。
- **解法(Desktop 取证钩子,不动 Kernel/Sim 数值路径)**:新增 `LV_RIVERSCAPE_VILLAGE_TOUR=1`,
巡游路点改为在这四个村民活动点之间环游(`villageTourWaypoints`)。512x384 跑 150 s:
`visible-npcs>=8` 的采样 **112/152 = 74%**,进入村庄段后 **108/122 = 89%**(后 90 s 91%),
峰值 30/30;日志 `/tmp/opencode/lv-p25-village150.txt`。确定性不变。
- **孤立石板修复**:成因是步骤 4 的泊松装饰石块(`GroundTile.Stone=2` 单格渲染成灰石板),
512x384 有 292 个孤立石板。新增 `Params.DecorativeStones`(默认 true),`paramsForSize`
对大于 256x192 的图关闭 → 大图不再产孤立路面 tile(装饰关闭后大图石块全部属于路网)。
64x48/256x192 保持开启,checksum 6042052 / 1284656962 不变。
- 回归:`LargeMapHasNoIsolatedStoneTiles`(大图无孤立石块、石块全属路网)、
`DecorativeStonesDisabledOnLargeMaps`;Desktop 134 -> 136。
- 取证:`/tmp/opencode/lv-p25-art/`(`p25-map-512x384-seed4242.png` 整图无灰石板、
`p25-zoom-village.png` 放大含 >=8 村民、`p25-tour-0{1,2,3,4}.png` tour 页、`tour/` 全量帧),
白块回归 241 张 0 命中。
## 江南水乡第 4 档:512×384 美术可读性与质感(P26)
- **① 民居立面第二变体**:世界图集由 28 格扩到 **32 格**(尺寸 896x32→1024x32),新增
slot 28 屋檐挑出 / 29 木格栅窗 / 30 院门;`scripts/make-jiangnan-art.py` 仍为确定性管线,
二次运行 byte-identical。`VillageArt.houseFacadeVariant (building) = (Left+Top) % 2` 为纯函数,
沿河沿路确定性交替:变体 A 保持 P24 的黛瓦脊 + 木墙/窗 + 门;变体 B 用 `RoofEaveOverhangSprite`
向两侧各挑出一格,墙身用 `WallLatticeSprite`/`CourtyardDoorSprite`。只改绘制分支,
门可达不变量与 `MapGen` 瓦片零改动(64x48=6042052、256x192=1284656962 不变)。
- **② 河岸细节(仅 512)**:`MapGen.Params.RiverDecorations`(默认 false;`paramsForSize` 仅对
`width>256 || height>192` 置 true)。`MapGen.Result.Decorations: RiverDecoration list` 三类——
护岸石垒 `RevetmentStone` / 芦苇 `Reeds` / 垂柳 `Willow`(占上下两格)。**纯视觉层,不写入任何
瓦片**,故沿用 `DecorativeStones` 参数化先例的同时不产孤立石板;装饰只落在紧邻水面的岸格,
并避开路网/桥面/民居/核心区。回归:`RiverDecorationsOnlyEnabledOnLargeMaps`、
`RiverbankDecorationsSitOnBanksAndNeverBlockPathsOrBridges`(岸格相邻水 + 不压路/桥/核心)、
`LargeMapRiverDecorationsAreDeterministic`。
- **③ 夜晚光照二次整改(仅 Desktop 渲染层)**:新增纯函数 `M6Presentation.moonlightVeilAlpha`
(0..72,仅 deep night 有值,暖色晨昏窗口被 `warmth` 压制)与 `moonlightRgb = (120,170,235)`;
`Game.DrawWorldView` 在金辉 veil 之后叠加预乘冷色整屏 veil,`premultiply` 保证 alpha<250 不可能
饱和成白块。灯笼光晕复核回归 `LanternGlowIsRadialWithoutSquareCorners`:逐像素累积同心圆 alpha,
四角恒为 0、中心行沿半径单调递减,确认无残留方斑(图像级 145+142 帧白块回归 0 命中)。
- **④ HUD 微调**:`HudLayout.textOutlineOffsets`(上下左右 4 偏移)、`HudLayout.segmentBackdrop`
与 `Palette.SegmentBackdrop/Outline`;`DrawHudBar` 每个常驻片段(时段/时间/倍速/体力/钱币)
先铺半透明深色底药丸,再以 1px 深色描边文字,昼/夜两套调色板均不透明描边。回归
`HudPersistentSegmentsGetBackdropAndOutlineInBothPeriods`。
- **回归**:Desktop **136 → 143**,Kernel 92 不变(未触 Kernel/Sim 数值路径,未动 M3)。
- **门槛**:clean rebuild 0 警告 0 错误;`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C`
3 次一致、`performance_determinism=PASS`;默认世界仍 256×192。
- **取证** `/tmp/opencode/lv-p26-art/`(沙箱把规格路径 `/tmp/lv-p26-art` 映射到此,不放 git):
`p26-map-512x384-seed4242.png`(512 整图真实渲染 3072x2304)、`p26-zoom-480x360-2x.png`
(村庄局部 480×360 裁切 → 2× 最近邻放大,含 30/30 可辨村民)、`p26-day-village.png`、
`p26-night-village.png`(夜景:冷月光 + 桥头灯笼 + HUD)、`p26-hud-day.png`/`p26-hud-night.png`
(HUD 双时段裁切 2×)、`villager-visibility.txt`(autorun 巡游复跑可见村民统计)、
`white-block-day.txt`/`white-block-night.txt`(0 命中)。
## 已验证命令
```bash
# 完整重建(0 警告 0 错误)
dotnet clean LivingVillage.sln 2>&1 | tail -n1
rm -rf src/*/bin src/*/obj
dotnet restore LivingVillage.sln
dotnet build LivingVillage.sln -c Release 2>&1 | tail -n3
# 全量测试
dotnet test src/LivingVillage.Desktop.Tests -c Release --no-build
dotnet test src/LivingVillage.Kernel.Tests -c Release --no-build
# M3 批次(流式;判据行与原格式逐字节兼容,仅行尾追加 elapsed_s)
LV_BATCH_WORKERS=4 dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll --batch 4 10
# 长局成本阶梯(只读,真实测量)
dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll --cost-probe 1,5,10
# 素材再生成(确定性,输出 byte-identical)
python3 scripts/make-jiangnan-art.py && python3 scripts/make-cc0-art.py
# Xvfb 录屏取证(沿用既有 pN harness 外参即可;harness 不入库)
pkill -f "Xvfb :99"; Xvfb :99 -screen 0 1280x720x24 & sleep 2
ffmpeg -y -f x11grab -video_size 1280x720 -framerate 10 -i :99 -pix_fmt yuv420p out.mp4
LV_AUTOPLAY_SAMPLE=1 LV_AUTOPLAY_DAYLIGHT=1 LV_AUTOPLAY_RECORD=1 \
dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll
# P16-FIX 开始界面截图取证(停在主菜单,拍初始/读档高亮与 splash 淡入中帧)
timeout 90 xvfb-run -a -s "-screen 0 1280x720x24" env \
LV_AUTOPLAY_MENU_SHOT=1 LV_RECORD_DIR=/tmp/opencode/lv-p16-art \
dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll
# P17 取证(街边细节/人物节奏/昼夜精修;LV_AUTOPLAY_START_HOUR 定起时:昼 12 / 黄昏 20.75 / 夜 0)
for pair in day:12.0 dusk:20.75 night:0.0; do name=${pair%%:*}; hour=${pair##*:}; \
timeout 100 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \
LV_AUTOPLAY_START_HOUR=$hour LV_AUTOPLAY_RECORD=1 \
LV_RECORD_DIR=/tmp/opencode/lv-p17-art/$name LV_RECORD_EVERY=2 \
dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll; done
# P19 白天色温取证(正午 12 / 黄昏 20.75 / 深夜 0.5 三帧对比)
for pair in noon:12.0 dusk:20.75 night:0.5; do name=${pair%%:*}; hour=${pair##*:}; \
timeout 100 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \
LV_AUTOPLAY_START_HOUR=$hour LV_AUTOPLAY_RECORD=1 \
LV_RECORD_DIR=/tmp/opencode/lv-p19-art/$name LV_RECORD_EVERY=2 \
dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll; done
# P21 性能核对:256x192 可玩世界实机帧率(放开 vsync)+ 60s 稳定采样
timeout 90 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \
LV_RIVERSCAPE_TOUR=1 LV_RIVERSCAPE_SECONDS=60 LV_UNLOCK_FPS=1 LV_PERF_SUMMARY=1 \
LV_AUTOPLAY_START_HOUR=12.0 \
dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll
# 内存(运行中采样 VmRSS)与 headless ticks/s
PID=$(pgrep -n -f LivingVillage.Desktop.dll); awk '/VmRSS/{print $2" kB"}' /proc/$PID/status
dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll --performance-baseline
# P21 昼夜巡游取证(真走位,p21-* 命名;代表帧挑出到 /tmp/opencode/lv-p21-art/)
for pair in day:12.0 night:20.75; do name=${pair%%:*}; hour=${pair##*:}; \
timeout 150 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \
LV_RIVERSCAPE_TOUR=1 LV_AUTOPLAY_RECORD=1 LV_RECORD_EVERY=20 LV_RECORD_NAME=p21-$name- \
LV_RECORD_DIR=/tmp/opencode/lv-p21-art/raw-$name LV_AUTOPLAY_START_HOUR=$hour \
dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll; done
# P22 补夜档巡游(23:30),并从中重挑「游戏内」代表帧覆盖启动/标题画面误帧
# HUD 判定:y in [2,35) 且 x in [0,w/2) 的近白像素 > 500 => 游戏内帧(标题/启动为 0)
timeout 150 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \
LV_RIVERSCAPE_TOUR=1 LV_AUTOPLAY_RECORD=1 LV_RECORD_EVERY=40 LV_RECORD_NAME=p21-n2- \
LV_RECORD_DIR=/tmp/opencode/lv-p21-art/raw-night2 LV_AUTOPLAY_START_HOUR=23.5 \
dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll
# P22 默认评估:64x48 样例世界 60s 帧率采样(LV_PERF_SOAK_SECONDS 让脚本循环采样)
timeout 120 xvfb-run -a -s "-screen 0 1280x720x24" env LV_LEGACY_MAP=1 \
LV_AUTOPLAY_SAMPLE=1 LV_UNLOCK_FPS=1 LV_PERF_SUMMARY=1 LV_PERF_SOAK_SECONDS=60 \
dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll
# P23 512x384 扩程取证:整图真实渲染 + 视口/近景(seed 4242 与 7)
for seed in 4242 7; do timeout 120 xvfb-run -a -s "-screen 0 1280x720x24" env \
LV_MAPGEN_SHOT=1 LV_MAPGEN_WIDTH=512 LV_MAPGEN_HEIGHT=384 LV_MAPGEN_SEED=$seed \
LV_RECORD_DIR=/tmp/opencode/lv-p23-art \
dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll; done
# P23 512x384 巡游帧率(单趟,解锁 vsync;日志打印 perf-summary 与 VmRSS 可另采)
timeout 280 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \
LV_RIVERSCAPE_TOUR=1 LV_RIVERSCAPE_WIDTH=512 LV_RIVERSCAPE_HEIGHT=384 \
LV_UNLOCK_FPS=1 LV_PERF_SUMMARY=1 \
dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll
# P24 512x384 民居密度取证:整图 + 巡游(记录帧;LV_NPC_VISIBLE_LOG 打印可见村民数)
timeout 240 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \
LV_RIVERSCAPE_TOUR=1 LV_RIVERSCAPE_WIDTH=512 LV_RIVERSCAPE_HEIGHT=384 \
LV_UNLOCK_FPS=1 LV_NPC_VISIBLE_LOG=1 LV_AUTOPLAY_RECORD=1 LV_RECORD_EVERY=120 \
LV_RECORD_NAME=p24-tour- LV_RECORD_DIR=/tmp/opencode/lv-p24-art/tour512 \
dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll
# 局部放大由 tour512 帧裁 480x360 后 2x 最近邻放大(含民居+村民)
# P25 村民可见性巡游:路点在 Sim 四个活动点间环游,150s 循环,多数采样帧 >=8 村民
timeout 220 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \
LV_RIVERSCAPE_TOUR=1 LV_RIVERSCAPE_VILLAGE_TOUR=1 LV_RIVERSCAPE_WIDTH=512 \
LV_RIVERSCAPE_HEIGHT=384 LV_RIVERSCAPE_SECONDS=150 LV_UNLOCK_FPS=1 \
LV_NPC_VISIBLE_LOG=1 LV_AUTOPLAY_RECORD=1 LV_RECORD_EVERY=120 \
LV_RECORD_NAME=p25-tour- LV_RECORD_DIR=/tmp/opencode/lv-p25-art/tour \
dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll
# P25 整图取证(无孤立灰石板)
timeout 120 xvfb-run -a -s "-screen 0 1280x720x24" env LV_MAPGEN_SHOT=1 \
LV_MAPGEN_WIDTH=512 LV_MAPGEN_HEIGHT=384 LV_MAPGEN_SEED=4242 \
LV_RECORD_DIR=/tmp/opencode/lv-p25-art \
dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll
# P26 512 整图真实渲染(含两种民居立面 + 河岸装饰;产物 p20b-map-*.png,改名为 p26-map-*)
timeout 150 xvfb-run -a -s "-screen 0 1280x720x24" env LV_MAPGEN_SHOT=1 \
LV_MAPGEN_WIDTH=512 LV_MAPGEN_HEIGHT=384 LV_MAPGEN_SEED=4242 \
LV_RECORD_DIR=/tmp/opencode/lv-p26-art/map \
dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll
# P26 昼夜村庄巡游录帧(村民可见性日志 + 夜景月光/灯笼/HUD 取证)
for pair in day:12.0 night:23.5; do name=${pair%%:*}; hour=${pair##*:}; \
timeout 200 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \
LV_RIVERSCAPE_TOUR=1 LV_RIVERSCAPE_VILLAGE_TOUR=1 LV_RIVERSCAPE_WIDTH=512 \
LV_RIVERSCAPE_HEIGHT=384 LV_RIVERSCAPE_SECONDS=40 LV_AUTOPLAY_START_HOUR=$hour \
LV_NPC_VISIBLE_LOG=1 LV_AUTOPLAY_RECORD=1 LV_RECORD_EVERY=30 \
LV_RECORD_NAME=p26-$name- LV_RECORD_DIR=/tmp/opencode/lv-p26-art/$name \
dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll; done
# 局部 480x360 锚定村民+民居质心裁切后 2x 最近邻放大 -> p26-zoom-480x360-2x.png
# HUD 双时段:裁顶部 y 0..44 左 640px 再 2x -> p26-hud-day.png / p26-hud-night.png
# P26 夜景白块回归(方斑核对,0 命中)
python3 scripts/check-no-white-blocks.py /tmp/opencode/lv-p26-art/night
# P20 第三步:默认生成器世界的昼夜巡游录制(真走位,含桥面行走帧;p20c-* 命名)
for pair in day:12.0 night:20.75; do name=${pair%%:*}; hour=${pair##*:}; \
timeout 150 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \
LV_RIVERSCAPE_TOUR=1 LV_AUTOPLAY_RECORD=1 LV_RECORD_EVERY=20 LV_RECORD_NAME=p20c-$name- \
LV_RECORD_DIR=/tmp/opencode/lv-p20-art/p20c-$name LV_AUTOPLAY_START_HOUR=$hour \
dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll; done
# P20 大地图生成器取证(整图 + 裁剪帧;每 seed 一拍即退,日志打印 gen_ms/drawn/culled)
for seed in 4242 7 99; do timeout 120 xvfb-run -a -s "-screen 0 1280x720x24" env \
LV_MAPGEN_SHOT=1 LV_MAPGEN_WIDTH=256 LV_MAPGEN_HEIGHT=192 LV_MAPGEN_SEED=$seed \
LV_RECORD_DIR=/tmp/opencode/lv-p20-art \
dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll; done
# P18 黄昏暖色取证(20:45 真实帧;深夜把 20.75 换成 0)
timeout 100 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \
LV_AUTOPLAY_START_HOUR=20.75 LV_AUTOPLAY_RECORD=1 \
LV_RECORD_DIR=/tmp/opencode/lv-p18-art/dusk LV_RECORD_EVERY=2 \
dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll
# 街边细节图集再生成(确定性,二次运行 byte-identical)
python3 scripts/make-jiangnan-art.py
# P13 动画证据(确定性接触表 + 真实运行帧)
python3 scripts/make-animation-evidence.py # -> evidence/walk-idle-frames.png 等
# 真实运行帧(白天 / 夜晚各一次;record 目录与间隔可用环境变量覆盖)
timeout 45 xvfb-run -a -s "-screen 0 1280x720x24" env \
LV_AUTOPLAY_SAMPLE=1 LV_AUTOPLAY_DAYLIGHT=1 LV_AUTOPLAY_RECORD=1 \
LV_RECORD_DIR="$PWD/evidence/day-run" LV_RECORD_EVERY=2 \
dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll
timeout 45 xvfb-run -a -s "-screen 0 1280x720x24" env \
LV_AUTOPLAY_SAMPLE=1 LV_AUTOPLAY_RECORD=1 \
LV_RECORD_DIR="$PWD/evidence/night-run" LV_RECORD_EVERY=2 \
dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll
```
- `LV_RECORD_DIR` 默认 `/tmp/lv-p5-prompt`,`LV_RECORD_EVERY` 默认 25(每 N 次绘制存一帧)。
跑完 `sample result=ok`;连续移动帧(如 day-run 的 0136..0146)即走路逐帧采样。
- 夜晚白块图像级回归(0 命中 = 通过;对 P13 旧帧应报 42/69 命中):
```bash
python3 scripts/check-no-white-blocks.py evidence/night-run
```
## 取证快照位置(近期)
- `/tmp/lv-p2-ui/`、`/tmp/lv-p3-fixes/`(标题/键位/P5 提示与修)
- `/tmp/lv-p6-art/`(昼夜光照/四帧行走/标题入场)
- `/tmp/lv-p7-map/`(512×384 大世界巡游帧 + 夜灯对照)
- `/tmp/lv-p8-cc0/`(CC0 同 seed 对照帧)
- `/tmp/opencode/lv-p13/evidence/`(P13:`walk-idle-frames.png`、`water-3phase.png`、
`smoke-3phase.png`、`water-2frame.png`、`smoke-2frame.png`、`day-panorama.png`、
`night-lantern.png`、`day-night-compare.png`、`walk-real-run.png`、`day-run/`、`night-run/`)
- `/tmp/opencode/lv-p25-art/`(P25:`p25-map-512x384-seed4242.png` 整图无灰石板、
`p25-culled-*`/`p25-detail-*`、`p25-zoom-village.png` 放大含 >=8 村民、
`p25-tour-0{1,2,3,4}.png` tour 页、`tour/` 全量巡游帧)
- `/tmp/opencode/lv-p24-art/`(P24 512x384:`p24-map-512x384-seed{4242,7}.png` 整图、
`p24-zoom-houses.png`/`p24-zoom-villagers.png` 局部放大、`p24-tour-0{1,2,3}.png` tour 页、
`tour512/` 全量巡游帧;规格要求的 `/tmp/lv-p24-art` 被沙箱 external_directory deny,落此路径)
- `/tmp/opencode/lv-p23-art/`(P23 512x384:`p23-map-512x384-seed{4242,7}.png` 整图真实渲染、
`p23-culled-512x384-seed{4242,7}.png` 视口裁剪、`p23-detail-512x384-seed{4242,7}.png` 1:1 近景、
`tour512/` 巡游帧)
- `/tmp/opencode/lv-p21-art/`(P21:`p21-day-bridge-01.png`、`p21-day-river-01.png`、
`p21-day-buildings-0{1,2}.png`、`p21-night-bridge-01.png`、`p21-night-river-01.png`、
`p21-night-street-01.png`;原始帧 `raw-day/`、`raw-night/`)
- `/tmp/opencode/lv-p20-art/`(P20 第三步:默认生成器世界昼夜巡游 `p20c-day-*.png`/`p20c-night-*.png`
(含桥面行走)、代表帧 `p20c-day-bridge-01.png`/`p20c-day-buildings-01.png`/
`p20c-night-bridge-01.png`/`p20c-night-lantern-01.png`;P20:第一步 `mapgen-256x192-seed{4242,7,99}.png` 整图、
`mapgen-culled-256x192-seed{4242,7,99}.png` 裁剪帧;第二步 `p20b-map-256x192-seed{4242,7,99}.png`
整图(含桥/路/沿河民居)、`p20b-detail-256x192-seed{4242,7,99}.png` 桥/路/民居 1:1 近景、
`p20b-culled-256x192-seed{...}.png` 裁剪帧)
- `/tmp/opencode/lv-p19-art/`(P19:`p19-noon-12h-0{1,2}.png`、`p19-dusk-20h45-01.png`、
`p19-night-00h30-01.png`、`p19-before-noon-yellow.png`/`p19-after-noon-neutral.png`;同一份拷入 `evidence/`)
- `/tmp/opencode/lv-p18-art/`(P18:`p18-dusk-20h45-0{1,2}.png`、`p18-night-deep-01.png`、
`p18-before-old-dusk-blue.png`/`p18-after-new-dusk-warm.png`、`sample-dusk.mp4`;同一份拷入 `evidence/`)
- `/tmp/opencode/lv-p17-art/`(P17:`p17-day-12h-0{1,2}.png`、`p17-dusk-20h45-0{1,2}.png`、
`p17-night-00h-0{1,2}.png`、`p17-diff-before-dawn.png`/`p17-diff-after-dawn.png`、`sample.mp4`;
同一份拷入 `evidence/`)
- `/tmp/opencode/lv-p16-art/`(P16:`frame-day-0{1,2,3}.png`、`frame-night-0{1,2,3}.png`、
`frame-splash-01.png`、`frame-menu-0{1,2}.png`、`hud-bar-day.png`、`hud-bar-night.png`、
`sample.mp4`、`final_verification.txt`;沙箱不允许 `/tmp/lv-p16-art`,故落此路径)
- `/tmp/opencode/lv-p13-evidence/`(P14 修复后重录:`day-panorama.png`、`night-lantern.png`、
`day-night-compare.png`、`chimney-closeup.png`、`night-lantern-closeup.png`、
`white-block-check.txt`、`day-run/`、`night-run/`;同步一份到 `evidence/`)
- `artifacts/perf-independent/<TS>/`(digest 3 联测回读)
|