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
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
|
# 维护说明
结论先行:代码 HEAD = `b830b4b`(2026-09-22 独立复验全绿),主分支保持 0 警告 0 错误 +
Kernel **119/119**、Desktop **160/160** 全绿;性能基线
final_digest 固定 953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C。
本文记录模块职责、不变量与逐字可复制的验收命令。
## 模块职责
- `src/LivingVillage.Kernel/`:纯确定性模拟(需求/关系/谣言/年鉴/交易/职业),无渲染依赖。
- `Sim.fs`:世界结构、`Sim.step` 主推进、对话/交易/谣言决策、地图边界
`configureBounds`(默认 64×48,可到 512×384;桌面默认可玩世界为 256×192 生成器世界,
启动即 `configureBounds 256 192`)。
- `Occupation.fs`:职业系统纯函数——职业档案(`profileOf`)、报价偏置(`quoteBiasBasisPoints`)、
每日任务模板、玩家对话回应偏差(`biasResponse`)、轻剧情线状态机(`Story`);
无职业(None)路径与旧行为逐字节一致。
- `RumorBench.fs`:谣言子系统隔离基准(headless `--rumor-bench`,P27)。
- `WorldSave.fs`:存档序列化。v2 头携带地图宽高;v3 仅在挂职业时产出(occupation /
today-task / annals `story` token),无职业路径与 v2 逐字节一致;v1 旧档读入自动回退 64×48。
- `Personality.fs / Interaction.fs(fsm) / M6*.fs`:NPC 行为状态机与里程碑数据。
- `src/LivingVillage.Desktop/`:MonoGame 渲染与交互层。
- `VillageArt.fs`:图集加载与世界/人物/室内绘制;CC0 覆盖层(LV_ASSET_PACK=cc0);
桥头/门窗夜间光晕(`lanternFalloff` 径向衰减)。
- `Interaction.fs`:M5 视图与情境解析(FloatingPrompt / panelLines / 情境提示、剧情线行)。
- `M6Presentation.fs`:昼夜光照纯函数(lightingBlend / transitionWarmth / goldenVeil /
moonlightVeil)与渲染档(Blend/LanternGlow)。
- `TitleScreen.fs / Game.fs`:标题布局与主循环;LV Autos(sample / flow / record / map tour)。
- `MapGen.fs`:默认可玩世界的确定性生成器(splitmix 河道 + value noise + 泊松布点;
大图沿河民居组团/田块/主路柔化,默认 256×192,可到 512×384)。
- `ProceduralMap.fs`:遗留 `LV_MAP_SCALE` 大世界路径(512×384 确定性地形)。
- `WorldBootstrap.fs`:开局站位(核心可达域)与职业档案注入。
- `HudLayout.fs / ChineseText.fs / CjkGlyphAtlas.fs`:HUD 几何与描边纯数据、
中文文案清单与 CJK 图集(307 字,384×480)。
- `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/v3**:`WorldSave` v2 头携带 `width height`,v1 旧档读入回退默认;
v3 仅在挂职业时产出(occupation 段 + `today-task` 尾段 + 年鉴 `story` token),无职业
世界保存与 v2 逐字节一致。`worldDigest` 对版本头与边界 token 不敏感(剥头后重构
V1 常量语义再 SHA256),摘要只绑定模拟状态。
4. **地图 512×384 大世界**:`ProceduralMap.generate` 同 seed 两次生成逐字节一致;
村核矩形(24..38 × 16..34)由生成器不变式保证可通行;
默认可玩世界为 `MapGen.generateWithSize 256 192 42UL`(详见 §大世界(P21))。
## 动画语义(P13,全部为 `tick → 帧` 纯函数;P45/P72 扩充角色帧)
- 角色图集由 `CharacterArt.buildAtlas` **运行时生成**(内嵌原创点阵,非外部素材,P45 起取代
旧的 `Assets/jiangnan-characters.png`;旧 PNG 仅作历史资产保留,运行期不再采样):
5120×48,每个 (变体,方向) 占 **8** 格(**6 走路 + 2 待机呼吸**),格宽 32 高 48。
- `cellIndex = (variantIndex * 4 + directionIndex) * 8 + frameIndex`;
方向序 North/South/West/East = 0/1/2/3,变体序 Indigo/Ochre/Jade/Grey/StrawHat = 0..4。
- frameIndex:Walk 0..5 = 0..5(1/2 与 3 为左右迈步,4/5 为由 1/2 镜像出的过渡步),IdleOne/IdleTwo = 6/7。
- `animationFrame = (tick / 4) % 6`(24 tick 一循环);`idleFrame = (tick / 24) % 2`(48 tick 一循环)。
- `characterFrame isMoving tick`:移动取走路帧,静止取呼吸帧。Avatar 用 `Game.fs` 的
`avatarMoving`(比较前后位置);NPC 用 `npcSpriteSpecAtTarget`(目标向量 ≥0.5px 视为移动)。
单人节奏仍由 `rhythmFor` 的 `WalkWindow/IdleWindow/PhaseOffset` 决定(农夫 4/24、货郎 3/20、书生 6/36)。
- 世界图集 `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 命中)。
## 昼夜光照档(P73)
- 既有 `lightingBlend`(0 白天→1 夜晚,dawn 4.0–6.5 / dusk 20.0–22.0)与 `transitionWarmth`
(晨曦/黄昏三角暖色峰)是**连续曲线**;P73 在其上再叠一层**四时段档**,填补 17:00–19:15
原本与正午无差的黄昏空档。两者都由 `world.Tick` 纯函数导出,Kernel 不参与。
- `M6Presentation.periodAtTick`:边界与 `timePeriodLabel` 完全一致(<5 夜晚、<8 清晨、
<17 正午、<21 黄昏、否则夜晚)→ `Morning | Noon | Dusk | Night`。
- `M6Presentation.periodLighting`:纯数据参数表,每档给 `AmbientTint`(乘性环境色调)、
`Veil`/`VeilAlpha`(加性覆盖色,经 `VillageArt.premultiply`)、`LanternGlow`(灯光强度)。
**正午档为恒等**(白 tint / `VeilAlpha=0` / 灯光 0),故既有正午白平衡不变。
- 渲染接线(`Game.fs` `DrawWorldView`):`worldTint = multiplyTint profile.WorldTint period.AmbientTint`;
`lanternGlow = effectiveLanternGlow tick`(连续曲线与时段档取大,黄昏 0.6 起灯笼点亮、夜晚 1.0);
时段覆盖色在 golden/moon veil 之后最后叠上。HUD/室内界未改。
- **回归**:`P73LightingTierTests`(四档两两不同、正午恒等、黄昏灯亮、夜晚灯满、时段边界与
`timePeriodLabel` 一致)。**证据**:`scripts/analyze-p73.py` 对 `LV_P73_SHOT=1`(同一桥头机位
6/12/18/22 四帧)断言参数一致 + 亮度/暖度可辨 + 两两 pixel diff,落 `docs/evidence/p73-*`。
## 性能基线命令
```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`。
## P74 启动开场过渡(水墨式渐显)
- 新增纯函数模块 `InkReveal.fs`:`inkStrength`(起手 1,`dissolveFrames=96` 内 ease-out 退到 0)、
`dabs frame width height`(6 滴归一化种子墨滴,随消散半径外扩、alpha 下降)、`inkRgb=(26,36,52)`。
单滴 `alpha < 255` 且用 `VillageArt.ellipseRuns` + `premultiply` 落成柔光墨晕,**绝无实心黑块**。
- 接线:`Game.DrawSplash` 在场景层(天色/云/屋脊/水面/灯笼)之上、标题之下绘制墨滴,与既有
`LaunchScreen.revealProgress/veilAlpha` 淡入/暗幕退场叠加,形成可辨识的水墨开场;HUD 结构未动。
- **回归**:`P74LaunchTransitionTests`(墨幕单调消散且为 0、墨滴确定/随帧变化、alpha<255、
半径 < 半屏、`LaunchScreen` 不变量、开始界面三项 开始/读档/退出 + 有档 继续/读档/退出)。
- **证据**:`LV_P74_SHOT=1`(60Hz 固定步进,splash 帧表 8/30/60/100/140 各一帧)→
`scripts/analyze-p74.py` 断言帧号/墨幕非增/无大黑块/两两 pixel diff>0,落 `docs/evidence/p74-*`。
## 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)
- 来源 commit:merge `7dfd58b`(feat `1724ce6` / `8654557` / `f819d25`)。
- 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)
- 来源 commit:merge `aa3d749`(feat `0931037` / `7e3df86`)。
- `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)
- 来源 commit:feat `fc5b375` / `91b7204`(分支 `p25-tour-visibility`,tick `d36e86a` 记录合并)。
- **成因**: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)
- 来源 commit:merge `b377e75`。
- **① 民居立面第二变体**:世界图集由 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 命中)。
- **范围外遗留(另开一单,本单不做)**:① 512 生成耗时因河岸装饰的 O(n²) 间距检查与民居加密从
~80ms 升到 ~151ms(一次性、进入世界前,无逐帧开销),可改空间网格索引优化;② 变体 B 挑檐向两侧
各出一格,相邻民居极近时挑檐可能视觉叠压,可在布点时加 1 格间隔;③ 巡游 40s 档的 `visible>=8`
占比仅 13%(P25 150s 档为 74%),属取证时长差异,非可见性回归。
## P27:Rumor 长局性能修复(工作集扫描去装箱 + 裁剪判定)
- 来源 commit:merge `1b0724d`(feat `164f474` / `a059745`)。
- **现状复核(更正 M3-1 前提)**:M3-1 记录的「`World.Rumors` 不裁剪、逐次聊天全表扫描」已由 M6a
(`d94ec2d` 工作集容量上界+按天淘汰、`cc850bf` 窗口化早停扫描)修复:`RumorEvent.DayIndex` 派生字段
+ `trimRumors` 把工作集按「最新在前 + 3 天保留窗口」收敛。实测单日成本已平稳(本机 `--cost-probe`:
1 天 18.7s/18.9GB、5 天 90.5s/95.7GB、10 天 198.2s/191.9GB,≈19GB/天常数),M3 记录的 20 天
≈45 s/天的超线性项已消失。故 P27 不采用「砍 depth/strength 传播」这类会改变模拟语义的方案。
- **剩余 Rumor 成本定位**:工作集有界后每次聊天仍有两条 O(N) 扫描 `latestRumorFor` / `rumorIsDuplicate`;
且此前用 `=` 比较 `[<Struct>]` 的 `NpcId`/`RumorId`,会走泛型结构相等,对每一条被扫描到的谣言
装箱(实测 ≈24 B/条、单次聊天随 N 线性增长——`Sim.chat` 单独测得 N=16000 时 393,736 B/次);
这是 Rumor 逐条分配的主因,而非列表 cons 本身。
- **Kernel 长跑基准**:新增 `src/LivingVillage.Kernel/RumorBench.fs` 与 headless
`--rumor-bench [days npcs chats_per_day]`,把「聊天→生成/追溯谣言」路径从 `Sim.step` 的 NPC 快照
分配解耦,按模拟日推进 tick、固定节奏注入聊天,测工作集分配与耗时;同 `Config` 的
`retained_id_checksum` 可证明确定性。
修复前(30 NPC,`--rumor-bench D 30 2000`):
| days | chat_calls | rumors_retained | allocated_bytes | elapsed_ms |
|---|---|---|---|---|
| 3 | 6000 | 6000 | 930,716,456 | 1626.7 |
| 30 | 60000 | 8000 | 13,419,045,864 | 12182.5 |
| 60 | 120000 | 8000 | 27,297,668,608 | 23769.7 |
60 天工作集稳定在 8000(M6a 的 3 天保留窗口 + 2000/天),不再随年龄增长。
- **修复策略(两条,均不改保留集合与查询结果)**:
1. **扫描去装箱**:`latestRumorFor` / `rumorIsDuplicate` 原来用 `=` 比较 `[<Struct>]` 的
`NpcId`/`RumorId`(含 `RumorId option`),走泛型结构相等、逐条装箱。改为先解构底层
`int`/`int64` 再比较(`Parent` 用 `int64 option` 比较);语义完全等价,扫描不再逐条分配。
2. **裁剪判定 O(1)**:`World` 新增派生镜像 `RumorCount`/`RumorOldestDay`(不参与存档,读档由
`Sim.rumorWorkingSetStats` 从列表重建)。`appendRumor` 用「`count+1 > capacity` 或
`min(oldest, day) < cutoff`」O(1) 判定;旧 `consRumor` 每次追加都 `rumorsNeedTrim` 全表扫一遍。
判定条件与 `rumorsNeedTrim` 逐字等价,仅真需裁剪时才走 `trimRumors`,保留集合逐条相同。
- **修复后基准(同机、同 `--rumor-bench D 30 2000`)**:
| days | 修复前 alloc / ms | 修复后 alloc / ms | alloc 降幅 |
|---|---|---|---|
| 3 | 930,716,456 / 1626.7 | 482,716,192 / 1511.3 | −48% |
| 30 | 13,419,045,864 / 12182.5 | 4,884,067,640 / 8879.6 | −64% |
| 60 | 27,297,668,608 / 23769.7 | 9,774,660,360 / 17078.2 | −64% |
60 天分配 −64%、耗时 −28%;`retained_id_checksum=927964000`(3/30/60 天分别为
17997000 / 447996000 / 927964000)与修复前逐位一致,证明保留集合未变。
- **等价性与门槛**:5 天 2000 聊天/天连跑两遍 `WorldSave.save` 逐字节相同;
`RumorCount`/`RumorOldestDay` 与对列表重算一致(count=8000、oldest_day=1)。
`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C`
三次一致、`performance_determinism=PASS`;Kernel 92 / Desktop 143 全绿。
- **全模拟口径(`--cost-probe`)**:1 天 18.7s/18.8GB、5 天 90.4s/94.6GB,与修复前基本持平——
全模拟分配由逐 tick 的 `Npc[30]` 快照(`Sim.step` 的 `Array.copy`,≈120 B/NPC/tick)主导,
谣言子系统占比很小;P27 的收益集中在 Rumor 工作集路径,符合「隔离测量」的基准口径。
逐 tick NPC 快照分配属另一个课题(会触及不可变快照不变量),留给后续单。
## P28:512 大图民居观感整改(P26 遗留)
- 来源 commit:merge `b2a6701`(feat `da11e35` / `26afb04` / `1b4c4e0` / `533b39f`)。
- **① 第二变体辨识度(确定性图集 + 纯绘制分支)**:P26 变体 B 在 512 整图 ~2px/tile 下不可辨,
根因是挑檐与变体 A 同为近黑黛瓦、格栅窗与 A 横向窗差异在 2px 内被抹平。P28 给 B 独立
配色/几何:`scripts/make-jiangnan-art.py` slot28 挑檐改为亮青灰瓦色阶(base 84,106,110 /
light 134,158,158,A 仍是黛瓦脊 slot8),slot29 木格栅窗改竖长比例(内窗 10x18,A 为 18x16
横向),slot30 院门改绿双扇 + 青灰门楣;`VillageArt` 变体 B 再向上叠一层挑檐(该格为水面时
不叠,避免屋顶压水)且向两侧各出一格,墙身统一为格栅窗。图集二次运行 byte-identical
(`jiangnan-world.png` md5 `e5aca186a25b8f8b78d37c0d3ef167b5`)。1 tile≈2px 实测:
A 屋脊 `[46,60,54]` vs B 挑檐 `[68,101,82]`(ΔG≈40),墙线 A 暖 `[154,125,97]` vs
B 冷 `[118,140,115]`,肉眼可分(对照 4x NEAREST 裁剪)。回归
`VariantBRoofPaletteIsClearlyLighterThanVariantA`:解码 world 图集断言 slot28 平均亮度
> slot8 + 15。
- **② 密度/聚落层次(仅大图 mapgen 参数)**:`MapGen.Params.ExtraRiversideHouses` 10 -> 16,
额外民居宽度按 2/3/4 加权(2=附属小筑 / 3=民居 / 4=大宅)。512x384 seed4242 由 18 -> 24 座
(变体 A 11 / B 13;宽度 2×8、3×10、4×6),不再是单一等距 3x2。默认图仍为 0 且不进入
大图分支、不消耗 `structureRng`,故 **64x48=6042052、256x192=1284656962 逐字节不变**
(`DefaultSizeChecksumsArePinned`)。回归 `LargeMapHouseDensityHierarchyOnlyGrowsLargeMaps`。
- **③ 取证钩子(仅证据路径,不改语义)**:`LV_MAPGEN_FIT_WIDTH/HEIGHT` 把整图按指定像素目标
再做一次真实离屏渲染(1024x768 对 512x384 恰为 2px/tile);`LV_MAPGEN_NIGHT=1` 时以 23:30 的
真实 `WorldTint` + golden/moonlight veil 出夜景整图,与 `DrawWorldView` 共用同一套纯函数。
全部为真实渲染帧,非编辑器合成/外表图像。
- **回归/门槛**:Kernel **92** / Desktop **145** 全绿;Release build 0 警告 0 错误;
`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C`
3 次一致、`performance_determinism=PASS`。
- **取证** `/tmp/opencode/lv-p28/art/`:`p28-map-512x384-seed4242-day.png`(512 整图真实渲染
3072x2304)、`p28-map-512x384-seed4242-2px-day.png`(1024x768 真实帧,1 tile=2px)、
`p28-variant-pair-ab-4x-day.png`(A/B 相邻民居 4x NEAREST 对照,裁 tile x184..214 y186..196)、
`p28-village-core-4x-day.png`(村核 4x)、`p28-village-density-2x-day.png`(密度总览),
以及同名 `*-night.png`(夜景:整图 + 2px + A/B 对照)与 `p28-night-village.png`
(512 夜巡真实帧,HUD 在屏、民居/石板路清晰)。白块回归 4 张夜景整图 + 571 张夜巡帧
0 命中。
## P29:512 聚落组团 + 夜景灯光径向光晕(P28 观感遗留)
- 来源 commit:merge `4034f55`(feat `a70a057`)。
- **① 聚落组团(仅大图 mapgen 分支 + 独立 group seed)**:`MapGen.Params` 新增 `ClusterSeed`
(`paramsForSize` 仅大图时置 `seed ^^^ 0xC1A57E2`,默认 0 且非大图不进入分支)。512 沿河民居
不再沿路随机散点,改为沿横向石板路(主街 + 河岸路)成排成组:组内 2-4 座、组内 1 格间隙、
组间留 4-6 格空档,宽度仍按 2/3/4 加权;垂柳改为确定性成丛(2-3 株一丛、丛间 3-5 格空档),
与成排民居呼应。组发牌走独立 `clusterRng`,不触碰既有 `structureRng`,桥/路/灯笼坐标不变;
民居/树仍只落核心区外草地并做重叠检测,BFS 可达性保持。默认图仍 0 额外民居、不消费 RNG,
故 **64x48=6042052、256x192=1284656962 逐字节不变**(`DefaultSizeChecksumsArePinned`)。
- **② 夜景灯光径向衰减(渲染层纯函数)**:新增 `VillageArt.lanternFalloff distance radius`
(半径 0 处 =1,边缘 =0,中间 `1-(d/r)^2` 严格单调、无平台);`lanternGlowLayers` 改为按该衰减
的逐环增量累积 alpha,`drawNightGlows` 对「桥头灯笼 + 民居门窗暖光」(`nightLightTiles`)
画半径 4 tile、8 环的柔和径向光晕。整图取证(`drawMapFitted`/`drawMapViewport`)与实机
`DrawWorldView` 共用同一套函数;无硬方斑、无白块(`check-no-white-blocks` 11 帧 0 命中)。
- **③ 取证钩子**:复用 `LV_MAPGEN_SHOT` + `LV_MAPGEN_FIT_WIDTH/HEIGHT`(2/4/8 px per tile)与
`LV_MAPGEN_NIGHT=1`(23:30 真实 `WorldTint` + golden/moonlight veil);夜景帧在原整图流程上
叠加与实机一致的灯光光晕。
- **回归**:Kernel 92 / Desktop 151(+6:`ClusterSeedOnlyEnabledForLargeMaps`、
`LargeMapHousesFormClusteredRowsWithControlledGaps`、`LargeMapClusterLayoutChecksumIsEmptyDiffAcrossRuns`、
`LargeMapWillowsGrowInGroves`、`LanternFalloffIsMaxAtCoreAndMonotonicWithoutPlateau`、
`NightLightTilesCombineBridgeLanternsAndHouseWindows`)全绿;Release 0/0;
`final_digest=953775FAEB2F…` 3 次一致、`performance_determinism=PASS`。
- **取证** `/tmp/opencode/lv-p29/art/`:`p29-map-512x384-seed4242-{day,night}.png`(512 整图
3072x2304 真实渲染)、`p29-village-cluster-4x-{day,night}.png`(512x256,4px/tile 桥头沿河组团
局部,含成排民居/桥/水面)、`p29-lantern-halo-8x-night.png`(192x192,单灯 8px/tile 特写;
径向剖面 191→189→183→167→137→113→96→84 平滑递减)、`p29-variant-pair-ab-4x-{day,night}.png`
(512x256,A/B 对照);
源帧 `p29-day/`、`p29-night/`、`p29-day8/`、`p29-night8/`;工作区 `art/` 不入 git。
- **遗留**:民居总数与 P28 相同(24 座),本轮只做「组团化」未继续提高总密度;河岸垂柳成丛后
仍为纯视觉层,不参与通行;默认 64x48/256x192 观感不变。
## P30:512 民居增密 + 乡村空区填充 + 主路柔化(P29 观感遗留)
- 来源 commit:merge `dabc5d0`(feat `92bcc9b`)。
- **① 民居增密与组团(仅大图 mapgen 分支)**:`ExtraRiversideHouses` 16 -> 30,组团规模由
2-4 改为 2-5 座;含基础岸屋的横向路段优先布组,并为每座仍孤立的基础民居在同行贴 1 格补
一座。`placeGroup` 先局部试排,再用 `connected`(与既有民居或同组民居足迹间隙 <=2)过滤后
才提交,孤立单屋根本不落地。512x384 seed4242:沿河/沿路组团民居 24 -> **45 座**,组团规模
`[5;4x6;3x2;2x3]`,**0 组 <2 座**;宽度仍按 2/3/4 加权(附属小筑/民居/大宅)。
- **② 乡村空区填充(仅大图)**:新增 `GroundTile.PaddyField=4 / VegetablePlot=5`(成熟 tile
素材:水田走静态水面 sprite、菜畦走菜地 sprite;均为非水瓦片故仍可通行)、`Result.Groves`
(竹丛/灌木丛,纯视觉层,不入瓦片、不影响通行)、`Result.Farmhouses`(散落农舍,门贴院落路,
计入 BFS 可达性)。按 18x14 确定性网格铺田块/树丛/农舍,田块离水面 >=3 格并避开路网/核心/
民居;上部与下部空带均有田块。512x384 seed4242:水田 11595 + 菜畦 12877 格、树丛 852 格、
散落农舍 21 座;草地瓦片占比 **96.3% -> 83.9%**(整图真实渲染中草地像素约 80.8%)。
- **③ 主路柔化(仅大图)**:新增 `Result.MainRoadCenter` 逐列中心线,用确定性 value noise
(scale 130、幅度 ±3 格)微扰,相邻列步长恒 `<=1`,遇弯补一格保证 4 连通;桥的竖向引路按
`roadYAt bx` 对齐。默认图主街仍笔直。同 seed 两次逐字节一致(`serialize` 含中心线)。
- **回归 +5**:`LargeMapGroupsAreTwoToFiveWithFewIsolated`、
`LargeMapCountrysideFillsEmptyBandsWithMatureTiles`、`FieldsStayOnLandAwayFromWaterPathsAndCore`、
`LargeMapFarmsteadDoorsAreReachableAndDeterministic`、`LargeMapMainRoadIsDeterministicSerpentine`。
Kernel **92** / Desktop **156** 全绿;Release build 0 警告 0 错误;
`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C` 3 次一致、
`performance_determinism=PASS`;**64x48=6042052、256x192=1284656962 锁定不变**
(`DefaultSizeChecksumsArePinned`)。
- **取证** `/tmp/opencode/lv-p30/art/`(真实离屏渲染,非构造):`p30-map-512x384-seed4242-{day,night}.png`
(3072x2304 整图)、`p30-field-cluster-4x-{day,night}.png`(512x256,4px/tile,田块+农舍+树丛)、
`p30-roadserp-4x-day.png`(512x256,4px/tile,主路逐点弯曲)。`check-no-white-blocks` 5 帧 0 命中。
- **遗留**:散落农舍与乡村树丛为纯布局/美术层,不与 NPC 数值、任务系统耦合;默认
64x48/256x192 判定不进入新分支、不消费额外 RNG,观感不变。
## 职业系统(P11-P15 铺底,P31 补完对话与剧情线)
- 代码落点:`src/LivingVillage.Kernel/Occupation.fs`(Kernel 纯函数,不进 `Sim.step` 数值路径)+
Desktop 选择页/任务面板/报价接线。设计依据为第二版实现稿
`docs/design/occupation-system-design.md`;第一版草稿 `docs/profession-system-design.md`
已被其取代(「药童」以「农夫」为第四职业合入 main)。
- 职业四档(`Occupation.profileOf`):农夫 Money 60 / 能量 100 / Food×12;渔夫 45 / 95 /
Food×8 + Fish×3;货郎 90 / 100 / Food×6 + Spice×4;书生 70 / 100 / Food×10 + Scroll×2。
- P11 每日任务(`f883070`、`27f0ca0`):任务生成纯函数 + `TaskState` 状态机(Offered/Active/Done/Failed);
存档 v3 `today-task` 尾段(annals 之后追加,双向兼容读旧档)。
- P12 报价偏置(`c348da2`、`98b6b21`):4 位定点基点钳 [0.95, 1.05]——货郎买卖双向 ±3.00%、
书生 ±1.00%,农夫/渔夫/无职业恒 1.0;T 键任务面板(Offered/Active/Done 三态 + 职业名),
存读档字段级携带职业。
- P15 玩法收口(feat `0222501` / `e1d8d45` / `60bbc83` / `50875bb`,merge `6ce29c5`,独立复验全绿):
货郎/书生各补 2 条每日任务模板 + 既有事件通道信号桥;CJK 图集 197->228 补职业/任务用字;
开局职业选择页(标题→新游戏→四选一+「暂不选择」);选职业→第一天任务→完成判定→Done
全链路回归。
- 确定性口径:同 seed 同职业两次开局世界逐字节一致(任务哈希走 `(seed, profession, day)`
splitmix 模式);无职业世界全部走 identity/原路径,`performance-baseline` digest 不受影响。
## P31:职业系统 P-d——对话分支倾向 + 四条轻剧情线
- 来源 commit:feat `6c2f180`(step1 偏差)/ `0539bba`(step2 状态机)/ `9821b54`(step3
年鉴+身份+图集)/ `4f51232`(step4 显示与证据);合并经 `b3d1cb3`(tick `d429662` 验收记
「profession system P-a..d complete」)。
- **① 对话分支倾向(design §5)**:`Occupation.biasResponse (kind option) (intent) (personality)`
是 `Sim.responseFor` 之上的纯函数重映射,无职业(None)逐字节等于原输出。规则各自只作用于
签名意图:农夫 AskHelp `Refused->Helpful`;渔夫 SmallTalk `Reserved->Friendly`(聊天气鱼汛,
冷场变热络);货郎 SmallTalk `Reserved->Bargaining`(三句不离买卖);书生 Joke **完全覆盖**
为 `Reserved`(与 personality 相反时取完全覆盖语义而非概率偏向,禁随机,design §5 反差感)。
接线:`Sim.responseFor` 改为公开(同一纯函数,原口径不变);新增
`Sim.chooseDialogueWith respond target intent world`(`chooseDialogue` 保留为 identity 包装,
既有行为逐字节不变);Desktop `M5Interaction.chooseIntent` 按玩家职业传 `biasResponse`,
偏差后的回应照常进 DialogueOutcome/双向记忆/年鉴(`Sim.appendAnnal` 改公开,语义不变)。
- **② 轻剧情线状态机**:`Occupation.Story`,每职业三段式 接任务→走访→收束,`StoryStage`
语义 `0 未开始 / 1 已接任务 / 2 走访中 / 3 已收束`(v3 occupation 段早已序列化该字段,
读取路径原样恢复,存档段格式未动)。每次玩家对话至多推进一段:开线日
`startDayOf seed kind = hash(seed ^ storySeed, day 0) mod 3 + 2 ∈ [2,4]`(沿每日任务的
splitmix `(seed, profession, day)` 哈希模式,story 种子与任务种子两两不撞);`1→2`、`2→3`
分别要求 `day >= startDay+1`、`startDay+2`。终局由 `relationMeanToPlayer`(与
`Sim.relationMatrix` 同口径的衰减 valence,只计认识玩家的村民;无一认识 = 0)过纯阈值
`≥0.3 Warm / [0,0.3) Plain / <0 Distant` 定格三种结局文案,写入年鉴快照后不再随关系衰减改变。
四条线:农夫「雨水与年成」、渔夫「水位与收成」、货郎「新货的路子」、书生「庙前的争论」。
(设计稿 §5 的「药童」在 P-a 落地时以「农夫」为第四职业合入 main,故第四线写农夫题材,
其余三线名照设计稿。)
- **③ 年鉴条目**:`Sim.AnnalKind` 新增 `StoryAnnal`(文案落 `Summary`),v3 存档以新 token
`story` 序列化;v1/v2(无职业)永不产生该 token,字节口径不变;v3 存→读→存逐字节一致
(有测试)。§3「身份条目」此前未落地,本单补齐:带职业开局由 `StartNewGame` 写入
`以耕田为生 / 以打渔为生 / 以贩货为生 / 以读书为生`(年鉴短句风格,不带主语)。
- **④ 显示层收口(最小展示)**:任务面板(T 键)在职业名/每日任务下新增一行
`剧情线:<线名>,<段名>`;职业选择页选中职业时提示 `剧情线:<线名>`;年鉴面板按既有
`annalLine` 渲染 `StoryAnnal.Summary` 原文。M5View 新增 `Seed` 字段(与开局种子同源),
供剧情线开线日哈希。
- **⑤ 中文图集**:新增文案(四线名、四段名、身份条目、接任务/走访/12 条结局文案)全部进入
`ChineseText.requiredUiLabels`;`cjk-glyphs.txt` 与 `CjkGlyphAtlas.characterTable` 同步
238 -> **307** 字(384x480,行数 15 -> 20),`scripts/generate-cjk-glyph-atlas.sh` 确定性
再生成 PNG;`CjkGlyphAtlasHasAStableCharacterTableAndGeometry` 计数/行数/尺寸断言同步更新。
- **回归**:Kernel **92 -> 119**(偏差 9 + 剧情线 14 + 存档/身份 4)、Desktop **156 -> 160**
(面板剧情行 2、M5 对话接线 2)全绿;Release build 0 警告 0 错误;
`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C` 多次一致;
**64x48=6042052、256x192=1284656962 锁定不变**(`DefaultSizeChecksumsArePinned`)。
M3 相关文件零改动;无职业世界(Occupation=None)全部走 identity/原路径,模拟数值语义
未触碰(Sim.fs 仅 visibility 公开 + chooseDialogue 重构为 identity 包装,行为逐字节等价)。
- **取证** `/tmp/opencode/lv-p31/art/`(gitignore 不入库,真实 Xvfb 帧非概念图):
`p31-story-{farmer,fisher,peddler,scholar}-stage2.png`(1280x720 各 1 张,任务面板含
剧情线行 + 年鉴身份条目);同批四张已复制到仓库 `docs/evidence/p31-story-*.stage2.png`
供查阅(注:根 .gitignore 的 `evidence/` 规则使其未入库,属文档外仓库状态,留给后续单处置)。复现命令:
`timeout 120 xvfb-run -a -s "-screen 0 1280x720x24" env LV_STORY_SHOT=1 LV_STORY_OCCUPATION=fisher LV_STORY_STAGE=2 LV_RECORD_DIR=/tmp/opencode/lv-p31/art dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll`。
- **范围外**:设计稿 §5 的「视频证据」以 4 张真实渲染截图替代(本单验收清单口径);
药童线以农夫线替代(见 ②);NPC 职业化、补选职业、剧情线多线并行仍为设计边界外。
## 已验证命令
```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
# P27 Rumor 长跑基准(隔离谣言子系统;默认 30 NPC×60 天×2000 聊天/天)
dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll --rumor-bench
dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll --rumor-bench 3 30 2000
# 素材再生成(确定性,输出 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
# P28 512 整图真实渲染(3072 整图 + 1024x768 恰 2px/tile;night=1 走 23:30 tint+月光 veil)
for night in 0 1; do extra=""; [ "$night" = 1 ] && extra="LV_MAPGEN_NIGHT=1"; \
dir=/tmp/opencode/lv-p28/art/map; [ "$night" = 1 ] && dir=/tmp/opencode/lv-p28/art/map-night; \
timeout 180 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_MAPGEN_FIT_WIDTH=1024 LV_MAPGEN_FIT_HEIGHT=768 $extra LV_RECORD_DIR=$dir \
dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll; done
# 两个变体 4x NEAREST 裁剪对照(从 1024x768 真实帧按 2px/tile 裁 tile x184..214 y186..196)
python3 - <<'PY'
from PIL import Image
for tag, p in (("day","/tmp/opencode/lv-p28/art/map"), ("night","/tmp/opencode/lv-p28/art/map-night")):
im = Image.open(f"{p}/p28-fit-1024x768-seed4242.png").convert("RGB")
c = im.crop((184*2, 186*2, 214*2+2, 196*2+2))
c.resize((c.width*4, c.height*4), Image.NEAREST).save(f"/tmp/opencode/lv-p28/art/p28-variant-pair-ab-4x-{tag}.png")
PY
# P28 夜景白块回归(4 张整图 + 571 张夜巡帧,0 命中)
python3 scripts/check-no-white-blocks.py /tmp/opencode/lv-p28/art/map-night
python3 scripts/check-no-white-blocks.py /tmp/opencode/lv-p28/art/night-raw
# P28 512 夜巡真实帧(seed 42;默认桥/门巡游路点,夜景 23:30)
timeout 220 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_RIVERSCAPE_SECONDS=60 LV_AUTOPLAY_START_HOUR=23.5 LV_NPC_VISIBLE_LOG=1 \
LV_AUTOPLAY_RECORD=1 LV_RECORD_EVERY=20 LV_RECORD_NAME=p28-night- \
LV_RECORD_DIR=/tmp/opencode/lv-p28/art/night-raw \
dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll
# 代表帧 p28-night-10100.png -> art/p28-night-village.png
# P29 512 组团 + 夜景光晕取证(4/8 px per tile;night 走 23:30 tint+veil+径向光晕)
run_p29 () { # $1=fit_w $2=fit_h $3=night(0/1) $4=out_dir
local extra=""; [ "$3" = 1 ] && extra="LV_MAPGEN_NIGHT=1"
timeout 300 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_MAPGEN_FIT_WIDTH=$1 LV_MAPGEN_FIT_HEIGHT=$2 $extra LV_RECORD_DIR=$4 \
dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll
}
run_p29 2048 1536 0 /tmp/opencode/lv-p29/art/p29-day
run_p29 2048 1536 1 /tmp/opencode/lv-p29/art/p29-night
run_p29 4096 3072 0 /tmp/opencode/lv-p29/art/p29-day8
run_p29 4096 3072 1 /tmp/opencode/lv-p29/art/p29-night8
# 裁剪(PIL):整图 p20b-map-*.png -> p29-map-*;组团 4x = 2048 帧裁 tile x278..406 y82..146;
# 单灯 8x = 4096 帧以桥灯 (113,180) 为中心裁 192x192;
# A/B 对照 = 2048 帧裁 tile x228..260 y170..186 后 4x NEAREST -> 512x256
python3 scripts/check-no-white-blocks.py /tmp/opencode/lv-p29/art/p29-night /tmp/opencode/lv-p29/art/p29-night8
# P30 512 证据出图(真实离屏渲染:整图 3072x2304 + 田块/主路局部 512x256@4px per tile)
mkdir -p /tmp/opencode/lv-p30/art
timeout 300 xvfb-run -a -s "-screen 0 1280x720x24" env LV_MAPGEN_SHOT=1 LV_MAPGEN_P30_SHOT=1 \
LV_MAPGEN_WIDTH=512 LV_MAPGEN_HEIGHT=384 LV_MAPGEN_SEED=4242 \
LV_RECORD_DIR=/tmp/opencode/lv-p30/art \
dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll
python3 scripts/check-no-white-blocks.py /tmp/opencode/lv-p30/art/*.png
# 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
```
## 取证快照位置(近期)
- `docs/evidence/`(P31 四职业 stage2 截图 `p31-story-{farmer,fisher,peddler,scholar}-stage2.png`;
P32 夜间白块修复运行时截图 `p32-*.png`。该目录被 .gitignore 的 `evidence/` 规则命中,
UI/文档类证据用 `git add -f` 显式入库,其余大批量工作区帧仍不入 git)
- `/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/`)
- `/tmp/opencode/lv-p28/art/`(P28:`p28-map-512x384-seed4242-day.png`/`-night.png` 512 整图、
`p28-map-512x384-seed4242-2px-day.png`/`-night.png` 1024x768 恰 2px/tile、
`p28-variant-pair-ab-4x-day.png`/`-night.png` A/B 4x 对照、
`p28-village-core-4x-day.png`/`-night.png`、`p28-village-density-2x-day.png`/`-night.png`、
`p28-night-village.png` 夜巡代表帧、`night-raw/` 571 帧;工作区内不入 git)
- `/tmp/opencode/lv-p29/art/`(P29:`p29-map-512x384-seed4242-{day,night}.png` 512 整图、
`p29-village-cluster-4x-{day,night}.png` 512x256 桥头沿河组团 4px/tile、
`p29-lantern-halo-8x-night.png` 192x192 单灯 8px/tile 光晕特写、
`p29-variant-pair-ab-4x-{day,night}.png` 512x256 A/B 对照;源帧 `p29-day/`、`p29-night/`、
`p29-day8/`、`p29-night8/`;工作区 `art/` 不入 git)
- `/tmp/opencode/lv-p30/art/`(P30:`p30-map-512x384-seed4242-{day,night}.png` 3072x2304 整图、
`p30-field-cluster-4x-{day,night}.png` 512x256 田块+农舍+树丛 4px/tile、
`p30-roadserp-4x-day.png` 512x256 主路逐点弯曲 4px/tile;工作区 `art/` 不入 git)
- `artifacts/perf-independent/<TS>/`(digest 3 联测回读)
## 证据清单(P32 夜间白块修复运行时截图)
P32 补拍 P14(预乘 alpha 灯笼光晕 / 水面相位 / 炊烟 late pass)修复后的真实运行时夜间帧,
落库 `docs/evidence/`。该目录被 .gitignore 的 `evidence/` 规则命中,故 P32 这批 UI/文档证据用
`git add -f` 显式入库。
复现命令(Release 构建后;`/tmp` 请用沙箱允许的路径):
```bash
DLL=src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll
# ① 生成器世界整帧:512x384 tile、seed 4242,2048x1536 = 4px/tile;night 走 23:30
for t in day night; do
extra=""; [ "$t" = night ] && extra="LV_MAPGEN_NIGHT=1"
timeout 200 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_MAPGEN_FIT_WIDTH=2048 LV_MAPGEN_FIT_HEIGHT=1536 $extra \
LV_RECORD_DIR=/tmp/opencode/lv-p32-evidence/$t dotnet "$DLL"
done
# ② 样例村庄真实运行帧(1280x720;每 10 帧存 1)
for h in 23.5 12.0; do
tag=night; [ "$h" = 12.0 ] && tag=day
timeout 25 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \
LV_AUTOPLAY_RECORD=1 LV_AUTOPLAY_START_HOUR=$h LV_RECORD_EVERY=10 \
LV_RECORD_NAME=p32-$tag- LV_RECORD_DIR=/tmp/opencode/lv-p32-evidence/scene-$tag \
dotnet "$DLL"
done
# ③ 夜间白块回归(本单 7 帧 + 生成器夜帧全集,0 命中)
python3 scripts/check-no-white-blocks.py docs/evidence/p32-*.png
python3 scripts/check-no-white-blocks.py /tmp/opencode/lv-p32-evidence/night
```
入库文件(`docs/evidence/`):
| 文件 | 内容 |
| --- | --- |
| `p32-night-map-512x384-seed4242.png` | 生成器世界夜间整帧 2048x1536(23:30 `WorldTint` + 月光 veil + 桥头/民居径向暖光) |
| `p32-day-map-512x384-seed4242.png` | 同上白天对照帧 |
| `p32-night-map-bridge-2x.png` | 桥 (249,203) + 河面区域 2x NEAREST:暖色径向光晕(R−B 峰值 +47、暖光像素 9884)叠在蓝色水面上 |
| `p32-night-village.png` | 样例村庄真实运行夜间整帧 1280x720 |
| `p32-day-village.png` | 样例村庄真实运行白天整帧 1280x720 |
| `p32-night-village-closeup-2x.png` | 村庄灯笼/水面区域 2x NEAREST(夜光在月光 veil 下偏柔,无白块) |
| `p32-day-village-closeup-2x.png` | 村庄民居/水面/炊烟区域 2x NEAREST(炊烟烟柱在屋脊上方,见 P14 late pass) |
说明:`DrawMapFitted` 生成器渲染路径不绘制炊烟(炊烟只在样例村庄 `VillageArt.drawWorld` 的
late pass),故「灯笼光晕 + 水面」以生成器夜帧为准,「炊烟」见样例村庄昼间特写。所有帧
`check-no-white-blocks.py` 0 命中(无 ≥64x64 不透明纯白块)。
## P33 素材接入样板(真实像素素材竖直切片)
把样例村庄的**一类地面 + 一类角色**从程序绘制换成 CC0 真实像素素材,跑通接入管线;其余仍走
原程序绘制图集 fallback。来源 / 许可 / sha256 完整记录见 `docs/素材来源.md`。
- 构建(确定性,二次运行 byte-identical):
```bash
python3 scripts/make-cc0-art.py
# -> Assets/cc0-tiles.png 128x32 (4 个 32x32 地面格)
# -> Assets/cc0-characters.png 3840x48 (5 变体 x 4 向 x 6 帧, 32x48 格)
```
同时修复了地块取样间距:源用无间距的 `tilemap_packed.png`(16px pitch),旧代码误按带间距
的 17px pitch 采样导致错格。
- 开关:`LV_ASSET_PACK=cc0` 启用真实素材;未设置或其它值回退程序绘制江南图集。选择入口
`VillageArt.useCc0Pack` / `selectedGroundAtlasRelativePath` / `selectedCharacterAtlasRelativePath`。
- 规格:源 16x16 / 16x24 整数 **2x NEAREST** → 32x32 / 32x48;加载尺寸与整数倍由
`src/LivingVillage.Desktop.Tests/AssetPackTests.fs` 断言(含开关与 fallback)。
- 证据(`docs/evidence/`,被 .gitignore 命中故 `git add -f`;`check-no-white-blocks.py` 4 帧 0 命中):
- `p33-atlas-day.png` / `p33-atlas-night.png`:样例村庄真实素材昼 / 夜整帧 1280x720。
- `p33-atlas-closeup-2x.png`:村庄地面 + 角色 2x NEAREST 特写(Kenney 草色 (78,151,76) 与
(132,198,105) 合计约 7.4 万像素,证实真素材上图)。
- `p33-atlas-baseline-day.png`:同场景程序绘制 fallback 对照(草色 (66,112,70)),与真实素材
帧明显不同(昼帧约 92% 像素差异)。
- 复现:
```bash
DLL=src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll
for h in 12.0 23.5; do
tag=day; [ "$h" = 23.5 ] && tag=night
timeout 20 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \
LV_AUTOPLAY_RECORD=1 LV_AUTOPLAY_START_HOUR=$h LV_RECORD_EVERY=10 \
LV_RECORD_NAME=p33-$tag- LV_ASSET_PACK=cc0 \
LV_RECORD_DIR=/tmp/opencode/lv-p33-evidence/$tag dotnet "$DLL"
done
```
- 范围:本单只接入基础地块与一类角色;动画全套 / 全村 / 中式元素补绘留待后续切片。
## P34 素材扩展切片(水面 / 屋顶 / 墙体 / 栏杆)
在 P33 竖直切片之上,把样例村庄更多表面/构件从程序几何切到真像素素材,延续 `LV_ASSET_PACK=cc0`
开关与程序绘制 fallback。来源/许可/自绘清单见 `docs/素材来源.md`。
- 图集扩展:`Assets/cc0-tiles.png` 由 128x32(4 格)扩到 **384x32(12 格)**。0-3 格仍为
Kenney Tiny Farm 地面(整数 2x);4-11 格为**自绘**构件(Kenney 农场包无水面/屋顶/墙/栏杆):
`4 water A / 5 water B / 6 roof ridge / 7 roof eave / 8 wall wood / 9 wall window / 10 railing / 11 door`。
- 构建:`python3 scripts/make-cc0-art.py`(确定性,二次运行 byte-identical)。
- 水面:自绘 **2 相位**,`cc0WaterTileIndexAt tick x y = (tick/32 + x + 2y) mod 2`,单帧内沿河
同时出现两相位;河道与水田/水网(生成器 `code=4`)共用。江南 fallback 仍是 3 相位。
- 屋顶 / 墙体 / 门:样例民居在 cc0 模式下用 6/7/8/9/11 格;桥面用 10 格栏杆;其余道具仍走
江南图集。
- 断言:`src/LivingVillage.Desktop.Tests/AssetPackTests.fs` 更新图集尺寸(12 格)与整数缩放,
并新增「构件索引都在图集内 + 单帧覆盖两水相位」用例。
- 证据(`docs/evidence/`,`git add -f`;`check-no-white-blocks.py` 5 帧 0 命中):
- `p34-realassets-day.png` / `p34-realassets-night.png`:样例村庄真实素材昼 / 夜整帧 1280x720。
- `p34-realassets-closeup-2x.png`:民居屋顶/白墙/桥栏杆/水面 2x NEAREST(青灰瓦 (52,58,72)、
白墙 (234,228,212)、木栏杆 (112,74,46)、水面 (54,110,170) 均在场)。
- `p34-water-2x.png`:河面 2x 特写(同帧可见两相位波纹)。
- `p34-baseline-day.png`:同场景程序绘制 fallback 对照。
- 复现:
```bash
DLL=src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll
for h in 12.0 23.5; do
tag=day; [ "$h" = 23.5 ] && tag=night
timeout 20 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \
LV_AUTOPLAY_RECORD=1 LV_AUTOPLAY_START_HOUR=$h LV_RECORD_EVERY=5 \
LV_RECORD_NAME=p34-$tag- LV_ASSET_PACK=cc0 \
LV_RECORD_DIR=/tmp/opencode/lv-p34-evidence/$tag dotnet "$DLL"
done
```
- 范围:仍为切片,不做全村;动画全套与更多中式元素补绘留待后续。
## P36 星露谷式情境提示系统(交互硬要求)
对齐 `docs/overnight-quality-brief.md`「交互新增硬要求」整段的验收口径:取消常驻键位清单 /
大型帮助面板;只在靠近**可达**可交互目标时显示匹配的短提示;一帧只高亮一个目标;提示对象与
按键对象一致;对话/菜单开启时隔离世界交互,Esc 关闭当前层;保存等操作给短暂中文反馈;提示
布局不遮场景、不越界。
- **常驻 HUD 审计**:常驻只有顶部窄信息条(时段/时间/倍速/体力/钱币,`HudLayout`),无任何
常驻全键位清单或大型帮助面板——世界面板 `M5Interaction.panelLines` 为世界态时不含键位行
(现有用例 `WorldPanelDoesNotRenderA永久KeyList` 断言为空)。完整键位只放在由 `P` 打开的
暂停帮助页(`MenuState.enterPauseHelp`/`exitPauseHelp`,打开时暂停、退出恢复原倍速)。
- **情境提示**:`InteractionResolver.resolve` 选出唯一目标(`InteractionTarget`:动作 +
位置 + 中文短文本 `E 交谈`/`E 进屋`/`E 出屋`);`M5Interaction.refreshPrompt` 仅在
`Panel = WorldPanel` 时把文本/坐标写入视图;离开范围(`Sim.chatRangePx`=96px)立即清空;
隔墙不触发(`hasClearPath` 采样玩家到目标的连线,命中 `WhiteWallDarkTileHome` 墙体瓦片即
判不可达,见 `blockingTiles`)。
- **确定性优先级**:`resolve` 按 `(DistanceSquared, stableId)` 排序取首个;stableId 村民用
`NpcId`、民居用 `HomeId`,距离平局按 ID 稳定裁决;一帧只产出一个目标。
- **提示对象=按键对象**:渲染的提示与 `Interact`(E)都调用同一个 `InteractionResolver.resolve`,
不存在第二套选择逻辑;`Escape` 面板内先走 `M5Interaction.apply ClosePanel`(对话/需求/观察/
年鉴/任务 → 世界面板),世界面板时才打开主菜单。
- **输入隔离**:`M5Interaction.worldInputAllowed`(仅 `WorldPanel` 为真)与 `commandAllowed`
双重门禁;对话层打开时 `Interact`/`Observe` 等世界命令被拒绝;`Game.Update` 只在
`menu.Page = Playing` 时调用 `UpdatePlaying`,主菜单/设置/操作页打开时世界输入整体不处理。
- **保存反馈**:`F6/F7` 成功或失败写入 `m5View.Status`(`save succeeded`/`save failed`),
经 `statusText` 映射为「保存成功/保存失败」,由 `transientStatusLine` 在 150 tick(≈2.5s)内
以顶部条下小药丸短暂显示后自动消失。
- **布局**:`FloatingPrompt.layout` 把提示水平居中于目标并夹在视口内,永不覆盖顶部状态条与
左下 HUD 带;回归覆盖 800x600 / 1024x576 / 1280x720 / 1920x1080。
- **回归测试**:新增 `src/LivingVillage.Desktop.Tests/P36PromptTests.fs`(10 例:距离边界、
就近切换、离开消失、隔墙不触发、对话层输入隔离、提示=按键对象一致、Esc 关闭当前层、
暂停帮助 Esc 回上层、保存 toast、布局多尺寸不遮 HUD),并复用既有
`PrototypeTests` 的浮动提示/选择确定性用例。Release 全量 Desktop 174/174、Kernel 119/119。
- **证据**(`docs/evidence/`,`git add -f`;`check-no-white-blocks.py` 4 帧 0 命中):
- `p36-prompt-approach-1.png`:进入靠近村民阶段(`stage=ApproachNpc`,pos≈(923,753)),
面板=world,`prompt=<none>`(尚未进入范围,无提示)。
- `p36-prompt-approach-2.png`:进入交谈范围(pos≈(933,729)),`prompt=E 交谈`;帧内 x[634,697]
y[199,200] 的 2px 强调线即目标上方提示药丸(宽 64 = 文本 52 + 边距),顶部 HUD 完整。
- `p36-prompt-approach-3.png`:按 E 后 `panel=dialogue`、`prompt=<none>`——浮动提示消失
(世界交互被隔离),左下对话层出现(强调线落于 x[18,395] y[516,518])。
- `p36-prompt-approach-4.png`:关闭并走远后(`stage=WalkAway`,pos≈(985,790),距最近村民
> chatRange+24px)`panel=world`、`prompt=<none>`,提示已消失。
- **复现**(确定性钩子 `LV_P36_SHOT=1`,复用 sample 脚本走位/互动并抓 4 帧后退出):
```bash
LV_P36_SHOT=1 LV_RECORD_DIR=/tmp/opencode/lv-p36-evidence LV_AUTOPLAY_START_HOUR=9.0 \
xvfb-run -a -s "-screen 0 1280x720x24" \
dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll
# 运行日志逐帧打印 panel/prompt/home 对照点,末行 p36-shot=done
```
## P37 职业系统首切片(先设计后实现)
设计/验收依据:`docs/design/profession-system.md`(4 职业:农夫/渔夫/货郎/书生 + 暂不选择)。
职业只挂在玩家侧:世界(地图/NPC/谣言/关系)仍只由 seed 决定,职业不改变世界生成确定性。
- **已落地(P11–P15/P31 血统,本单给出设计基线与显式验收)**:
- 开局属性差异:农夫/渔夫/货郎/书生开局钱 60/45/90/70、体力 100/95/100/100,写进
`WorldBootstrap.initialWorldWithOccupation` 的 `Avatar.Mind.Needs`;开局物品包在职业档案
`Occupation.profileOf … .InitialInventory`(随 v3 状态持久化)。**注意**:玩家 `Avatar` 结构体
无 `Inventory` 字段(NPC 才有),且设计禁止改 World 结构(会动 digest),故物品差异落在职业
档案/状态而非世界实体上;世界数值路径零感知。
- 每日任务:池按职业分化(农 3 / 渔 3 / 货 2 / 书 2,无职业 0),当日任务 =
`hash(seed ⊕ 职业种子, 天) mod 池大小`(splitmix 同源,禁 System.Random),确定性可复现;
完成走既有事件通道,逾期判负,完成写年鉴。
- 对话/剧情接点:村民回应按职业做确定性小幅重映射(农夫求助、渔夫闲聊、货郎闲聊、书生讲
笑话各一规则);每职业一条三段式轻剧情线,靠对话推进、关系均值决定结局;货郎/书生报价小幅
偏置(4 位定点基点,钳 ±5%),其余恒 1.0。
- 开局界面:主菜单「开始」→「选择职业」页(`OccupationSelect`),上下选择、Enter 确认、
Esc 返回,中文文案(标题「选择职业」+ 四项 + 「暂不选择」+ 剧情线提示)。
- 存档兼容:无职业存档保持 v2;v1/v2 读入职业为「无」(等同旧行为),不吞档。
- **回归测试**:新增 `src/LivingVillage.Desktop.Tests/P37ProfessionTests.fs`(8 例,覆盖设计 §4
验收 1–5),并复用既有 `DailyTaskTests`/`OccupationBiasTests`/`OccupationStoryTests`/
`M6bTests`(OccupationSelect 页)。Release 全量 Desktop **182/182**、Kernel **119/119**。
- **证据**(`docs/evidence/`,`git add -f`;`check-no-white-blocks.py` 3 帧 0 命中):
- `p37-occupation-select.png`:主菜单职业选择页(标题「选择职业」+ 农夫/渔夫/货郎/书生/暂不选择,
选中项高亮;`selected=1` 即渔夫)。
- `p37-fisher-start.png`:选渔夫(`LV_P37_OCCUPATION=fisher`)开局后 HUD + 任务面板。
- `p37-farmer-start.png`:同 seed 选农夫开局对照。
- 对照量化:两帧同一确定性世界(tick=0、同站位),差异像素共 2479,其中顶部 HUD 1648、
左下任务面板 831、**场景区 0** —— 直接证明职业不改变世界生成,差异只在开局属性与任务。
- **复现**:
```bash
DLL=src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll
for occ in fisher farmer; do
LV_P37_SHOT=1 LV_P37_OCCUPATION=$occ LV_RECORD_DIR=/tmp/opencode/lv-p37-evidence \
xvfb-run -a -s "-screen 0 1280x720x24" dotnet "$DLL"
done
# 运行日志打印 p37-shot=... page/selected/task 对照点
```
- **范围**:首切片仅 4 职业;全村 NPC 职业化、技能树/等级、其他职业与玩家实体背包留待后续单。
## P38 启动/界面升级切片(HUD 图标化)
审计 `docs/overnight-quality-brief.md` 方向 #2(启动动画 / 开始界面 / HUD,禁大黑块、星露谷式布局、
不复制素材)后的现状与差距:
- **已达标(沿用,未重复造轮子)**:启动动画 `LaunchScreen`(标题淡入 + 整屏暗幕退场,帧计数纯
函数、Esc/空格跳过)、开始界面 `TitleScreen`(分层天色 + 屋脊/水面剪影 + 灯笼 + 菜单入场,
确定性 2s)、顶部 HUD `HudLayout`(半透明渐变窄条 + 强调线 + 每段半透明药丸 + 1px 描边,
`barHeight=36` 并非大黑块)、完整键位仅在暂停帮助页。
- **最大差距(本单实现)**:顶部状态条只有文字,缺少**图标化**辨识度。已为五个常驻段各加一枚
7x7 原创点阵图标,用现有 `pixel` 贴图绘制(**无外部素材**):
时段=太阳、时间=钟面、倍速=双箭头、体力=心形、钱币=圆形钱眼。
- **布局**:`HudLayout` 每段 = 图标 `iconWidth`(7*2=14) + `iconGap`(6) + 文本;`segmentSpan` 为段总宽,
`textOrigin` 为文本起点;药丸底色与描边同时覆盖图标与文本。段高与 `barHeight` 不变,仍为窄条。
- **回归测试**:新增 `src/LivingVillage.Desktop.Tests/P38HudIconTests.fs`(4 例:图标唯一且点阵良构、
段宽=图标+间隙+文本且不重叠/在 1280 内、段与布局确定性、昼夜底色半透明且覆盖图标与文本)。
既有 `P16HudTests` 未改(`segmentText`/`segmentWidth` 语义保留)。Release 全量 Desktop **186/186**、
Kernel **119/119**。
- **确定性**:HUD 仅改渲染层,不触 `Sim.step`;`--performance-baseline` 三次 `final_digest`
仍恒为 `953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C`(digest 恒定证明
未改模拟;HUD 差异只在截图可见)。
- **证据**(`docs/evidence/`,`git add -f`;`check-no-white-blocks.py` 5 帧 0 命中):
- `p38-splash-1.png`:启动动画淡入中帧(`frame-splash-mid`,标题显现中、暗幕未退尽)。
- `p38-splash-2-menu.png`:启动结束、开始界面就位(屋脊/水面/灯笼 + 菜单面板)。
- `p38-hud-day.png` / `p38-hud-night.png`:样例村庄真实运行昼 / 夜帧,顶部窄条五段图标 + 文本;
两帧条内调色板亮色像素分别约 1386 / 1374,证明图标与文本都在场。
- `p38-hud-closeup-2x.png`:顶部条 2x NEAREST 特写(可辨五枚图标与中文字)。
- **复现**:
```bash
DLL=src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll
LV_AUTOPLAY_MENU_SHOT=1 LV_RECORD_DIR=/tmp/opencode/lv-p38-evidence xvfb-run -a -s "-screen 0 1280x720x24" dotnet "$DLL"
for h in 9.0 23.5; do tag=day; [ "$h" = 23.5 ] && tag=night
LV_AUTOPLAY_SAMPLE=1 LV_AUTOPLAY_RECORD=1 LV_AUTOPLAY_START_HOUR=$h LV_RECORD_EVERY=20 \
LV_RECORD_NAME=p38-$tag- LV_RECORD_DIR=/tmp/opencode/lv-p38-evidence/$tag xvfb-run -a -s "-screen 0 1280x720x24" dotnet "$DLL"
done
```
- **范围**:本单只做 HUD 图标化(审计选出的最大差距);启动动画与开始界面已达标不再改动;
更细的 HUD 动效/主题换肤留待后续单。
## P39 美术横向扩展切片(cc0 图集补绘中式元素 + 确定性摆放 + 夜间光晕)
在 P33/P34 竖直素材切片之后,为 `LV_ASSET_PACK=cc0` 图集补绘五类中式景观元素,并按确定性规则
接入真实场景(摆放不写瓦片、不改可达性),让 cc0 模式昼夜帧都有可辨的中式村庄细节。
- **图集扩展**:`scripts/make-cc0-art.py` 由 384x32(12 格)扩到 **608x32(19 格)**;新增 7 个自绘
函数(`draw_bridge_center` / `draw_bridge_end` / `draw_boat` / `draw_stone_lantern` / `draw_stall` /
`draw_reeds`)。旧索引 0-11 **逐字节不变**(用 `git show HEAD:...` 校验通过);产物 608x32 / 1523 bytes /
sha256 `ef86ced2…6adcb`,二次生成 byte-identical。图集 `NEAREST` 放大 2x 无混色。
- **新格索引契约**:12 石桥中段 / 13 石桥桥台 / 14 乌篷船左半 / 15 乌篷船右半 / 16 石灯笼 /
17 路摊 / 18 芦苇(见 `docs/素材来源.md` 第 4 节)。
- **确定性摆放**(`src/LivingVillage.Desktop/VillageArt.fs`,纯函数、仅 cc0 生效):
- `cc0BridgeTiles`:每座桥列最上一格铺中段(12),再上一格铺桥台(13)。
- `cc0BoatTile`:开阔水面泊 2 格船,优先靠近最近的桥(曼哈顿距离),平局按 `hash2 … 0x39B0A7UL`。
- `cc0LanternTiles map spacing`:`x % spacing = 0`,y = `MainRoadCenter.[x]`,避开水面。
- `cc0StallTiles map spacing`:`x % spacing = spacing / 2`(与灯笼错开)。
- `cc0ReedTiles map spacing`:陆地格且上下邻格为水,`x % spacing = 0`。
- **奇偶 spacing 边界**(本单自检并修复):灯笼余数类 `{0}`、摊位余数类 `{spacing/2}`,`spacing >= 2`
时两同余类恒不相交(奇偶皆成立);唯一退化点是 `spacing = 1`(`spacing/2 = 0` 与灯笼同余 → 重叠)。
两个摆放函数现**守卫 `spacing < 2` 返回空集**,从源头消除重叠;`spacing = 0`/负值同样返回空集。
- **夜间光晕接入**:新增 `nightLightTilesWith (includeRoadLanterns: bool) map`;`nightLightTiles map =
nightLightTilesWith false map`(保持旧断言不变);`drawNightGlows` 末尾新增 `includeRoadLanterns` 参数,
VillageArt 内部三处 offscreen 调用传 `false`,`Game.fs` riverscape 分支传 `artTextures.Cc0TileAtlas.IsSome`。
夜帧石灯笼复用 P29/P32 径向光晕路径,真实可见。fallback 模式不摆放新元素。
- **回归测试**:`P39ArtTests.fs` 由 7 例增至 **9 例**(新增 `StallsAndLanternsDoNotOverlapForOddAndEvenSpacing`
覆盖奇 `{3,5,7,11,13}`/偶 `{2,4,6,8,12}` 两组,`DegenerateSpacingPlacesNothing` 覆盖 `{0,1,-3}`);
`AssetPackTests` 更新为 19 格 + 新索引断言。Release 全量 Desktop **195/195**、Kernel **119/119**。
- **证据**(`docs/evidence/`,`git add -f`;`check-no-white-blocks.py` 5 帧 0 命中,重复运行 byte-identical):
- `p39-day.png` / `p39-night.png`:cc0 模式村庄真实运行昼 / 夜帧(同 seed)。
- `p39-lantern-night.png`:夜间石灯笼光晕可见帧。
- `p39-closeup-2x.png`(640x480)/ `p39-closeup-lantern-2x.png`(640x360):桥/船与灯笼 2x NEAREST 特写。
- **复现**:
```bash
DLL=src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll
timeout 90 xvfb-run -a -s "-screen 0 1280x720x24" \
env LV_P39_SHOT=1 LV_ASSET_PACK=cc0 LV_RECORD_DIR=docs/evidence dotnet "$DLL"
python3 scripts/make-p39-closeups.py docs/evidence docs/evidence
python3 scripts/check-no-white-blocks.py docs/evidence/p39-*.png
```
`LV_P39_SHOT=1` 在 `Update` 里 `PrepareP39Shot` 设置 hour/camera 并置 `p39Pending`,`Draw` 末尾
`CaptureP39Shot` 落盘(避免软件渲染每帧多跑导致缓冲相同),须配合 `LV_ASSET_PACK=cc0`。
- **范围**:本单只做横向美术扩展与确定性摆放 + 夜光接入;不写瓦片、不改可达性、不动 `Sim.step`
(digest 不变),P36/P37/P38 均未受影响。
## P40 美术深化切片(石板路衔接 / 乌篷船特征 / 石灯笼与红灯笼区分)
承接 P39 验收的两处观察点(day 帧孤立石板摆位、乌篷船特征较小),做美术深化,范围严格限定:
- **石板路衔接**:新增纯函数 `cc0PavingTiles map`(`VillageArt.fs`)=「本身或四邻属于
`map.Paths`(主路/桥引道/门口)或 `map.Bridges`(桥面)」的石板格索引集合。cc0 模式下
`drawTerrainTile` 的 code 2 只用该集合铺**连贯石板带**(新自绘索引 19),稀疏
`DecorativeStones` 孤立散布石板**改画草地**——seed 42/256x192 实测:全图 782 石板格中
502 连贯、280 孤立被抑制。fallback(无 cc0 图集)与无 `mapGen` 的大世界保持原样。
- **乌篷船特征**:重绘索引 14/15,船体放大到满 64x32 足迹、船帮加高,**拱形竹篷**高拱 +
竹骨 + 脊线显著,桥上/岸上一眼可辨(仍是自绘像素格,不引外部素材)。
- **石灯笼 vs 红灯笼形态区分**:石灯笼(16)重绘为明确三段石结构(宽石座 / 石室暖芯 /
外挑石顶盖 + 宝顶);新增红灯笼(20)红纸圆身 + 竹骨 + 金穗 + 木顶盖,门口一盏。两者
**复用同一条 P14 预乘 alpha 径向光晕路径**(`nightLightTilesWith` 纳入 `cc0RedLanternTiles`),
不新增光照系统。
- **图集**:`make-cc0-art.py` 由 608x32(19 格)扩到 **672x32(21 格)**,索引 19 石板 / 20 红灯笼;
重绘 14/15/16;**索引 0-13、17-18 逐字节不变**(`git show HEAD:...` 复核)。二次生成 byte-identical。
- **确定性**:全部摆放为 map 纯函数;同 seed 昼/夜帧位置一致(`P40ArtTests` 断言),重生成逐字节
相同;`Sim.step` 未改,`--performance-baseline` digest 不变。
- **回归测试**:新增 `src/LivingVillage.Desktop.Tests/P40ArtTests.fs`(6 例:连贯带覆盖路网且
抑制孤立散布、摆放确定性、红灯笼落门口且确定、红/石灯笼同光晕路径、同 seed 重生成一致、
P40 索引在界内);`AssetPackTests` 索引列表加入 19/20。Release 全量 Desktop **201/201**、
Kernel **119/119** exit 0。
- **证据**(`docs/evidence/`,`git add -f`;`check-no-white-blocks.py` 5 帧 0 命中,重复运行
byte-identical):
- `p40-day.png` / `p40-night.png`:cc0 模式村庄昼 / 夜整帧(相机对准乌篷船河段;昼帧红灯笼
与石板带真实可见)。
- `p40-boat-closeup-2x.png`(400x240):乌篷船 2x NEAREST 特写,拱形竹篷结构清晰。
- **复现**:
```bash
DLL=src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll
timeout 90 xvfb-run -a -s "-screen 0 1280x720x24" \
env LV_P40_SHOT=1 LV_ASSET_PACK=cc0 LV_RECORD_DIR=docs/evidence dotnet "$DLL"
python3 scripts/make-p40-closeups.py docs/evidence docs/evidence
python3 scripts/check-no-white-blocks.py docs/evidence/p40-day.png docs/evidence/p40-night.png \
docs/evidence/p40-boat-closeup-2x.png
```
- **范围**:本单只做上述三处美术深化 + 对应确定性摆放/光晕接入;不写瓦片、不改可达性、
不动 `Sim.step`,玩法/重构/新模块未触碰。
|