summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/evidence/p68-analysis.txt18
-rw-r--r--docs/evidence/p68-map.pngbin0 -> 172204 bytes
-rw-r--r--docs/evidence/p68-run-log.txt4
-rw-r--r--docs/evidence/p68-verification.txt51
-rw-r--r--scripts/analyze-p68.py189
-rw-r--r--src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj1
-rw-r--r--src/LivingVillage.Desktop.Tests/P68MapSnapshotTests.fs54
-rw-r--r--src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj1
-rw-r--r--src/LivingVillage.Desktop/MapSnapshot.fs125
-rw-r--r--src/LivingVillage.Desktop/Program.fs48
10 files changed, 488 insertions, 3 deletions
diff --git a/docs/evidence/p68-analysis.txt b/docs/evidence/p68-analysis.txt
new file mode 100644
index 0000000..4e0a3a6
--- /dev/null
+++ b/docs/evidence/p68-analysis.txt
@@ -0,0 +1,18 @@
+P68(512×384 整图渲染截图证据)分析 — headless 离线渲染(无图形设备)
+钩子:LV_P68_MAPSHOT=1(MapGen.paramsForSize 512 384 seed=4242 → MapSnapshot 整图 PNG)
+
+log: p68-map width=512 height=384 scale=8 png=4096x3072 bytes=172204
+
+log: p68-tiles grass=164907 water=6120 stone=1109 peat=0 paddy=11595 vegetable=12877
+log: p68-features rivers=4 bridges=48 paths=1109 buildings=45 farmhouses=21 groves=852 road_columns=512
+
+log: p68-render deterministic=true sha256=DD23CC2DE96955DCDC58A261F14DB6A4BF5E012C6F1E3C047A6E868AAFDC0FBF
+
+p68-map.png 4096x3072
+palette pixel counts: grass=10480064 water=388608 stone=0 peat=0 paddy=742080 vegetable=824128 path=34112 road=32768 bridge=3072 roof=19328 door=4224 grove=54528
+water pixel ratio (vs tile count) = 0.992
+max_black_block=0 max_white_block=0
+
+missing_glyph_slots=0(纯地图整图,无 UI 文案帧)
+
+verdict: PASS
diff --git a/docs/evidence/p68-map.png b/docs/evidence/p68-map.png
new file mode 100644
index 0000000..deeebfa
--- /dev/null
+++ b/docs/evidence/p68-map.png
Binary files differ
diff --git a/docs/evidence/p68-run-log.txt b/docs/evidence/p68-run-log.txt
new file mode 100644
index 0000000..d1d68bf
--- /dev/null
+++ b/docs/evidence/p68-run-log.txt
@@ -0,0 +1,4 @@
+p68-map width=512 height=384 scale=8 png=4096x3072 bytes=172204
+p68-tiles grass=164907 water=6120 stone=1109 peat=0 paddy=11595 vegetable=12877
+p68-features rivers=4 bridges=48 paths=1109 buildings=45 farmhouses=21 groves=852 road_columns=512
+p68-render deterministic=true sha256=DD23CC2DE96955DCDC58A261F14DB6A4BF5E012C6F1E3C047A6E868AAFDC0FBF
diff --git a/docs/evidence/p68-verification.txt b/docs/evidence/p68-verification.txt
new file mode 100644
index 0000000..82ea1e6
--- /dev/null
+++ b/docs/evidence/p68-verification.txt
@@ -0,0 +1,51 @@
+P68(512×384 整图渲染截图证据)验证记录
+基线:commit a578791(P67)。本单未 push。
+
+范围
+- 为「512×384 整图」方案验收补一张覆盖全图的真实像素证据(P48 只验过 digest)。
+- 只新增 Desktop 侧离线渲染模块与证据钩子/脚本/测试;Kernel/Sim 零改动。
+
+改动文件
+- src/LivingVillage.Desktop/MapSnapshot.fs(新):`MapGen.Result` → RGB 像素 / PNG 的纯 CPU 渲染器。
+ - 每瓦片 `scale` 像素(默认 8,>=8);调色板:草地/水/石/泥炭/水田/菜畦/小径/主路/桥/屋顶/门/树丛,
+ 与 scripts/analyze-p68.py 一一对应。
+ - 绘制顺序:地面瓦片 → Paths 小径 → MainRoadCenter 主路 → Bridges 桥 → Buildings/Farmhouses
+ 屋顶矩形 + 门 → Groves 树丛。
+ - PNG 编码自包含(IHDR/IDAT/IEND + CRC32 + BCL `ZLibStream` 压缩),无外部图像依赖。
+- src/LivingVillage.Desktop/Program.fs:新增 LV_P68_MAPSHOT=1 钩子——用 `MapGen.generateWithSize 512 384 4242`
+ (内部即 paramsForSize)离线渲染整图到 `LV_RECORD_DIR/p68-map.png` 后退出,**不启动图形设备**(真正 headless)。
+- src/LivingVillage.Desktop.Tests/P68MapSnapshotTests.fs(新,3 例)+ 两处 .fsproj 挂载。
+- scripts/analyze-p68.py(新)+ docs/evidence/p68-{run-log,map}.png + p68-analysis.txt。
+
+红先
+- 先建 P68MapSnapshotTests.fs 引用未实现的 MapSnapshot → 暂存 Program.fs/fsproj 并移开 MapSnapshot.fs 后编译红
+ (FS0039 MapSnapshot 未定义),恢复后转绿。首次全量测试暴露 MSTest 对 byte[] 的 AreEqual 非结构比较,
+ 改用 CollectionAssert.AreEqual 后全绿。
+
+构建/测试
+- dotnet build LivingVillage.sln -c Release → 0 错误,1 警告(P40ArtTests.fs FS0760,既有)。
+- dotnet test src/LivingVillage.Desktop.Tests -c Release --no-build → 336/336(333→+3)。
+- dotnet test src/LivingVillage.Kernel.Tests -c Release --no-build → 119/119。
+- 用例:PNG 尺寸 = 512×scale × 384×scale(含 IHDR 解析)+ PNG 签名;RGB 缓冲含
+ 河流/农田/主路/小径/屋顶像素;同输入两次 PNG 逐字节一致、不同 scale 不同。
+
+真实整图证据(headless,无 Xvfb/X)
+- LV_P68_MAPSHOT=1 → p68-map.png 4096x3072(512×384 @ scale 8),172204 bytes。
+- 统计:grass=164907 water=6120 stone=1109 paddy=11595 vegetable=12877;
+ rivers=4 bridges=48 paths=1109 buildings=45 farmhouses=21 groves=852 road_columns=512。
+- scripts/analyze-p68.py verdict: PASS
+ - 尺寸 4096x3072;调色板像素计数:water=388608 paddy=742080 vegetable=824128 road=32768
+ path=34112 bridge=3072 roof=19328 grove=54528(均 > 0);水像素/瓦片比 = 0.992。
+ - 无 24x24 纯黑块、无 64x64 纯白块;missing_glyph_slots=0(纯地图,无 UI 帧)。
+ - 确定性 deterministic=true sha256=DD23CC2D…0FBF。
+- scripts/check-no-white-blocks.py → checked=1 failures=0 OK。
+
+确定性(数值路径未动)
+- dotnet run -c Release --project src/LivingVillage.Headless -- --performance-baseline ×3
+ → final_digest 全等 953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C,
+ performance_determinism=PASS ×3。
+
+边界
+- 未改 Kernel/Sim、未改 HUD/对话框、未改 MapGen 生成算法或 seed 口径、未引外部素材;未 push。
+- `docs/overnight-status.md` 未纳入本 slice commit(由外部监控单独提交)。
+- 逐帧/整图「视觉可辨」的 vision 复核由 Hermes 侧完成。
diff --git a/scripts/analyze-p68.py b/scripts/analyze-p68.py
new file mode 100644
index 0000000..e2a573c
--- /dev/null
+++ b/scripts/analyze-p68.py
@@ -0,0 +1,189 @@
+#!/usr/bin/env python3
+"""P68(512×384 整图渲染截图证据)分析:读 headless 离线渲染的整图 PNG + 实跑日志。
+
+判据:
+ 1. 尺寸:PNG 宽高 = 512×scale × 384×scale(scale>=8),日志 `p68-map` 一致。
+ 2. 河流/主路/农田/民居像素存在性:按 MapSnapshot 调色板精确计数
+ (water / paddy+vegetable / road / path / bridge / roof / grove 均 > 0)。
+ 3. 无纯白/纯黑大块(64x64 全白、24x24 全黑)。
+ 4. missing_glyph_slots=0(本图为纯地图,无 UI 文案帧)。
+ 5. 确定性:日志 `deterministic=true`。
+
+Usage: python3 scripts/analyze-p68.py <record-dir> <out-txt>
+Exit 0 pass / 1 fail / 2 usage.
+"""
+
+import re
+import sys
+from pathlib import Path
+
+import numpy as np
+from PIL import Image
+
+MAP_RE = re.compile(
+ r"^p68-map width=(\d+) height=(\d+) scale=(\d+) png=(\d+)x(\d+) bytes=(\d+)$",
+ re.MULTILINE,
+)
+TILES_RE = re.compile(
+ r"^p68-tiles grass=(\d+) water=(\d+) stone=(\d+) peat=(\d+) paddy=(\d+) vegetable=(\d+)$",
+ re.MULTILINE,
+)
+FEATURES_RE = re.compile(
+ r"^p68-features rivers=(\d+) bridges=(\d+) paths=(\d+) buildings=(\d+) farmhouses=(\d+) groves=(\d+) road_columns=(\d+)$",
+ re.MULTILINE,
+)
+RENDER_RE = re.compile(r"^p68-render deterministic=(\w+) sha256=([0-9A-F]+)$", re.MULTILINE)
+
+PALETTE = {
+ "grass": (86, 140, 74),
+ "water": (52, 110, 180),
+ "stone": (150, 150, 150),
+ "peat": (110, 96, 72),
+ "paddy": (120, 168, 84),
+ "vegetable": (154, 186, 96),
+ "path": (196, 176, 120),
+ "road": (214, 190, 128),
+ "bridge": (140, 96, 64),
+ "roof": (58, 58, 70),
+ "door": (120, 72, 40),
+ "grove": (34, 96, 52),
+}
+
+DARK_SIDE = 24
+DARK_MAX = 8
+WHITE_SIDE = 64
+WHITE_MIN = 250
+
+
+def max_block(rgba, side, predicate):
+ h, w = rgba.shape[0], rgba.shape[1]
+ if h < side or w < side:
+ return 0
+ mask = predicate(rgba).astype(np.int64)
+ integral = np.zeros((h + 1, w + 1), dtype=np.int64)
+ integral[1:, 1:] = mask.cumsum(axis=0).cumsum(axis=1)
+ best = 0
+ for y in range(0, h - side + 1):
+ for x in range(0, w - side + 1):
+ total = (
+ integral[y + side, x + side]
+ - integral[y, x + side]
+ - integral[y + side, x]
+ + integral[y, x]
+ )
+ best = max(best, int(total))
+ return best
+
+
+def main():
+ if len(sys.argv) != 3:
+ print(__doc__)
+ return 2
+ rec = Path(sys.argv[1])
+ out = Path(sys.argv[2])
+ log = (rec / "log.txt").read_text(encoding="utf-8", errors="replace")
+ failures = []
+ lines = [
+ "P68(512×384 整图渲染截图证据)分析 — headless 离线渲染(无图形设备)",
+ "钩子:LV_P68_MAPSHOT=1(MapGen.paramsForSize 512 384 seed=4242 → MapSnapshot 整图 PNG)",
+ "",
+ ]
+
+ m = MAP_RE.search(log)
+ if not m:
+ failures.append("log missing p68-map")
+ else:
+ width, height, scale, pw, ph, nbytes = (int(v) for v in m.groups())
+ lines.append(f"log: {m.group(0)}")
+ if (width, height) != (512, 384):
+ failures.append(f"map {width}x{height} != 512x384")
+ if scale < 8:
+ failures.append(f"scale {scale} < 8 (每瓦片像素不足)")
+ if (pw, ph) != (width * scale, height * scale):
+ failures.append(f"png {pw}x{ph} != {width * scale}x{height * scale}")
+ lines.append("")
+
+ m = TILES_RE.search(log)
+ if not m:
+ failures.append("log missing p68-tiles")
+ tile_counts = None
+ else:
+ tile_counts = {k: int(v) for k, v in zip(
+ ["grass", "water", "stone", "peat", "paddy", "vegetable"], m.groups())}
+ lines.append("log: " + m.group(0))
+ m = FEATURES_RE.search(log)
+ if not m:
+ failures.append("log missing p68-features")
+ else:
+ lines.append("log: " + m.group(0))
+ lines.append("")
+
+ m = RENDER_RE.search(log)
+ if not m:
+ failures.append("log missing p68-render")
+ else:
+ deterministic, sha = m.groups()
+ lines.append(f"log: {m.group(0)}")
+ if deterministic != "true":
+ failures.append("render not deterministic")
+ if len(sha) != 64:
+ failures.append(f"sha256 length {len(sha)} != 64")
+ lines.append("")
+
+ png = rec / "p68-map.png"
+ if not png.exists():
+ failures.append("missing p68-map.png")
+ else:
+ rgb = np.asarray(Image.open(png).convert("RGB"), dtype=np.uint8)
+ rgba = np.asarray(Image.open(png).convert("RGBA"), dtype=np.uint8)
+ h, w = rgb.shape[0], rgb.shape[1]
+ lines.append(f"p68-map.png {w}x{h}")
+ if (w, h) != (512 * 8, 384 * 8):
+ failures.append(f"decoded png {w}x{h} != 4096x3072")
+ flat = rgb.reshape(-1, 3)
+ counts = {}
+ for name, color in PALETTE.items():
+ counts[name] = int((flat == np.array(color, dtype=np.uint8)).all(axis=1).sum())
+ lines.append("palette pixel counts: " + " ".join(f"{k}={v}" for k, v in counts.items()))
+ for name in ("water", "paddy", "vegetable", "road", "path", "bridge", "roof", "grove"):
+ if counts[name] == 0:
+ failures.append(f"missing {name} pixels")
+ if counts["paddy"] + counts["vegetable"] == 0:
+ failures.append("missing farmland (paddy+vegetable) pixels")
+ if counts["roof"] == 0:
+ failures.append("missing building roof pixels")
+ if tile_counts is not None and tile_counts["water"] > 0:
+ ratio = counts["water"] / (tile_counts["water"] * 64)
+ lines.append(f"water pixel ratio (vs tile count) = {ratio:.3f}")
+ if ratio < 0.5:
+ failures.append(f"water ratio {ratio:.3f} < 0.5 (river pixels missing)")
+ dark = max_block(
+ rgba,
+ DARK_SIDE,
+ lambda a: (a[:, :, 0] <= DARK_MAX) & (a[:, :, 1] <= DARK_MAX) & (a[:, :, 2] <= DARK_MAX) & (a[:, :, 3] >= 250),
+ )
+ white = max_block(
+ rgba,
+ WHITE_SIDE,
+ lambda a: (a[:, :, 0] >= WHITE_MIN) & (a[:, :, 1] >= WHITE_MIN) & (a[:, :, 2] >= WHITE_MIN) & (a[:, :, 3] >= 250),
+ )
+ lines.append(f"max_black_block={dark} max_white_block={white}")
+ if dark != 0:
+ failures.append(f"black block {dark}")
+ if white >= WHITE_SIDE * WHITE_SIDE:
+ failures.append(f"fully-white block {white}")
+ lines.append("")
+
+ lines.append("missing_glyph_slots=0(纯地图整图,无 UI 文案帧)")
+ lines.append("")
+ verdict = "PASS" if not failures else "FAIL"
+ lines.append(f"verdict: {verdict}")
+ for f in failures:
+ lines.append(f"FAIL: {f}")
+ out.write_text("\n".join(lines) + "\n", encoding="utf-8")
+ print("\n".join(lines))
+ return 0 if not failures else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
index 2ea84b5..6d1a3e8 100644
--- a/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
+++ b/src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj
@@ -40,6 +40,7 @@
<Compile Include="P65RumorInquiryTests.fs" />
<Compile Include="P66TaskTargetTests.fs" />
<Compile Include="P67TaskTargetUiTests.fs" />
+ <Compile Include="P68MapSnapshotTests.fs" />
<Compile Include="SampleTests.fs" />
</ItemGroup>
diff --git a/src/LivingVillage.Desktop.Tests/P68MapSnapshotTests.fs b/src/LivingVillage.Desktop.Tests/P68MapSnapshotTests.fs
new file mode 100644
index 0000000..4109aae
--- /dev/null
+++ b/src/LivingVillage.Desktop.Tests/P68MapSnapshotTests.fs
@@ -0,0 +1,54 @@
+namespace LivingVillage.Desktop.Tests
+
+open Microsoft.VisualStudio.TestTools.UnitTesting
+open LivingVillage.Desktop
+
+/// P68:512×384 整图离线渲染(MapSnapshot)的可验收护栏。
+/// 断言尺寸/要素像素存在性/确定性;调色板口径与 scripts/analyze-p68.py 一致。
+[<TestClass>]
+type P68MapSnapshotTests () =
+
+ let map = MapGen.generateWithSize 512 384 (uint64 4242)
+
+ let be32 (bytes: byte array) (offset: int) : int =
+ (int bytes.[offset] <<< 24)
+ ||| (int bytes.[offset + 1] <<< 16)
+ ||| (int bytes.[offset + 2] <<< 8)
+ ||| int bytes.[offset + 3]
+
+ let countColor (rgb: byte array) (color: byte * byte * byte) : int =
+ let r, g, b = color
+ let mutable count = 0
+ let mutable i = 0
+ while i < rgb.Length do
+ if rgb.[i] = r && rgb.[i + 1] = g && rgb.[i + 2] = b then count <- count + 1
+ i <- i + 3
+ count
+
+ [<TestMethod>]
+ member _.RenderPngHasFullMapDimensions () =
+ let png = MapSnapshot.renderPng map 4
+ let signature = [| 137uy; 80uy; 78uy; 71uy; 13uy; 10uy; 26uy; 10uy |]
+ CollectionAssert.AreEqual(signature, png.[0..7])
+ Assert.AreEqual<string>("IHDR", System.Text.Encoding.ASCII.GetString(png.[12..15]))
+ Assert.AreEqual<int>(512 * 4, be32 png 16)
+ Assert.AreEqual<int>(384 * 4, be32 png 20)
+
+ [<TestMethod>]
+ member _.RenderRgbContainsRiverRoadFarmAndBuildingPixels () =
+ let rgb = MapSnapshot.renderRgb map 8
+ Assert.AreEqual<int>(512 * 8 * 384 * 8 * 3, rgb.Length)
+ Assert.IsTrue(countColor rgb (52uy, 110uy, 180uy) > 0, "river (water) pixels missing")
+ Assert.IsTrue(
+ countColor rgb (120uy, 168uy, 84uy) + countColor rgb (154uy, 186uy, 96uy) > 0,
+ "farmland pixels missing")
+ Assert.IsTrue(countColor rgb (214uy, 190uy, 128uy) > 0, "main-road pixels missing")
+ Assert.IsTrue(countColor rgb (196uy, 176uy, 120uy) > 0, "path pixels missing")
+ Assert.IsTrue(countColor rgb (58uy, 58uy, 70uy) > 0, "building roof pixels missing")
+
+ [<TestMethod>]
+ member _.RenderIsByteIdenticalAcrossRuns () =
+ let first = MapSnapshot.renderPng map 8
+ let second = MapSnapshot.renderPng (MapGen.generateWithSize 512 384 (uint64 4242)) 8
+ CollectionAssert.AreEqual(first, second)
+ Assert.IsTrue(MapSnapshot.renderPng map 4 <> first, "different scale must produce a different image")
diff --git a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj
index ab0a906..7243705 100644
--- a/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj
+++ b/src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj
@@ -9,6 +9,7 @@
<Compile Include="VillagePresentation.fs" />
<Compile Include="ProceduralMap.fs" />
<Compile Include="MapGen.fs" />
+ <Compile Include="MapSnapshot.fs" />
<Compile Include="WorldBootstrap.fs" />
<Compile Include="TaskRuntime.fs" />
<Compile Include="PlayerTrade.fs" />
diff --git a/src/LivingVillage.Desktop/MapSnapshot.fs b/src/LivingVillage.Desktop/MapSnapshot.fs
new file mode 100644
index 0000000..498ef48
--- /dev/null
+++ b/src/LivingVillage.Desktop/MapSnapshot.fs
@@ -0,0 +1,125 @@
+namespace LivingVillage.Desktop
+
+open System
+open System.IO
+open System.IO.Compression
+open System.Text
+
+/// P68:把 `MapGen.Result` 离线渲染为整图 PNG(不依赖图形设备,纯 CPU)。
+///
+/// 用途:为「512×384 整图」方案验收补一张覆盖全图的真实像素证据。
+/// 判据口径由 `scripts/analyze-p68.py` 复用(调色板常量与此处一一对应):
+/// * 每瓦片 `scale`(默认 8)像素,输出尺寸 = 宽×scale × 高×scale;
+/// * 河流(Water 瓦片)、主路(MainRoadCenter)、农田(PaddyField/VegetablePlot)、
+/// 民居(Buildings/Farmhouses 屋顶矩形)均有独立可辨色;
+/// * 纯函数:同输入逐字节一致;无外部素材依赖。
+module MapSnapshot =
+
+ /// 默认每瓦片像素(>= 8,满足证据可辨要求)。
+ let defaultScale = 8
+
+ let private grass = (86uy, 140uy, 74uy)
+ let private water = (52uy, 110uy, 180uy)
+ let private stone = (150uy, 150uy, 150uy)
+ let private peat = (110uy, 96uy, 72uy)
+ let private paddy = (120uy, 168uy, 84uy)
+ let private vegetable = (154uy, 186uy, 96uy)
+ let private path = (196uy, 176uy, 120uy)
+ let private road = (214uy, 190uy, 128uy)
+ let private bridge = (140uy, 96uy, 64uy)
+ let private roof = (58uy, 58uy, 70uy)
+ let private door = (120uy, 72uy, 40uy)
+ let private grove = (34uy, 96uy, 52uy)
+
+ let private tileColor (code: int) : byte * byte * byte =
+ match code with
+ | 1 -> water
+ | 2 -> stone
+ | 3 -> peat
+ | 4 -> paddy
+ | 5 -> vegetable
+ | _ -> grass
+
+ /// 把 `MapGen.Result` 渲染为 RGB 像素缓冲(行优先,左上原点),每瓦片 `scale` 像素。
+ let renderRgb (map: MapGen.Result) (scale: int) : byte array =
+ let s = max 1 scale
+ let width = map.Width * s
+ let height = map.Height * s
+ let pixels = Array.zeroCreate<byte> (width * height * 3)
+ let fillTile (tx: int) (ty: int) (r: byte, g: byte, b: byte) =
+ if tx >= 0 && tx < map.Width && ty >= 0 && ty < map.Height then
+ for py in ty * s .. ty * s + s - 1 do
+ for px in tx * s .. tx * s + s - 1 do
+ let i = (py * width + px) * 3
+ pixels.[i] <- r
+ pixels.[i + 1] <- g
+ pixels.[i + 2] <- b
+ let fillRect (left: int) (top: int) (rectWidth: int) (rectHeight: int) (r: byte, g: byte, b: byte) =
+ for ty in top .. top + max 0 rectHeight - 1 do
+ for tx in left .. left + max 0 rectWidth - 1 do
+ fillTile tx ty (r, g, b)
+ for ty in 0 .. map.Height - 1 do
+ for tx in 0 .. map.Width - 1 do
+ fillTile tx ty (tileColor map.Tiles.[ty * map.Width + tx])
+ for (x, y) in map.Paths do
+ fillTile x y path
+ map.MainRoadCenter
+ |> Array.iteri (fun x y -> fillTile x y road)
+ for (x, y) in map.Bridges do
+ fillTile x y bridge
+ let drawBuilding (building: MapGen.Building) =
+ fillRect building.Left building.Top building.Width building.Height roof
+ fillTile building.DoorX building.DoorY door
+ for building in map.Buildings do
+ drawBuilding building
+ for farmhouse in map.Farmhouses do
+ drawBuilding farmhouse
+ for g in map.Groves do
+ fillTile g.X g.Y grove
+ pixels
+
+ let private crcTable =
+ [| for n in 0 .. 255 ->
+ let mutable c = uint32 n
+ for _ in 0 .. 7 do
+ c <- if (c &&& 1u) = 1u then 0xEDB88320u ^^^ (c >>> 1) else c >>> 1
+ c |]
+
+ let private crc32 (bytes: byte array) : uint32 =
+ let mutable c = 0xFFFFFFFFu
+ for b in bytes do
+ c <- crcTable.[int ((c ^^^ uint32 b) &&& 0xFFu)] ^^^ (c >>> 8)
+ c ^^^ 0xFFFFFFFFu
+
+ let private be32 (value: uint32) : byte array =
+ [| byte (value >>> 24); byte ((value >>> 16) &&& 0xFFu); byte ((value >>> 8) &&& 0xFFu); byte (value &&& 0xFFu) |]
+
+ let private chunk (kind: string) (data: byte array) : byte array =
+ let kindBytes = Encoding.ASCII.GetBytes kind
+ let crc = crc32 (Array.append kindBytes data)
+ Array.concat [ be32 (uint32 data.Length); kindBytes; data; be32 crc ]
+
+ /// zlib 容器压缩(BCL `ZLibStream`,无外部依赖),PNG IDAT 直接用。
+ let private zlibCompress (data: byte array) : byte array =
+ use output = new MemoryStream()
+ using (new ZLibStream(output, CompressionLevel.Optimal, true)) (fun zlib ->
+ zlib.Write(data, 0, data.Length))
+ output.ToArray()
+
+ /// 渲染为 8-bit RGB PNG 字节流(无 alpha)。确定性:同输入逐字节一致。
+ let renderPng (map: MapGen.Result) (scale: int) : byte array =
+ let s = max 1 scale
+ let width = map.Width * s
+ let height = map.Height * s
+ let rgb = renderRgb map s
+ let stride = width * 3
+ let raw = Array.zeroCreate<byte> (height * (1 + stride))
+ for y in 0 .. height - 1 do
+ raw.[y * (1 + stride)] <- 0uy
+ Array.blit rgb (y * stride) raw (y * (1 + stride) + 1) stride
+ let signature = [| 137uy; 80uy; 78uy; 71uy; 13uy; 10uy; 26uy; 10uy |]
+ let ihdr = Array.concat [ be32 (uint32 width); be32 (uint32 height); [| 8uy; 2uy; 0uy; 0uy; 0uy |] ]
+ Array.concat [ signature; chunk "IHDR" ihdr; chunk "IDAT" (zlibCompress raw); chunk "IEND" [||] ]
+
+ let save (path: string) (map: MapGen.Result) (scale: int) : unit =
+ File.WriteAllBytes(path, renderPng map scale)
diff --git a/src/LivingVillage.Desktop/Program.fs b/src/LivingVillage.Desktop/Program.fs
index 0e2adcf..53cc274 100644
--- a/src/LivingVillage.Desktop/Program.fs
+++ b/src/LivingVillage.Desktop/Program.fs
@@ -1,9 +1,51 @@
namespace LivingVillage.Desktop
+open System
+open System.IO
+open System.Security.Cryptography
+
module Program =
+ /// P68 证据钩子:LV_P68_MAPSHOT=1 时离线渲染 512×384 / seed 4242 整图为 PNG 后退出,
+ /// 不启动图形设备(真正 headless)。打印尺寸/瓦片统计/要素统计/确定性供 analyze 核验。
+ let private runMapShot () : int =
+ let map = MapGen.generateWithSize 512 384 (uint64 4242)
+ let scale = MapSnapshot.defaultScale
+ let recordDirectory =
+ match Environment.GetEnvironmentVariable("LV_RECORD_DIR") with
+ | null | "" -> "/tmp/lv-p68-map"
+ | value -> value
+ Directory.CreateDirectory recordDirectory |> ignore
+ let png = MapSnapshot.renderPng map scale
+ let again = MapSnapshot.renderPng (MapGen.generateWithSize 512 384 (uint64 4242)) scale
+ let deterministic = png = again
+ let sha = SHA256.HashData png |> Convert.ToHexString
+ File.WriteAllBytes(Path.Combine(recordDirectory, "p68-map.png"), png)
+ let tiles code = map.Tiles |> Array.filter (fun c -> c = code) |> Array.length
+ let roadTiles =
+ map.MainRoadCenter
+ |> Array.filter (fun y -> y >= 0 && y < map.Height)
+ |> Array.length
+ printfn
+ "p68-map width=%d height=%d scale=%d png=%dx%d bytes=%d"
+ map.Width map.Height scale (map.Width * scale) (map.Height * scale) png.Length
+ printfn
+ "p68-tiles grass=%d water=%d stone=%d peat=%d paddy=%d vegetable=%d"
+ (tiles (int MapGen.GroundTile.Grass)) (tiles (int MapGen.GroundTile.Water))
+ (tiles (int MapGen.GroundTile.Stone)) (tiles (int MapGen.GroundTile.Peat))
+ (tiles (int MapGen.GroundTile.PaddyField)) (tiles (int MapGen.GroundTile.VegetablePlot))
+ printfn
+ "p68-features rivers=%d bridges=%d paths=%d buildings=%d farmhouses=%d groves=%d road_columns=%d"
+ map.Rivers.Length map.Bridges.Length map.Paths.Length map.Buildings.Length
+ map.Farmhouses.Length map.Groves.Length roadTiles
+ printfn "p68-render deterministic=%b sha256=%s" deterministic sha
+ 0
+
[<EntryPoint>]
let main _ =
- use game = new LivingVillageGame()
- game.Run()
- 0
+ if Environment.GetEnvironmentVariable("LV_P68_MAPSHOT") = "1" then
+ runMapShot ()
+ else
+ use game = new LivingVillageGame()
+ game.Run()
+ 0