# 维护说明 结论先行:主分支保持 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` 或逐接收者索引替代 全表扫描;③把 `latestRumorFor`/`rumorIsDuplicate` 限制到最近窗口。以上都需单开一单、 重跑 performance-baseline 与全部 digest 后由 Hermes 走冻结流程。 ## M6a 批验:谣言工作集容量上界与按天淘汰 - 实施 M3 的三条修复方向: ①`RumorEvent` 新增派生字段 `DayIndex = Tick / ticksPerDay`,不参与存档序列化,读档由 `Tick` 纯函数 重建,故 v1/v2/v3 存档文本逐字节不变; ②新增可变参数 `rumorCapacity`(默认 16384)与 `rumorRetentionDays`(默认 3,>= 新鲜窗口 3 天), 纯函数 `trimRumors` 只保留最新 N 条并丢弃早于 `nowDay - rumorRetentionDays` 的条目;容量内且新鲜时 零拷贝原样返回;两个前插点 `chooseDialogue`/`chat` 统一走 `consRumor`; ③列表最新在前,`latestRumorFor`/`rumorIsDuplicate` 改早停递归(越窗即停、无中间列表), `nextRumorId` 改读表头 O(1)。 - 等价性:保留窗口(>= 3 天)覆盖两个查询的新鲜判定,窗口外条目本就会因 `rumorStrengthAt < rumorMinimumStrength` 被过滤,故裁剪不改变任何查询结果;「表头为最大 Id」不变量在裁剪后保持。 - 实测(本机单 worker,npcs=30,seed=42,`--cost-probe`;同机 M3 未裁剪基线见上节): | days | 未裁剪 elapsed_s | M6a elapsed_s | 未裁剪 allocated_bytes | M6a allocated_bytes | |---|---|---|---|---| | 1 | 23.4(×3) | 23.4(×3) | 19,001,341,888 | 18,898,357,944 | | 10 | 240.6 / 268.6 | 236.2 / 274.0 | 202,373,638,848 | 191,916,294,064 | | 20 | ≈900(M3 记录) | 503.1 | — | 384,468,393,040 | 10 天分配/gen0 稳定下降约 5%;超线性项改善更大:M3 记录 20 天 ≈45 s/天,M6a 后 20 天 ≈25 s/天 (≈1.8×),单日成本回归平稳。 - 工作集实测(`evidence/` 探针,seed=42,npcs=30):第 5 天 `Rumors` 7796(收敛)vs 未裁剪 9821, 两者表头 `Id` 同为 9820,证明创建序列不变、仅淘汰已不可用的旧条目。 - 门槛:`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C` 三次一致, `performance_determinism=PASS`;Kernel 89 / Desktop 94 全绿。 ## P16 批验:启动画面 / 开始界面 / HUD 精美化 - ①启动画面:新增纯函数模块 `LaunchScreen.fs`(`durationFrames=150`、`fadeInFrames=45`、 `title/subtitle/skipHint`、`revealProgress/veilAlpha/isComplete/skipRequested`);`Game.DrawSplash` 以真实游戏内渲染绘制标题/副标题,Esc 或空格可跳过,`splashFrame` 单调推进。 - ②开始界面:`MenuState.items` 收敛为三项——无存档 `[NewGameItem; LoadItem; ExitItem]`、有存档 `[ContinueItem; LoadItem; ExitItem]`;标签 开始/继续/读档/退出;设置与操作说明不再占位(游戏内 F1/F2/F3 与 P 仍可开)。 - ③HUD:原 `DrawM5Overlay` 拆为 `DrawHudBar`/`DrawTransientStatus`/`DrawPanel`;顶部窄信息条承载 时段/时间/倍速/体力/钱币,无大黑块;`HudLayout.fs` 固化 `barHeight=36`/`accentHeight=3`/ `paddingX=16`/`segmentGap=20`/`textScale=2` 与昼/夜调色板(冬夜冷色)。`FloatingPrompt` 顶避让改为 全宽 `hudTopBarHeight=39`,移除 `hudTopRightWidth/Height`。 - ④文本:全部新 UI 文案走 CJK 图集;图集 228→238 字(补 `币 段 跳 过 空 格 清 晨 黄 昏`),行数仍 15、 PNG 仍 384×360;`Assets/cjk-glyphs.txt`、`CjkGlyphAtlas.characterTable`、`requiredUiLabels` 三者一致,无缺字。 - ⑤回归:新增 `P16UiTests.fs`(启动跳过/菜单导航/HUD 布局确定性);Desktop 94→104,Kernel 89 不变 (未触碰 Kernel/Sim.fs 数值路径)。 - ⑥证据:`/tmp/opencode/lv-p16-art/`(`frame-day-0{1,2,3}.png`、`frame-night-0{1,2,3}.png`、 `frame-splash-01.png`、`hud-bar-day.png`、`hud-bar-night.png`、`sample.mp4` 13.1 s、 `final_verification.txt`);`check-no-white-blocks.py` 对 92 帧 0 命中。 - ⑦(P16-FIX 证据补拍)`frame-menu-0{1,2}.png` 实为游戏内帧,不是开始界面;新增确定性截图模式 `LV_AUTOPLAY_MENU_SHOT=1`:跳过 hero 自动开始,停在开始界面按固定帧表取样。帧表抽为纯数据 `MenuShotScript`(splash 淡入中 30 / 初始高亮 130 / 向下 140 / 高亮读档 150 / 退出 170),附 1 例 回归;`Game.CaptureMenuShot` 复用 `SaveBackBuffer` 落图。产物:`menu-real-initial.png`(初始高亮 「开始」)、`menu-real-load.png`(按向下后高亮「读档」)、`frame-splash-mid-01.png`(淡入中帧)。 像素复验:初始帧仅第 1 行含高亮色 `(245,222,120)`(115 px),load 帧仅第 2 行含(111 px), 两帧差异 575 px,确证选择项迁移。 - 门槛:clean rebuild 0 警告 0 错误;`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C` 一致、`performance_determinism=PASS`。 ## P17 批验:场景细节 / 人物动效 / 昼夜光照精修 - ①街边中式细节物:`scripts/make-jiangnan-art.py` 世界图集由 25 格扩到 28 格(新增 slot 25 石灯笼 / 26 水缸 / 27 摊贩摊位,全部烘焙进 `Assets/jiangnan-world.png`,尺寸 800x32→896x32),游戏侧只做 `tileSourceRectangle` 贴片、无程序几何。`VillageArt` 新增 `RenderPropKind.StoneLantern/WaterVat/ MarketStall` 与 `worldTileCount=28`;`sampleRenderPlan` 沿石路摆 4 件(石灯笼 4,14 / 16,14,水缸 12,11,摊位 3,11)。图集脚本二次运行 byte-identical(sha256 一致)。 - ②村民走路节奏差异化:`VillageArt` 新增 `NpcTrade`(Farmer/Peddler/Scholar,按 `NpcId mod 3` 纯函数 分配)与 `WalkRhythm`(走路/待机窗口 + 相位)。农夫 4/24、货郎 3/20(更快)、书生 6/36(更慢); `npcSpriteSpec` 改走各自节奏,`animationFrame/idleFrame/characterFrame` 保持农夫节奏即旧语义。 - ③昼夜光照精修:`M6Presentation.lightingBlend` 过渡窗口参数化(清晨 4:00→6:30、黄昏 19:30→22:00) 并把小时改分数(原为整数小时,过渡只有 3 档),新增纯函数 `transitionWarmth`(晨昏中点火暖色峰值 1,正午/午夜 0)驱动 `backgroundAtWarmth/worldTintAtWarmth`,`M6RenderProfile` 增 `SkyWarmth`。 - ④回归:更新/新增确定性测试(行走节奏、光照锚点与暖色、图集 28 格与 PNG 尺寸、证据起时纯函数); Desktop 107 / Kernel 89 全绿(未触碰 Kernel/Sim.fs 数值路径)。 - ⑤证据:`/tmp/opencode/lv-p17-art/`(`p17-day-12h-0{1,2}.png`、`p17-dusk-20h45-0{1,2}.png`、 `p17-night-00h-0{1,2}.png`、`p17-diff-before-dawn.png` vs `p17-diff-after-dawn.png`(同一 6:00 tick, P17 前/后各 1 帧)、`sample.mp4` 13.1 s);`check-no-white-blocks.py` 对所选 6 帧 0 命中。起时用新增 环境钩子 `LV_AUTOPLAY_START_HOUR`(纯函数 `M6Presentation.startTickForHour`,桌面演示专用,不进 Kernel)。 - ⑥(P17-FIX 起时钩子修复)旧 `p17-day-0{1,2}` / `p17-dusk-0{1,2}` 帧的钩子在 sample/autoplay 取证路径下 失效:`StartNewGame` 重新创建世界时按 `daylightAutoplay` 把 Tick 重设为 0/6:00,覆盖了 `createInitialWorld` 设好的值,导致黄昏/昼取证实际全为「夜晚 00:00」,P17 光照精修未被真实帧展示。 修复:抽纯函数 `M6Presentation.resolveStartTick (startHour option) (startAtDaylight)`(显式小时优先, 否则 daylight 6:00,否则 0),`createInitialWorld` 与 `StartNewGame` 统一经 `evidenceStartTick`/`withStartTick` 取同一 startTick(占位/任务日一致),`new-game=ok` 日志追加 `tick=` 便于机器核验;非取证人工路径不受影响。 回归:`StartTickResolutionHonoursEvidenceHourThenDaylightThenMidnight`(Desktop 107→108), 实测 `tick`:昼 12.0→2,592,000、黄昏 20.75→4,482,000、夜 0→0,`sample.mp4` 为黄昏 20:45。 以上新文件名(`p17-day-12h-*` / `p17-dusk-20h45-*` / `p17-night-00h-*`)为准,旧名作废。 (P18 复核确认:这批帧起时虽已正确为 20:45,但画面仍为夜蓝冷调,原因是暖色渲染链缺失, 见 §P18;P17 黄昏/夜间帧的起时有效、但暖色未生效,最终以 P18 证据为准。) - 门槛:clean rebuild 0 警告 0 错误;`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C` 一致、`performance_determinism=PASS`。 ## P18 批验:黄昏暖色调真实渲染整改 - 根因(渲染调用链):①`lightingBlend` 黄昏暗化窗口 19:30–22:00,使 20:45 的 `blend=0.5`,`Mode` 直接落到 Night;②暖色仅经 `worldTintAtWarmth` 的**乘性** tint(R+10 / G−6 / B−26)施加于世界,而乘性通道无法把 低 R 的绿色植被染成橙色,地面依旧偏绿;③`timePeriodLabel` 20:00 起即为「夜晚」,与暖色窗口错位。 - 修复:黄昏**暗化**窗口改为 20:00→22:00(20:45 `blend=0.375`,仍 Day),暖色 bump 独立为 19:15–21:45 (峰值 20:30);`backgroundAtWarmth` 暖移加强(R+120 / G+20 / B−90),`worldTintAtWarmth`(R+70 / G−10 / B−75);新增纯函数 `goldenVeilAlpha`(0..150,峰 150),`Game.DrawWorldView` 在世界与角色绘制后叠加 **预乘暖色整屏 veil**(`premultiply 255 150 70 veilAlpha`,预乘按自身 alpha 缩放、不会饱和成白块), 补上「加性暖光」这一缺失环节;`LanternGlow = max blend (warmth * 0.8)` 让黄昏灯笼同步暖亮; `timePeriodLabel` 黄昏延到 21:00。 - 回归:更新光照锚点/单调/确定性用例并新增 veil 锚点(正午/午夜 0、20:30 峰 150、19:00 前 0), Desktop 108 / Kernel 89(Kernel 未动)。 - 证据:`/tmp/opencode/lv-p18-art/`(`p18-dusk-20h45-0{1,2}.png`、`p18-night-deep-01.png`、 `p18-before-old-dusk-blue.png` vs `p18-after-new-dusk-warm.png`、`sample-dusk.mp4` 13.2 s)。 像素复验:旧黄昏画面均值 (55,81,54)(R−B≈1,冷蓝)→ 新黄昏 (153,115,57)(R−B≈95,暖橙),深夜 (34,60,56) 仍冷;`check-no-white-blocks.py` 0 命中。 - 门槛:clean rebuild 0 警告 0 错误;`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C` 一致、`performance_determinism=PASS`。 ## P19 批验:白天偏黄过曝治理(daylight 色调路径) - line-level 定位:offender 唯一是 `M6Presentation.worldTintAtWarmth`(`M6Presentation.fs:81-87`)的 **day 基线** —— `lerp 248 136 blend`(:84)与 `lerp 224 184 blend`(:85)在 `blend=0`(正午)仍给出 `(255,248,224)`:蓝通道 被压约 12%,整场景乘性偏黄。黄昏暖色链在白天并不生效,已逐一排除:`transitionWarmth`(:51-60)bump 仅在 4.0–6.5 与 19.25–21.75;`goldenVeilAlpha`(:65)正午 = 0;`LanternGlow = max blend (warmth*0.8)`(:101) 正午 = 0;`lightingBlend`(:36-43)正午 = 0。故「白天偏黄」= day world-tint 基线,与黄昏暖窗无关。 - 修复:day 基线改中性白 `(255,255,255)`,乘性恒等(geometry 保留 true white balance,无伪色彩);night 基线 `(118,136,184)` 与 `warmth` 暖移不变,黄昏/清晨暖橙完全保留。 - 回归:新增 `NoonLightingIsWarmthFreeWithNeutralWhiteBalance`(DesktopTests);PrototypeTests 增白天 10:00–15:00 平台断言(warmth=0、通道中性、veil=0、glow=0);Desktop 108→109,Kernel 89 不变。 - 证据:`/tmp/opencode/lv-p19-art/`(`p19-noon-12h-0{1,2}.png`、`p19-dusk-20h45-01.png`、 `p19-night-00h30-01.png`、`p19-before-noon-yellow.png` vs `p19-after-noon-neutral.png`)。画面均值: 正午前 `(71,109,66)` R−B=+5.1(偏黄)→ 正午后 `(70,109,75)` R−B=−5.3(中性);黄昏 `(146,113,61)` R−B=+85(暖橙保留);深夜 `(34,60,56)` 冷调;白块回归 0 命中。 - 门槛:clean rebuild 0 警告 0 错误;`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C` 一致、`performance_determinism=PASS`。 ## P20 第一步:参数化确定性生成器与视口裁剪(大地图扩程前置) - 新增纯函数模块 `src/LivingVillage.Desktop/MapGen.fs`:`generate : Params -> Result`,尺寸参数化 (默认仍是 64x48;可用 `generateWithSize 256 192 seed`)。组成: splitmix64 随机游走河道(`RiverCount`/`RiverWidth`)、2-3 octave value noise 地形(草地/泥炭)、 泊松式最小间距布点(石头装饰);核心活动区先整平为草地,再由 **BFS(4 邻接、非水可走)** 从核心中心洪泛校验 5x6=30 个 spawn 全部可达,不可达则沿直线确定性修整并复验,结果写入 `ReachabilityOk`。 纯函数、无时钟/随机源:同 seed 两次生成逐字节一致(`Array.mapi` 校验 + checksum)。 - 视口裁剪纯函数 `MapGen.visibleTileRange`/`visibleTileCount`(与既有 `VillageArt.drawWorld` 的 `tx0..tx1/ty0..ty1` 窗口一致),`drawWorld` 已改为调用它,行为不变;`drawMapFitted`/`drawMapViewport` 为证据专用绘制(整图等比 + 1:1 裁剪),不进正常游戏循环。 - 存档兼容:沿用既有 v2/v3 的 `mapWidthTiles`/`mapHeightTiles` 边界字段(读旧档仍按 64x48), P20 只补参数化边界测试,未改 Sim 数值语义;`final_digest` 与基线一致。 - 回归:`MapGenTests`(同 seed byte-identical、异 seed 不同、256x192 30 spawn 全可达、默认尺寸 64x48、 裁剪范围/端点钳制)6 条;Kernel `SaveRoundTripsParameterizedMapBounds`(256x192 v2 round-trip)1 条。 Desktop **109 -> 115**,Kernel **89 -> 90**。 - 实测性能(Xvfb,本机):生成 256x192 = **52 ms**、64x48 = **1.9 ms**;256x192 生成 `reachable_ok=true`、`reachable_tiles≈47684`;裁剪后单帧仅绘制 **1050/49152 = 2.1%** (`culled=97.9%`),离屏单帧 **0.62 ms/帧**(implied ≈1610 fps,仅 harness 立即模式)。 - 证据:`/tmp/opencode/lv-p20-art/`(`mapgen-256x192-seed{4242,7,99}.png` 整图三 seed、 `mapgen-culled-256x192-seed{...}.png` 裁剪帧、harness 计数日志)。白块回归 0 命中。 - 门槛:clean rebuild 0 警告 0 错误;`final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C` 3 次一致、`performance_determinism=PASS`。 ## P20 第二步:河道连续性不变量 + 桥 / 石板路网 / 沿河民居 - 河道重做:由「`not (inCore)` 逐格随机游走」改为**贯穿全图的东西向连续水带**(`Rivers : River list`, 每条每列恰好一段、宽度恒 `>= max 3 RiverWidth`),置于核心区上下两侧且与核心不相交。 新增不变量测试 `RiversAreContinuousWithoutGapsOrIsolatedTiles`:逐列水段数 == `Rivers.Length` 且每段 `>= RiverWidth`、任意水格 4 邻接水邻居 `>= 2`(禁止断裂缺口与孤立水点)。 - 交通结构(`Result` 新增 `Bridges`/`Paths`/`Buildings`/`BridgeCrossingsOk`): 每条河 2 座桥(2 格宽,桥面仍记水但 `isWalkable` 放行);主石板路沿核心中心行横贯全图, 每座桥有垂直连接路贯通两岸与远岸路;远岸路两侧布置民居矩形(`Building`:白墙黑瓦两行色块,门朝路)。 `MapGen.isWalkable`/`MapGen.floodFill` 公开供测试:`ReachabilityOk` 现在同时要求 30 spawn、所有门、 所有桥面可达;`BridgeCrossingsOk` 要求每座桥两岸均被同一连通域覆盖。 - 回归新增 4 条:河道连续性、桥/路/民居存在且沿河、桥两岸可达(spawn/门/桥全连通)、结构逐字节确定性。 Desktop **115 -> 119**,Kernel **90** 不变。 - 渲染证据(`drawMapFitted` 每瓦片 6px 放大 + `drawMapViewport` 1:1 + 桥中心 detail 帧); `p20b-map-*`(整图,含桥/路/白墙黑瓦民居)、`p20b-detail-*`(1:1 桥/路/民居近景)、 `p20b-culled-*`。白块回归 0 命中;整图 1536x1152。 - 实测(Xvfb,本机):256x192 生成 ≈ **63 ms**、64x48 ≈ **2.0 ms**;`reachable_ok=true`、 `reachable_tiles=47652`(= 49152 - 两河水面 1524 + 桥面 24,三 seed 恒定,印证连续性不变量); 裁剪帧 1050/49152 = 2.1%,1:1 单帧 ≈ 0.7 ms(implied ≈1300-1600 fps)。 - 门槛同上:clean rebuild 0/0;`final_digest` 3 次一致、`performance_determinism=PASS`。 ## P20 第三步:河景与可玩接入收口 - 默认可玩世界改走 **256x192 生成器**:`Game` 启动时 `Sim.configureBounds 256 192` 并 `MapGen.generateWithSize 256 192 42UL`,用 `WorldBootstrap.initialWorldInMap` 以生成器核心可达区站位 (30 NPC 全落可达核心,avatar 落核心中心);`LV_LEGACY_MAP=1` 或 `LV_MAP_SCALE>1`/`LV_MAP_TOUR=1` 回退旧路径(64x48 样例世界 / ProceduralMap 大世界)。`LV_AUTOPLAY_SAMPLE=1`(非巡游)也保持 64x48 以确保既有录像可复现,只有显式 `LV_RIVERSCAPE_TOUR=1` 才启用生成器世界;启动日志打印 `world-mode=`。`WorldBootstrap.initialWorldWithPlacement` 为显式站位入口,旧 64x48 函数字节口径不变(Kernel/Desktop 既有断言不动)。 - 渲染接入:`VillageArt.drawWorld` 新增 `pixel` 与 `mapGen : MapGen.Result option`—— Some 时按生成器瓦片绘制地面(1 水 / 2 石板路 / 其余草地)、叠加 `drawMapStructures`(桥面 + 白墙黑瓦民居 + 门) 与桥头 `LanternSprite`,并跳过 64x48 样例平面/道具;水格沿用既有 `waterFrameTickAt` 多相位波纹。 - 河景灯笼:新增 `VillageArt.bridgeLanternTiles`(每桥每列最上/最下格 = 桥两端);夜间 `LanternGlow` 沿用既有预乘同心圆 `lanternGlowLayers ... 255 214 150`(软暖盘,无过曝白芯),生成器世界用桥头灯笼位置。 - 确定性:`MapGen.serialize` 对尺寸/seed/瓦片/河道/桥/路/民居做序列化,同 seed 逐字节一致; `SameSeedSerializationIsByteIdentical` + `MapWorldPlacementUsesReachableCoreSpawns` 两条新测试。 - 回归:Desktop **119 -> 121**,Kernel **90** 不变;clean rebuild 0 警告 0 错误; `final_digest=953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C` 3 次一致、 `performance_determinism=PASS`(未触 Kernel/Sim 数值路径)。 - 证据(`LV_AUTOPLAY_SAMPLE=1 LV_RIVERSCAPE_TOUR=1`,`LV_RECORD_NAME=p20c-*`):真人走位巡游沿桥/路/民居门 逐帧录制,`/tmp/opencode/lv-p20-art/p20c-day-*.png`(`LV_AUTOPLAY_START_HOUR=12.0`)与 `p20c-night-*.png`(20.75)各若干;挑出 `p20c-day-bridge-01.png`、`p20c-day-buildings-01.png`、 `p20c-night-bridge-01.png`、`p20c-night-lantern-01.png` 等代表性帧;716 张全过白块回归 0 命中。 ## 已验证命令 ```bash # 完整重建(0 警告 0 错误) dotnet clean LivingVillage.sln 2>&1 | tail -n1 rm -rf src/*/bin src/*/obj dotnet restore LivingVillage.sln dotnet build LivingVillage.sln -c Release 2>&1 | tail -n3 # 全量测试 dotnet test src/LivingVillage.Desktop.Tests -c Release --no-build dotnet test src/LivingVillage.Kernel.Tests -c Release --no-build # M3 批次(流式;判据行与原格式逐字节兼容,仅行尾追加 elapsed_s) LV_BATCH_WORKERS=4 dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll --batch 4 10 # 长局成本阶梯(只读,真实测量) dotnet src/LivingVillage.Headless/bin/Release/net8.0/LivingVillage.Headless.dll --cost-probe 1,5,10 # 素材再生成(确定性,输出 byte-identical) python3 scripts/make-jiangnan-art.py && python3 scripts/make-cc0-art.py # Xvfb 录屏取证(沿用既有 pN harness 外参即可;harness 不入库) pkill -f "Xvfb :99"; Xvfb :99 -screen 0 1280x720x24 & sleep 2 ffmpeg -y -f x11grab -video_size 1280x720 -framerate 10 -i :99 -pix_fmt yuv420p out.mp4 LV_AUTOPLAY_SAMPLE=1 LV_AUTOPLAY_DAYLIGHT=1 LV_AUTOPLAY_RECORD=1 \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll # P16-FIX 开始界面截图取证(停在主菜单,拍初始/读档高亮与 splash 淡入中帧) timeout 90 xvfb-run -a -s "-screen 0 1280x720x24" env \ LV_AUTOPLAY_MENU_SHOT=1 LV_RECORD_DIR=/tmp/opencode/lv-p16-art \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll # P17 取证(街边细节/人物节奏/昼夜精修;LV_AUTOPLAY_START_HOUR 定起时:昼 12 / 黄昏 20.75 / 夜 0) for pair in day:12.0 dusk:20.75 night:0.0; do name=${pair%%:*}; hour=${pair##*:}; \ timeout 100 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \ LV_AUTOPLAY_START_HOUR=$hour LV_AUTOPLAY_RECORD=1 \ LV_RECORD_DIR=/tmp/opencode/lv-p17-art/$name LV_RECORD_EVERY=2 \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll; done # P19 白天色温取证(正午 12 / 黄昏 20.75 / 深夜 0.5 三帧对比) for pair in noon:12.0 dusk:20.75 night:0.5; do name=${pair%%:*}; hour=${pair##*:}; \ timeout 100 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \ LV_AUTOPLAY_START_HOUR=$hour LV_AUTOPLAY_RECORD=1 \ LV_RECORD_DIR=/tmp/opencode/lv-p19-art/$name LV_RECORD_EVERY=2 \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll; done # P20 第三步:默认生成器世界的昼夜巡游录制(真走位,含桥面行走帧;p20c-* 命名) for pair in day:12.0 night:20.75; do name=${pair%%:*}; hour=${pair##*:}; \ timeout 150 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \ LV_RIVERSCAPE_TOUR=1 LV_AUTOPLAY_RECORD=1 LV_RECORD_EVERY=20 LV_RECORD_NAME=p20c-$name- \ LV_RECORD_DIR=/tmp/opencode/lv-p20-art/p20c-$name LV_AUTOPLAY_START_HOUR=$hour \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll; done # P20 大地图生成器取证(整图 + 裁剪帧;每 seed 一拍即退,日志打印 gen_ms/drawn/culled) for seed in 4242 7 99; do timeout 120 xvfb-run -a -s "-screen 0 1280x720x24" env \ LV_MAPGEN_SHOT=1 LV_MAPGEN_WIDTH=256 LV_MAPGEN_HEIGHT=192 LV_MAPGEN_SEED=$seed \ LV_RECORD_DIR=/tmp/opencode/lv-p20-art \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll; done # P18 黄昏暖色取证(20:45 真实帧;深夜把 20.75 换成 0) timeout 100 xvfb-run -a -s "-screen 0 1280x720x24" env LV_AUTOPLAY_SAMPLE=1 \ LV_AUTOPLAY_START_HOUR=20.75 LV_AUTOPLAY_RECORD=1 \ LV_RECORD_DIR=/tmp/opencode/lv-p18-art/dusk LV_RECORD_EVERY=2 \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll # 街边细节图集再生成(确定性,二次运行 byte-identical) python3 scripts/make-jiangnan-art.py # P13 动画证据(确定性接触表 + 真实运行帧) python3 scripts/make-animation-evidence.py # -> evidence/walk-idle-frames.png 等 # 真实运行帧(白天 / 夜晚各一次;record 目录与间隔可用环境变量覆盖) timeout 45 xvfb-run -a -s "-screen 0 1280x720x24" env \ LV_AUTOPLAY_SAMPLE=1 LV_AUTOPLAY_DAYLIGHT=1 LV_AUTOPLAY_RECORD=1 \ LV_RECORD_DIR="$PWD/evidence/day-run" LV_RECORD_EVERY=2 \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll timeout 45 xvfb-run -a -s "-screen 0 1280x720x24" env \ LV_AUTOPLAY_SAMPLE=1 LV_AUTOPLAY_RECORD=1 \ LV_RECORD_DIR="$PWD/evidence/night-run" LV_RECORD_EVERY=2 \ dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll ``` - `LV_RECORD_DIR` 默认 `/tmp/lv-p5-prompt`,`LV_RECORD_EVERY` 默认 25(每 N 次绘制存一帧)。 跑完 `sample result=ok`;连续移动帧(如 day-run 的 0136..0146)即走路逐帧采样。 - 夜晚白块图像级回归(0 命中 = 通过;对 P13 旧帧应报 42/69 命中): ```bash python3 scripts/check-no-white-blocks.py evidence/night-run ``` ## 取证快照位置(近期) - `/tmp/lv-p2-ui/`、`/tmp/lv-p3-fixes/`(标题/键位/P5 提示与修) - `/tmp/lv-p6-art/`(昼夜光照/四帧行走/标题入场) - `/tmp/lv-p7-map/`(512×384 大世界巡游帧 + 夜灯对照) - `/tmp/lv-p8-cc0/`(CC0 同 seed 对照帧) - `/tmp/opencode/lv-p13/evidence/`(P13:`walk-idle-frames.png`、`water-3phase.png`、 `smoke-3phase.png`、`water-2frame.png`、`smoke-2frame.png`、`day-panorama.png`、 `night-lantern.png`、`day-night-compare.png`、`walk-real-run.png`、`day-run/`、`night-run/`) - `/tmp/opencode/lv-p20-art/`(P20 第三步:默认生成器世界昼夜巡游 `p20c-day-*.png`/`p20c-night-*.png` (含桥面行走)、代表帧 `p20c-day-bridge-01.png`/`p20c-day-buildings-01.png`/ `p20c-night-bridge-01.png`/`p20c-night-lantern-01.png`;P20:第一步 `mapgen-256x192-seed{4242,7,99}.png` 整图、 `mapgen-culled-256x192-seed{4242,7,99}.png` 裁剪帧;第二步 `p20b-map-256x192-seed{4242,7,99}.png` 整图(含桥/路/沿河民居)、`p20b-detail-256x192-seed{4242,7,99}.png` 桥/路/民居 1:1 近景、 `p20b-culled-256x192-seed{...}.png` 裁剪帧) - `/tmp/opencode/lv-p19-art/`(P19:`p19-noon-12h-0{1,2}.png`、`p19-dusk-20h45-01.png`、 `p19-night-00h30-01.png`、`p19-before-noon-yellow.png`/`p19-after-noon-neutral.png`;同一份拷入 `evidence/`) - `/tmp/opencode/lv-p18-art/`(P18:`p18-dusk-20h45-0{1,2}.png`、`p18-night-deep-01.png`、 `p18-before-old-dusk-blue.png`/`p18-after-new-dusk-warm.png`、`sample-dusk.mp4`;同一份拷入 `evidence/`) - `/tmp/opencode/lv-p17-art/`(P17:`p17-day-12h-0{1,2}.png`、`p17-dusk-20h45-0{1,2}.png`、 `p17-night-00h-0{1,2}.png`、`p17-diff-before-dawn.png`/`p17-diff-after-dawn.png`、`sample.mp4`; 同一份拷入 `evidence/`) - `/tmp/opencode/lv-p16-art/`(P16:`frame-day-0{1,2,3}.png`、`frame-night-0{1,2,3}.png`、 `frame-splash-01.png`、`frame-menu-0{1,2}.png`、`hud-bar-day.png`、`hud-bar-night.png`、 `sample.mp4`、`final_verification.txt`;沙箱不允许 `/tmp/lv-p16-art`,故落此路径) - `/tmp/opencode/lv-p13-evidence/`(P14 修复后重录:`day-panorama.png`、`night-lantern.png`、 `day-night-compare.png`、`chimney-closeup.png`、`night-lantern-closeup.png`、 `white-block-check.txt`、`day-run/`、`night-run/`;同步一份到 `evidence/`) - `artifacts/perf-independent//`(digest 3 联测回读)