diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-29 09:18:45 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-29 09:18:45 +0800 |
| commit | 4aa6fabbb594af1889778ff7f01ae3193dc08b1d (patch) | |
| tree | f25dea86c382d94bb005239001ef99da25fded37 | |
| parent | a578791b0dd8ca95fba3af6f3c2f39c816bd7e52 (diff) | |
| download | living-village-4aa6fabbb594af1889778ff7f01ae3193dc08b1d.tar.gz | |
p68: 512×384 整图离线渲染截图证据
- MapSnapshot:MapGen.Result 纯 CPU 渲染为整图 PNG(每瓦片 8px,自包含
IHDR/IDAT/IEND + CRC32 + ZLibStream,无外部图像依赖);调色板覆盖
河流/主路/农田/民居屋顶/小径/桥/树丛
- Program.fs:LV_P68_MAPSHOT=1 钩子用 paramsForSize 512 384 seed=4242 生成并
离线渲染整图后退出(headless,不需图形设备)
- P68MapSnapshotTests(3 例,红先):尺寸/PNG 签名、要素像素存在、两次渲染逐字节一致
- scripts/analyze-p68.py:尺寸/调色板像素/无黑白大块/missing_glyph_slots=0/确定性
- Desktop 336/336、Kernel 119/119;digest 953775FA…7F6C ×3 PASS;未动 Kernel/Sim
| -rw-r--r-- | docs/evidence/p68-analysis.txt | 18 | ||||
| -rw-r--r-- | docs/evidence/p68-map.png | bin | 0 -> 172204 bytes | |||
| -rw-r--r-- | docs/evidence/p68-run-log.txt | 4 | ||||
| -rw-r--r-- | docs/evidence/p68-verification.txt | 51 | ||||
| -rw-r--r-- | scripts/analyze-p68.py | 189 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/LivingVillage.Desktop.Tests.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop.Tests/P68MapSnapshotTests.fs | 54 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/LivingVillage.Desktop.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/MapSnapshot.fs | 125 | ||||
| -rw-r--r-- | src/LivingVillage.Desktop/Program.fs | 48 |
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 Binary files differnew file mode 100644 index 0000000..deeebfa --- /dev/null +++ b/docs/evidence/p68-map.png 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 |
