summaryrefslogtreecommitdiff
path: root/docs/api-reference
diff options
context:
space:
mode:
Diffstat (limited to 'docs/api-reference')
-rw-r--r--docs/api-reference/README.md41
-rw-r--r--docs/api-reference/interaction-and-trade.md60
-rw-r--r--docs/api-reference/map-and-mapgen.md64
-rw-r--r--docs/api-reference/occupation.md60
-rw-r--r--docs/api-reference/rendering-desktop.md65
-rw-r--r--docs/api-reference/sim-kernel.md82
-rw-r--r--docs/api-reference/world-save.md57
7 files changed, 429 insertions, 0 deletions
diff --git a/docs/api-reference/README.md b/docs/api-reference/README.md
new file mode 100644
index 0000000..26db279
--- /dev/null
+++ b/docs/api-reference/README.md
@@ -0,0 +1,41 @@
+# 中文 API / 模块说明(api-reference)
+
+本目录为《活着的村庄》玩家可见代码模块的系统性中文说明,面向维护者与验收者。
+每条口径以**当前源码**为准(F# 8 / .NET 8);文档只描述既有实现,不发明接口。
+
+## 阅读约定
+
+- **单位**:模拟时间以 `tick` 为基本单位,`1 tick = 1/60 秒`;`1 模拟日 = 86,400 秒 = 5,184,000 tick`。
+ 位置以**像素**为单位(`Sim.tilePixels = 32` 像素/瓦片);地图以**瓦片**为单位。
+- **确定性**:世界推进只由 `World.Rng`(splitmix64 同源)与 `seed` 派生,禁止墙钟与 `System.Random`。
+- **性能**:只引用**实测**基准(`docs/evidence/p70-*.txt`),不外推、不宣称未测数字。
+- **交叉引用**:`docs/维护说明.md`(模块职责 / 状态不变量 / 已验证命令)、
+ `docs/chinese-village-art-direction.md`(中文与美术方向)。
+
+## 模块索引
+
+| 文件 | 覆盖模块 | 关键接口 |
+| --- | --- | --- |
+| `sim-kernel.md` | `LivingVillage.Kernel/Sim.fs` | `Sim.step`、`World`、`chat`、`trade`、`chooseDialogueWith` |
+| `map-and-mapgen.md` | `MapGen.fs`、`ProceduralMap.fs`、`MapSnapshot.fs` | `MapGen.generateWithSize`、`MapGen.serialize`、`MapSnapshot.renderPng` |
+| `rendering-desktop.md` | `Game.fs`、`VillageArt.fs`、`CjkGlyphAtlas.fs` | `LivingVillageGame` 主循环、`VillageArt.drawWorld`、`CjkGlyphAtlas.sourceRectangle` |
+| `interaction-and-trade.md` | `Interaction.fs`、`PlayerTrade.fs` | `M5Interaction.apply`、`M5View`、`PlayerTrade.buy/sell` |
+| `occupation.md` | `Occupation.fs` | `Occupation.saveToken`、`Occupation.dailyTaskOfWith`、`Occupation.applySignal` |
+| `world-save.md` | `WorldSave.fs` | `WorldSave.save/saveWith`、`WorldSave.loadFromFileWith` |
+
+## 统一小节口径
+
+每个模块文件固定包含六节:
+
+1. **职责**:一句话说明该模块负责什么、不负责什么。
+2. **对外 API 概览**:公开类型与函数的真实签名/语义。
+3. **单位与量纲**:输入/输出的单位、取值范围。
+4. **不变量**:可验收的硬约束。
+5. **失败分支**:错误/拒绝/异常路径与处理方式。
+6. **算法与性能取舍**:算法要点与**实测**性能引用。
+
+## 相关证据与基准
+
+- Kernel 吞吐 / GC:`docs/evidence/p70-kernel-baseline.txt`(`--performance-baseline`)。
+- Desktop 帧率 / 每帧分配:`docs/evidence/p70-analysis.txt`、`docs/evidence/p70-frames-sample.txt`。
+- 复跑:`bash scripts/bench-p70.sh /tmp/opencode/lv-p70`。
diff --git a/docs/api-reference/interaction-and-trade.md b/docs/api-reference/interaction-and-trade.md
new file mode 100644
index 0000000..bbc0687
--- /dev/null
+++ b/docs/api-reference/interaction-and-trade.md
@@ -0,0 +1,60 @@
+# 交互与交易(M5Interaction / PlayerTrade)
+
+源码:`src/LivingVillage.Desktop/Interaction.fs`、`src/LivingVillage.Desktop/PlayerTrade.fs`。
+
+## 职责
+
+把键盘输入翻译为 M5 面板/对话/交易命令,并维护玩家可见面板与状态文案;
+`PlayerTrade` 把职业背包侧车变成世界里可买卖的物品(不改 `Sim.step` 数值路径)。
+
+## 对外 API 概览
+
+- `type M5Panel = WorldPanel | NeedsPanel | ObservationPanel | DialoguePanel | ChroniclePanel | TaskPanel`。
+- `type M5Command = Interact | Intent1..Intent6 | BuyFromTarget | SellToTarget | AcceptTask |
+ Observe | ToggleNeeds | ShowChronicle | ShowTaskPanel | ClosePanel`。
+- `type M5View = { Panel; Menu; Needs; Observations; Chronicle; Status; StatusTick; HomeMode;
+ Prompt; PromptTargetPos; Task: Occupation.State option; Seed: uint64 }`。
+- `M5Interaction.initial : M5View`;`M5Interaction.apply command world view : World * M5View`(public,纯函数);
+ `panelLines world view`、`statusText view`、`panelName view`、`titleText view`、
+ `transientStatusLine nowTick view`、`cornerStatusLines world`、`refreshPrompt world view`。
+- `PlayerTrade.Outcome = { World; Occupation; Succeeded; UnitPrice: float32 option;
+ Reward: (ItemKind*int) option; Message: string }`。
+- `PlayerTrade.quoteBase / quote`、`PlayerTrade.buy state counterparty item quantity world`、
+ `PlayerTrade.sell ...`、`PlayerTrade.buyFood`、`PlayerTrade.sellFirst`。
+
+## 单位与量纲
+
+- `Status: string` 为**内部 token**(如 `"dialogue target=..."`、`"task|..."`、`"inquiry|..."`),
+ 经 `statusLabel` 映射为中文;`StatusTick: int64 option` 为显示计时(`statusDisplayDurationTicks = 150L`)。
+- 交易金额/单价为 `float32`,与 `Sim.Needs.Money` 同尺度;数量 `int`(必须 > 0)。
+- 面板快捷键:`1..6` → 六种对话意图;`B` 买入食物×1、`V` 卖出背包首件×1;`Tab` 需求、`Q` 观察、
+ `C` 年鉴、`T`/回车 任务面板、`Esc` 关闭;`E` 互动/进屋/出屋。文案见 `ChineseText.requiredUiLabels`。
+
+## 不变量
+
+1. **apply 纯函数**:返回新 `(World, M5View)`,不改动入参;世界交互仅在 `WorldPanel` 生效
+ (`worldInputAllowed`)。
+2. **面板行来源唯一**:所有面板正文由 `panelLines` 生成;文案经 `CjkGlyphAtlas` 全可渲染。
+3. **无伪造状态**:面板只展示 `M5View`/`World` 中真实存在的值(需求、观察、年鉴、任务)。
+4. **交易不改数值路径**:`PlayerTrade` 只追加 `TradeEvent`/`TradeAnnal` 并写双方记忆与背包,
+ 不动 `Sim.step`/地图/seed;玩家以临时镜像 `NpcId -1` 仅用于报价。
+5. **背包口径**:`Occupation.State.Backpack` 为声明顺序稳定的 `(ItemKind*int) list`,归零移除、新物品追加尾部。
+
+## 失败分支
+
+- 命令不可用(如非对话面板发 `Intent1`):`applyAllowed` 返回 `(world, view)` 原样,不改变状态。
+- 对话失败 token:`dialogue rejected:<reason>` → 文案「对话失败:<reason>」;
+ 无可用对话 → 「当前没有可用对话」;无效选项 → 「对话选项无效」;附近无目标 → 「附近没有可互动目标」。
+- 交易失败:`PlayerTrade` 返回 `Succeeded = false` 且 `Message` 为中文原因——
+ 「未选择职业」「找不到对方」「数量必须大于零」「库存不足」「无法报价」「对方资金不足」/
+ 「背包里没有可卖物品」;对话交易侧失败名为「找不到目标 / 目标不在范围内 / 目标暂时无法互动 /
+ 找不到买家 / 找不到卖家 / 买家和卖家不能是同一人 / 数量必须大于零 / 库存不足 / 资金不足」。
+- 未知状态 token:`statusLabel` 兜底「状态已更新」。
+
+## 算法与性能取舍
+
+- `apply` 是纯状态转换:命令 → 校验(`commandAllowed`)→ 执行 → 生成 `Status` token;
+ 面板文本在帧内按需拼接,无额外缓存。
+- `PlayerTrade` 报价复用 `Sim.quotePrice`(先经 `Occupation.biasedQuote`;无职业恒等 1.0),
+ 通过「报价世界副本」把玩家镜像成 `Npc`,副本不写回真实世界——避免给 `Avatar` 引入 Inventory 字段。
+- 面板文案统一走既有 CJK 图集,不新增字体/外部素材。
diff --git a/docs/api-reference/map-and-mapgen.md b/docs/api-reference/map-and-mapgen.md
new file mode 100644
index 0000000..5943967
--- /dev/null
+++ b/docs/api-reference/map-and-mapgen.md
@@ -0,0 +1,64 @@
+# 地图与生成(MapGen / ProceduralMap / MapSnapshot)
+
+源码:`src/LivingVillage.Desktop/MapGen.fs`、`src/LivingVillage.Desktop/ProceduralMap.fs`、
+`src/LivingVillage.Desktop/MapSnapshot.fs`。
+
+## 职责
+
+确定性生成可玩地图(河道 / 桥 / 石板路 / 民居 / 农田 / 主路),并把生成结果离线渲染为整图 PNG。
+`MapGen` 是默认可玩世界(256×192)与大图(512×384)的生成器;`ProceduralMap` 是遗留
+`LV_MAP_SCALE` 路径;`MapSnapshot` 只做纯 CPU 像素渲染,不参与游戏帧循环。
+
+## 对外 API 概览
+
+- `MapGen.defaultParams seed`:默认 64×48、`RiverCount = 2`、`RiverWidth = 3`、`CoreSide = 14`、
+ `SpawnColumns = 5`、`SpawnRows = 6`。
+- `MapGen.paramsForSize width height seed`:尺寸自适应;`large = width > 256 || height > 192`。
+ 大图追加 `ExtraRiversideHouses = 30`、`RiverDecorations = true`、`ClusterSeed = seed ^^^ 0xC1A57E2UL`,
+ 并关闭 `DecorativeStones`;非大图这些字段保持默认(不消费额外 RNG)。
+- `MapGen.generate params` / `MapGen.generateWithSize width height seed` → `MapGen.Result`。
+- `MapGen.serialize map`:确定性文本(同 seed 逐字节一致),用于回归/跨机核对。
+- `MapGen.tileAt map x y`、`MapGen.isWalkable map x y`、`MapGen.floodFill map x y`、
+ `MapGen.visibleTileRange`、`MapGen.visibleTileCount`。
+- `ProceduralMap.generate`:遗留大世界地形(512×384 路径)。
+- `MapSnapshot.renderRgb map scale` / `MapSnapshot.renderPng map scale` / `MapSnapshot.save path map scale`;
+ `defaultScale = 8`。
+
+## 单位与量纲
+
+- 地图尺寸以**瓦片**计(`Result.Width` / `Result.Height`);内存瓦片码 `Tiles: int array`(行优先)。
+- `GroundTile`:`Grass=0 / Water=1 / Stone=2 / Peat=3 / PaddyField=4 / VegetablePlot=5`。
+- `River = { CenterY; Width }`(每列一条);`Building = { Left; Top; Width; Height; DoorX; DoorY }`(瓦片)。
+- `MainRoadCenter: int array`(长度 = 宽,逐列主路中心 y);`Paths: (int*int) list`、`Bridges`。
+- `MapSnapshot`:输出像素 = `Width*scale × Height*scale`;8-bit RGB PNG(无 alpha)。
+
+## 不变量
+
+1. **种子确定性**:同 `(width, height, seed)` 两次 `generateWithSize` 的 `Tiles`、
+ `Rivers/Bridges/Paths/Buildings/Farmhouses/Groves/MainRoadCenter` 与 `serialize` 逐字节一致。
+2. **既有尺寸逐字节不变**:64×48 / 256×192 不进入大图分支、不消费额外 RNG(默认 stones 开启、
+ decorations 关闭、ClusterSeed = 0)。
+3. **河道连续**:每条河每列恰好一段,宽度 ≥ `RiverWidth`;桥面仍标 `Water` 但 `isWalkable` 为真。
+4. **可达性**:`Result.ReachableTiles / ReachabilityOk / BridgeCrossingsOk` 由 `floodFill` 判定;
+ 民居/农舍门贴可达地面。
+5. **大图农田**:`PaddyField`/`VegetablePlot` 只在陆地上、避开路径与核心矩形。
+6. **渲染纯函数**:`MapSnapshot` 不依赖图形设备与时钟;同输入 PNG 逐字节一致。
+
+## 失败分支
+
+- `paramsForSize` 对任意正整数尺寸不抛异常(`large` 判定决定分支)。
+- `MapSnapshot.renderRgb/renderPng` 对越界瓦片写入静默跳过(`fillTile` 有 `tx/ty` 范围检查);
+ `scale` 经 `max 1` 保护;`save` 的 IO 异常由调用方处理。
+- `MapGen.isWalkable` 对越界坐标返回 `false`。
+
+## 算法与性能取舍
+
+- 生成原语:`splitmix64`(无外部依赖、无时钟)+ `valueNoise` 多八度 + 泊松布点;
+ 大图再加聚落组团(`ClusterSeed`)与主路缓弯(相邻列步长 ≤1)。
+- 渲染:`MapSnapshot` 用扁平色块 + 简化几何(屋顶矩形、门、主路/小径)而非纹理,
+ 目的是**离线、可复跑、无 GPU 依赖**;PNG 用 BCL `ZLibStream` 压缩。
+- 实测(`docs/evidence/p68-analysis.txt`,512×384 / seed=4242 / scale=8 → 4096×3072):
+ 河 388,608px、水田 742,080px、菜畦 824,128px、主路 32,768px、小径 34,112px、屋顶 19,328px 均 > 0;
+ 无 24×24 纯黑块 / 64×64 纯白块;`missing_glyph_slots = 0`;确定性 sha256 = `DD23CC2D…0FBF`。
+- 复跑:`LV_P68_MAPSHOT=1 LV_RECORD_DIR=<dir> dotnet src/LivingVillage.Desktop/bin/Release/net8.0/LivingVillage.Desktop.dll`,
+ 再 `python3 scripts/analyze-p68.py <dir> docs/evidence/p68-analysis.txt`。
diff --git a/docs/api-reference/occupation.md b/docs/api-reference/occupation.md
new file mode 100644
index 0000000..071b337
--- /dev/null
+++ b/docs/api-reference/occupation.md
@@ -0,0 +1,60 @@
+# 职业系统(Occupation)
+
+源码:`src/LivingVillage.Kernel/Occupation.fs`(`module Occupation`)。
+设计依据:`docs/design/occupation-system-design.md`、`docs/design-professions.md`。
+
+## 职责
+
+玩家侧职业系统纯函数:职业档案、每日任务、任务完成判定与刷新、轻剧情线、背包侧车与存档 token。
+**不进入 `Sim.step` 数值路径**,也不给 NPC 加职业;职业状态由 Desktop `WorldBootstrap` 在开局注入。
+
+## 对外 API 概览
+
+- `type Kind = Farmer | Fisher | Peddler | Scholar`;`nameOf kind`(农夫/渔夫/货郎/书生)、
+ `saveToken kind`(`farmer`/`fisher`/`peddler`/`scholar`)、`all`。
+- `profileOf kind : Profile = { Kind; InitialMoney; InitialEnergy; InitialInventory }`。
+- 每日任务:`taskPoolOf kind`、`taskNameOf template`、`dailyTaskOf seed occupationSeed dayIndex kind`、
+ `dailyTaskOfWith targets seed occupationSeed dayIndex kind`(带定点)、
+ `acceptTask`、`applySignal`、`expireAt`、`satisfiesCompletion`、
+ `refreshState`、`refreshStateWith`、`refreshToday`、`refreshTodayWith`、`dayIndexOf tick`、
+ `taskHash seed occupationSeed dayIndex`。
+- 定点:`type TaskTargets = { Npcs: Sim.NpcId list; Tiles: (int*int) list }`、
+ `targetNpcOf`、`targetTileOf`。
+- 背包/报酬:`stateOf kind`、`backpackQuantity`、`grantItem`、`rewardOf template`、`itemNameOf item`。
+- 剧情线:`module Story`(`lineNameOf kind`、`stageNameOf stage` 等)。
+- `type State = { Profile; TaskToken: int; Today: DailyTask option; StoryStage: int;
+ Backpack: (Sim.ItemKind*int) list; Streak: int }`。
+
+## 单位与量纲
+
+- `dayIndex = tick / ticksPerDay`(模拟日);`OfferedTick = dayIndex * ticksPerDay`,
+ `DueTick = OfferedTick + ticksPerDay - 1`(当日有效)。
+- `TaskState = Offered | Active | Done | Failed`;`TaskToken` 为 v3 前缀占位扩展位(当前恒 0)。
+- `Backpack`:`(ItemKind*int) list`,数量 `int`;`Streak`:连续完成天数 `int >= 0`。
+- 随机源:`taskHash = splitmix(seed ^^^ occupationSeed, dayIndex+1)`,与 `Rng.fs` 同源,禁用 `System.Random`。
+
+## 不变量
+
+1. **无职业零影响**:`dailyTaskOf` / `refreshToday` / `refreshState`(空候选)与历史行为逐字节一致,
+ 目标恒 `None`;`Occupation` 不改变 `Sim` 状态结构。
+2. **状态机单向**:`acceptTask` 仅 `Offered → Active`;`applySignal` 仅 `Active` 命中才 `Done`;
+ `expireAt` 只把非终态在逾期后置 `Failed`;终态不可复活。
+3. **目标只影响读取它的模板**:`HelpWork` 读 `TargetNpc`、`TillSoil` 读 `TargetTile`;
+ 其余模板目标保持 `None`(不新增完成语义)。定点在候选集合内确定性选取。
+4. **候选注入**:Kernel 不感知地图/世界,候选由调用方(`WorldBootstrap.reachableTargets`)注入;
+ 空候选 → `None`(通配),不臆造目标。
+5. **序列化稳定**:`Backpack` 保持声明顺序;`Streak` 仅在 `> 0` 时写尾段(旧 v3 读回默认 0)。
+
+## 失败分支
+
+- 空任务池 / 空候选集合:`dailyTaskOf*` 返回 `None`、`targetNpcOf/targetTileOf` 返回 `None`(不抛异常)。
+- 未 `accept` 的任务不因信号完成;`Done/Failed` 终态对 `applySignal/expireAt/acceptTask` 幂等返回原值。
+- `grantItem` 对 `quantity <= 0` 原样返回;`backpackQuantity` 无物品返回 0。
+- `rewardOf` 仅两处有设计口径物品(夜捕 `Fish×2`、送粮 `Food×1`),其余返回 `None`(不发明奖励)。
+
+## 算法与性能取舍
+
+- 每日任务选择 = `taskHash seed occupationSeed dayIndex % pool.Length`;定点用同源另一段流
+ (`targetNpcSalt` / `targetTileSalt` 异或后再 hash),候选先排序以消除输入顺序影响。
+- 全部为纯函数、无分配敏感热路径(不在 `Sim.step` 内),成本相对 `Sim.step` 可忽略。
+- 存档 token:kind 用 `saveToken` 小写英文;任务模板/状态/物品各有稳定 token(见 `world-save.md`)。
diff --git a/docs/api-reference/rendering-desktop.md b/docs/api-reference/rendering-desktop.md
new file mode 100644
index 0000000..ca3a841
--- /dev/null
+++ b/docs/api-reference/rendering-desktop.md
@@ -0,0 +1,65 @@
+# 桌面渲染(Game / VillageArt / 字形图集)
+
+源码:`src/LivingVillage.Desktop/Game.fs`、`src/LivingVillage.Desktop/VillageArt.fs`、
+`src/LivingVillage.Desktop/CjkGlyphAtlas.fs`、`src/LivingVillage.Desktop/ChineseText.fs`、
+`src/LivingVillage.Desktop/HudLayout.fs`。
+
+## 职责
+
+MonoGame 渲染与主循环:把 `World` 画成江南水乡画面(地面/河道/建筑/人物/夜景光晕/室内),
+并绘制 HUD、对话浮层与各类面板。不负责模拟推进(`Sim.step` 在 Kernel)、不负责地图生成算法。
+
+## 对外 API 概览
+
+- `LivingVillageGame`(`Game` 子类):`override Update(gameTime)` / `override Draw(gameTime)`。
+ 默认 `IsFixedTimeStep = true`、`TargetElapsedTime = 1/60s`、垂直同步开启;
+ `LV_UNLOCK_FPS=1` 时放开两者以测渲染上限。
+- `VillageArt.loadTextures device` → `ArtTextures`;`VillageArt.drawWorld`、
+ `VillageArt.drawMapViewport` / `drawMapFitted` / `drawMapRegion`、`VillageArt.drawCharacter`、
+ `VillageArt.drawInterior` / `drawInteriorCharacter`、`VillageArt.drawNightGlows`、
+ `VillageArt.drawCc0BoatOverlay`、`VillageArt.drawGroundShadow`。
+- 动画:`VillageArt.characterFrame isMoving tick`、`npcSpriteSpec*`、`avatarSpriteSpec`、
+ `waterFrameTickAt tick x y`、`smokeFrameTick tick`。
+- 预乘与光晕:`VillageArt.premultiply r g b a`、`VillageArt.lanternGlowLayers`、
+ `VillageArt.ellipseRuns`。
+- 中文字形:`CjkGlyphAtlas.columns = 16`、`cellPixels = 24`、`contains char`、
+ `sourceRectangle char`、`load device`;文案清单 `ChineseText.requiredUiLabels`;
+ HUD 几何 `HudLayout.barHeight = 36`、`textScale = 2`。
+
+## 单位与量纲
+
+- 视口默认 1280×720;世界像素 = 瓦片 × `Sim.tilePixels`(32)。
+- 字形:每字一格 24×24 px,绘制时按 `PixelText` 的 `scale` 放大(HUD 常用 `scale = 2` → 每字 16px;
+ 图集源格固定 24px)。ASCII 走 `PixelText.glyphs` 位图,空格宽 `4*scale`,其余 ASCII 宽 `6*scale`。
+- 动画:字符格 32×48;`animationFrame = (tick/4) % 4`、`idleFrame = (tick/24) % 2`。
+
+## 不变量
+
+1. **预乘 alpha**:任何 `alpha < 255` 的前景绘制必须用 `VillageArt.premultiply`,
+ 否则预乘混合会把 rgb 直接加进帧缓冲导致纯白块(见 `docs/维护说明.md` §预乘 alpha)。
+2. **无缺字**:`PixelText.isRenderable` 要求每个字符 ∈ ASCII 位图 ∪ `CjkGlyphAtlas.contains`;
+ 不可渲染字符按设计**留空**,绝不画假字。
+3. **图集几何**:`CjkGlyphAtlas` 表保持有序稳定,`pixelWidth = 16*24 = 384`、
+ `pixelHeight = rowCount*24`;`load` 校验尺寸否则 `invalidOp`。
+4. **帧选择纯函数**:动画/水波/炊烟只由 `tick` 与「是否移动」决定,同 tick 必得同帧。
+5. **绘制分层**:`drawWorld` 末尾单独绘制炊烟;夜间光晕只在真实灯笼实体处产生。
+
+## 失败分支
+
+- `CjkGlyphAtlas.load`:文件缺失 → `FileNotFoundException`;尺寸不符 → `invalidOp` 并释放纹理。
+- 文本:`PixelText.draw` 对不支持字符静默跳过(推进光标),不抛异常。
+- 纹理加载:`VillageArt.loadTexture` 校验期望宽高,资产缺失/尺寸不符会失败(构建期资产必在)。
+
+## 算法与性能取舍
+
+- 绘制分层(`Game.DrawWorldView`):地面 → 结构/民居 → 夜间光晕 → 水面波纹/船 →
+ 角色阴影与角色 → 黄金时刻暖幕 → 月光幕 → 面板/浮层(`DrawM5Overlay`)。
+- 只绘制可见瓦片(`isVisible` 视口裁剪);光晕用预乘同心圆 pixel run 近似径向衰减。
+- 帧循环结构:`Update`(键盘/菜单/证据钩子/`Sim` 推进)→ `Draw`(世界或菜单/证据帧);
+ 证据钩子(`LV_*_SHOT`)在 Playing 帧后落盘并退出。
+- 实测(`docs/evidence/p70-analysis.txt`,1280×720 / Xvfb 软件渲染 / 解锁 vsync):
+ 60 秒 8,788 帧、`measured_fps ≈ 139.3`;帧间隔 mean 7.179 / max 33.919 / p95 9.847 ms;
+ 掉帧 > 25ms = 4(0.05%)、> 33.3ms = 2(0.02%);每帧分配 mean ≈ 231 KB / p95 297 KB。
+ 帧率随机器与软件渲染波动,只列本次实测,不外推。
+- 复跑:`P70_SECONDS=60 bash scripts/bench-p70.sh /tmp/opencode/lv-p70`
+ (逐帧日志经 `LV_FRAME_LOG=1`,仅日志、不改渲染逻辑)。
diff --git a/docs/api-reference/sim-kernel.md b/docs/api-reference/sim-kernel.md
new file mode 100644
index 0000000..8fd4d34
--- /dev/null
+++ b/docs/api-reference/sim-kernel.md
@@ -0,0 +1,82 @@
+# Sim(F# 模拟核心)
+
+源码:`src/LivingVillage.Kernel/Sim.fs`(`module Sim`,命名空间 `LivingVillage.Kernel`)。
+
+## 职责
+
+纯确定性模拟核心:维护 `World` 状态、按固定 `tick` 推进(`Sim.step`)、做对话/交易/谣言/需求决策。
+不负责渲染、输入采集、存档文本(后者在 `WorldSave.fs`)、职业档案(后者在 `Occupation.fs`)。
+
+## 对外 API 概览
+
+- 时间与量纲:`ticksPerSecond = 60L`、`secondsPerDay = 86400L`、`ticksPerDay = 5_184_000L`、
+ `dtSeconds = 1/60`、`dtSecondsF`。
+- 世界尺寸:`mapWidthTiles` / `mapHeightTiles`(默认 64×48)、`configureBounds width height`、
+ `tilePixels = 32`、`avatarSpeed = 160.0f`、`npcSpeed = 80.0f`、`arriveEpsilon = 2.0f`。
+- 构造:`initialWorld seed`、`initialWorldN seed count`(NPC 网格 6 列、间距 64px)。
+- 主推进:`step (ts: TimeStep) (world: World) : World`;`TimeStep = { Input: Input }`,
+ `Input = { MoveX; MoveY }`(分量建议在 `[-1, 1]`)。
+- 对话:`openDialogueMenu world`、`responseFor intent personality`、
+ `chooseDialogueWith respond target intent world`、`chooseDialogue target intent world`
+ (后者 = `chooseDialogueWith responseFor`,历史入口)。
+- 聊天/谣言:`chat { Narrator; Receiver } world`、`rumorStrengthAt now rumor`、
+ `trimRumors now rumors`、`rumorWorkingSetStats rumors`、`rumorPath world target`、
+ `rumorTraceText world`、`findChatPartner self pos npcs`、`chatableForChat npc`。
+- 交易/定价:`quotePrice request world`、`trade request world`。
+- 观察/年鉴:`observeVisible bounds world`、`annalText world`、`appendAnnal entry world`、
+ `needsPanel world`、`relationMatrix world`、`relationCounts matrix`。
+- 常量:`dialogueOptions = [SmallTalk; AskHelp; OfferTrade; Joke; Apologize; Provoke]`、
+ `chatRangePx = 96.0f`、`chatTicks = 300L`、`rumorCapacity = 16384`、`rumorRetentionDays = 3L`、
+ `rumorFreshnessTicks = 3 * ticksPerDay`、`rumorMinimumStrength = 0.125f`、
+ `relationHalfLifeTicks = ticksPerDay`、`playerId = NpcId -1`、`maxAnnalEntries = 128`。
+
+## 单位与量纲
+
+- `Tick: int64`(模拟 tick,从 0 起单调 +1);`Time: float`(= `tick * dtSeconds` 秒)。
+- `Vec2`:像素坐标,左上原点 `(0,0)`,右下 `(mapWidthTiles*32 - 32, mapHeightTiles*32 - 32)`。
+- `Needs`:四维均为 `float32`,语义取值域经 `needsClamp` 限制在 `[0, 100]`(Money 亦为 0..100 的口径)。
+- `Personality`:五维 `float32`,由 `personalityOfSeed` 生成,域 `[0, 1]`。
+- 价格 `unitPrice: float32`,货币单位与 `Needs.Money` 同尺度;`quotePrice` 结果被 clamp 到 `[0.01, 1000]`。
+- 谣言 `Strength: float32`(初始 1.0)、`Depth: int`(0 起)、`DayIndex = tick / ticksPerDay`。
+
+## 不变量
+
+1. **数组快照**:`World.Npcs` 在每个动作中要么原样引用、要么 `Array.copy` 后写新数组;
+ `stepNpc` 读取 `oldNpcs` 同时写 `newNpcs`,保证同一 tick 内每个 NPC 看到一致快照。
+2. **确定性**:`step` 每 tick 只推进一步 `Rng.nextUInt64`(`rngOut` 当前被 `ignore`,
+ 仅推进状态);所有派生(性格、谣言、动画)都是 `seed`/`tick` 的纯函数。
+3. **谣言行序**:工作集 `World.Rumors` 约定**最新在前**、`Tick` 非递增、头节点 `Id` 最大;
+ `nextRumorId` 依赖该头不变量做 O(1) 取号;裁剪只丢尾部旧条目。
+4. **派生镜像**:`RumorCount` / `RumorOldestDay` 是列表的派生量,不参与存档序列化,读档由列表重建。
+5. **年鉴上限**:`Annals` 经 `appendAnnal` 裁剪到 `maxAnnalEntries = 128`。
+6. **记忆双配额**:`recordMemory` 容量 64,且交互类记忆(`Chatted`/`Dialogue`)优先保留。
+7. **边界 clamp**:Avatar 位置在 `step` 中 clamp 到地图内;`Needs` 经 `needsClamp`。
+
+## 失败分支
+
+- **对话**:`chooseDialogueWith` 返回 `DialogueResult`——
+ `DialogueRejected(DialogueTargetNotFound, world)`(目标不在 `Npcs`)、
+ `DialogueRejected(DialogueTargetOutOfRange, world)`(距离平方 > `chatRangeSq`)、
+ `DialogueRejected(DialogueTargetUnavailable, world)`(目标在 `Sleep`/`Chat`);
+ 成功为 `DialogueSucceeded(outcome, world)`。
+- **聊天**:`chat` 返回 `ChatResult`——`ChatRejected(NarratorNotFound | ReceiverNotFound, world)`、
+ `ChatRejected(ChatSameParticipant, world)`、`ChatRejected(ParticipantNotChatable, world)`;
+ 重复谣言(同 narrator/receiver/parent 且新鲜可用)返回 `ChatSucceeded(None, world)`(只写记忆,不新增谣言)。
+- **交易**:`trade` 返回 `TradeResult`——`TradeRejected(BuyerNotFound | SellerNotFound | SameParticipant |
+ InvalidQuantity | OutOfStock | InsufficientFunds, world)`;`quotePrice` 对非法参与者/同人返回 `None`。
+- **异常**:`configureBounds` 本身不校验;调用方不得在推进世界期间改边界(边界每 tick 读取)。
+ 非法数值不会抛出,而是被 clamp。`WorldSave` 的解析错误在 `WorldSave` 侧处理。
+
+## 算法与性能取舍
+
+- NPC 决策:`scoreAction` 线性加权(需求缺口 × 性格因子),`decideAction` 取 `maxBy`;
+ 夜间 `Sleep` 乘 `nightSleepMultiplier = 3.0`。
+- 移动:`moveToward` 向量归一化 + `arriveEpsilon` 到达容差;无寻路,直线目标点。
+- 谣言扫描去装箱:`NpcId`/`RumorId` 是 `[<Struct>]` 单例 DU,直接用 `=` 会比较时装箱;
+ 热路径改用 `npcIdValue`/`rumorIdValue` 解构比较(语义等价、去掉逐条分配)。
+- 谣言裁剪:`appendRumor` 用派生镜像把「每次追加全表扫描」降为 O(1) 判定,
+ 仅在超容量/超保留窗口时 `trimRumors`(`takeWhile + truncate`)。
+- 实测(`docs/evidence/p70-kernel-baseline.txt`,seed=42 / npc=4 / warmup=120 / measure=6000 ×3):
+ `ticks_per_second` mean ≈ 466,679(min 278,557 / max 646,089);`allocated_bytes` ≈ 3,032,184;
+ GC `gen0/gen1/gen2 = 0/0/0`;`final_digest = 953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C`、`performance_determinism = PASS`。
+- 复跑:`bash scripts/bench-p70.sh`;护栏:Kernel.Tests `P70PerformanceBaselineTests` 钉住 digest。
diff --git a/docs/api-reference/world-save.md b/docs/api-reference/world-save.md
new file mode 100644
index 0000000..a8f474f
--- /dev/null
+++ b/docs/api-reference/world-save.md
@@ -0,0 +1,57 @@
+# 存读档(WorldSave)
+
+源码:`src/LivingVillage.Kernel/WorldSave.fs`(`module WorldSave`)。
+
+## 职责
+
+把 `Sim.World`(可选附 `Occupation.State`)序列化为稳定的 `|` 分隔文本并解析回来,
+兼容 v1/v2/v3 三种版式;不负责文件路径策略与 UI(由 Desktop 调用方决定)。
+
+## 对外 API 概览
+
+- `save world : string` = `saveWith None world`(无职业,产 **v2**,与历史格式逐字节一致)。
+- `saveWith occupation world : string`:`Some state` → **v3**(含职业段与可选尾段)。
+- `load text : Result<World, string>`、`loadFromFile path : Result<World, string>`。
+- `loadFromFileWith path : Result<World * Occupation.State option, string>`(读 v3 职业伴随数据)。
+- `saveToFile path world` / `saveToFileWith occupation path world`(UTF-8 无 BOM)。
+- 内部 token 读写:`writeDailyTask`(`task-today` 段)、`writeBackpack`(`backpack` 段)、
+ `writeAnnal`(年鉴,含 `story`)。
+
+## 单位与量纲
+
+- 版式头:`LV_WORLD_SAVE_V1`(无宽高,读回默认 64×48)、`LV_WORLD_SAVE_V2`(带 `width height`)、
+ `LV_WORLD_SAVE_V3`(带职业段)。文本以 `|` 分隔。
+- v3 职业段:`occupation.kind`(`farmer|fisher|peddler|scholar` 之一,未知 → `None`)、
+ `occupation.task`(int)、`occupation.stage`(int)。
+- 尾段(annals 之后,按需出现):`task-today`(模板/状态/`OfferedTick`/`DueTick`/
+ `npc-none|npc-some (+id)`/`tile-none|tile-some (+x,y)`)、`backpack`(`count` + 逐项
+ `item quantity`)、`streak`(仅 `> 0` 时)。
+- 任务模板 token:`deliver-grain/help-work/till-soil/night-catch/sell-fish/market-inquiry/
+ buy-goods/resell/observe-notes/reason-debate`;状态 token:`offered/active/done/failed`。
+
+## 不变量
+
+1. **v2 逐字节稳定**:`save world` 的输出在相同 `World` 下不变;`saveWith None world` 与 `save world`
+ 完全一致。
+2. **无职业 = 旧行为**:不带职业时 v2 序列化不产生任何 v3 专有段。
+3. **v1 向后兼容**:读到 `LV_WORLD_SAVE_V1` 时回退地图边界 64×48。
+4. **派生量不入档**:`RumorCount` / `RumorOldestDay` 不写;读档用 `rumorWorkingSetStats` 重建。
+5. **尾段可缺省**:旧 v3 无 `task-today`/`backpack`/`streak` 时,读回 `Today = None`、
+ `Backpack` 用 `profileOf` 默认值、`Streak = 0`。
+6. **完整消费**:解析后 `reader.Remaining = 0`,否则视为 `trailing save data`。
+
+## 失败分支
+
+- 头未知 → `Error "unsupported save format"`。
+- token 非法/缺字段/多余尾数据 → `SaveParseError`,归一为 `Error`。
+- 数值越界/格式错误 → 捕获 `FormatException` / `OverflowException` / `ArgumentException`,
+ 返回 `Error "invalid save: <message>"`;`null` 文本 → `Error "save text is null"`。
+- 文件读取失败 → `Error "could not read save: <message>"`(捕获 `IOException`)。
+- 背包数量 `< 0` → 解析失败(`invalid backpack[i].quantity`)。
+
+## 算法与性能取舍
+
+- 手写 token 序列化(`ResizeArray<string>` 拼接),无反射/无 JSON 依赖;浮点用
+ `CultureInfo.InvariantCulture` 格式以保证跨机一致。
+- 版本化采用「头 + 可选段」而非新格式文件:读端按头分派,v2 路径零额外开销。
+- 存档不是热路径;解析失败一律以 `Result` 返回,不抛出到调用方。