summaryrefslogtreecommitdiff
path: root/docs/维护说明.md
blob: 14d11f664accd33bf9769ca6332fcf6d109a9dc6 (plain)
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
# 维护说明

结论先行:主分支保持 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`。

## 已验证命令

```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
# 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-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 联测回读)