diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-19 00:18:43 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-19 00:18:43 +0800 |
| commit | 59458d149d7cecf73e708faa6d9e6db2bcd89dfd (patch) | |
| tree | af342c6a01766be326857f48ebe5c65529e50882 /src/LivingVillage.Kernel | |
| parent | b237a11908442a227361fae24dbdd0dbb287fac9 (diff) | |
| download | living-village-59458d149d7cecf73e708faa6d9e6db2bcd89dfd.tar.gz | |
feat: M1 模拟器内核 — SplitMix64 RNG + 确定性 step + 对拍测试 + headless 冒烟工具 + Desktop 自动演示
[变更性质]
本 commit 为功能开发,交付设计文档 §2 模拟器内核(里程碑 M1),非缺陷修复或结构重构。
[新增功能]
- Kernel:Rng.fs 提供 SplitMix64 纯函数 RNG(ofSeed / nextUInt64 / nextFloat32),World 携带种子状态。
- Kernel:Sim.fs 重写为 struct record(Vec2/Avatar/Input/TimeStep/World),step : World → TimeStep → World 纯函数,dt 固定 1/60 由内核常量给定,Time = Tick * dt 不做浮点累加;initialWorld 由无参改为带 seed。
- Tests:MSTest 确定性对拍——同 seed 轨迹逐 tick 相等、异 seed 发散、step 纯度、长跑有限/有界/时间-刻度关系、SplitMix64 已知向量、nextFloat32 值域。
- Headless:--days N --seed S 冒烟工具,每 1000 tick 输出摘要行,统计 NaN/越界计数并决定退出码。
- Desktop:LV_AUTOPLAY=1 环境变量启用正弦驱动自动演示,窗口标题与启动日志标注模式。
[实现方案]
- 全部状态用 [<Struct>] record,模拟路径零堆分配,支撑 51.84 亿刻长跑。
- step 不消费 World 内 RNG,种子经由外部确定性驱动脚本生效(每 900 刻换段,8 方向查表 + 三档幅值,避开三角函数保证跨平台位精确)。
- Headless 内存口径取 GC.GetTotalMemory(true)(全收集后的稳态驻留),避免 GetTotalMemory(false) 随 Gen0 段扩张漂移误报泄漏。
[影响范围]
- Kernel 保持零 MonoGame 引用,模拟与渲染彻底分离;sln 新增 Headless 与 Tests 两个项目。
- Release 构建 0 警告 0 错误,7/7 测试通过;1000 天长跑 5,184,000,000 刻退出码 0,无 NaN 无越界,内存 87,824 → 93,000 字节(1.06x < 2x)。
- 同 seed 两次运行 stdout 字节一致,seed 42/43 轨迹不同。
Diffstat (limited to 'src/LivingVillage.Kernel')
| -rw-r--r-- | src/LivingVillage.Kernel/LivingVillage.Kernel.fsproj | 1 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/Rng.fs | 24 | ||||
| -rw-r--r-- | src/LivingVillage.Kernel/Sim.fs | 40 |
3 files changed, 56 insertions, 9 deletions
diff --git a/src/LivingVillage.Kernel/LivingVillage.Kernel.fsproj b/src/LivingVillage.Kernel/LivingVillage.Kernel.fsproj index a8ed312..8a4fad1 100644 --- a/src/LivingVillage.Kernel/LivingVillage.Kernel.fsproj +++ b/src/LivingVillage.Kernel/LivingVillage.Kernel.fsproj @@ -5,6 +5,7 @@ </PropertyGroup> <ItemGroup> + <Compile Include="Rng.fs" /> <Compile Include="Sim.fs" /> </ItemGroup> diff --git a/src/LivingVillage.Kernel/Rng.fs b/src/LivingVillage.Kernel/Rng.fs new file mode 100644 index 0000000..9564b56 --- /dev/null +++ b/src/LivingVillage.Kernel/Rng.fs @@ -0,0 +1,24 @@ +namespace LivingVillage.Kernel + +[<Struct>] +type RngState = + { State: uint64 } + +module Rng = + + let goldenGamma = 0x9E3779B97F4A7C15UL + + let ofSeed (seed: uint64) : RngState = { State = seed } + + let nextUInt64 (rng: RngState) : uint64 * RngState = + let s = rng.State + goldenGamma + let mutable z = s + z <- (z ^^^ (z >>> 30)) * 0xBF58476D1CE4E5B9UL + z <- (z ^^^ (z >>> 27)) * 0x94D049BB133111EBUL + let out = z ^^^ (z >>> 31) + out, { State = s } + + let nextFloat32 (rng: RngState) : float32 * RngState = + let z, rng' = nextUInt64 rng + let bits = uint32 (z >>> 40) + float32 bits / 16777216.0f, rng' diff --git a/src/LivingVillage.Kernel/Sim.fs b/src/LivingVillage.Kernel/Sim.fs index d1b2e57..0e3683b 100644 --- a/src/LivingVillage.Kernel/Sim.fs +++ b/src/LivingVillage.Kernel/Sim.fs @@ -2,10 +2,12 @@ namespace LivingVillage.Kernel module Sim = + [<Struct>] type Vec2 = { X: float32 Y: float32 } + [<Struct>] type Avatar = { Pos: Vec2 } @@ -14,33 +16,53 @@ module Sim = MoveY: float32 } type TimeStep = - { Dt: float32 - Input: Input } + { Input: Input } + [<Struct>] + type NoHost = + { Reserved: uint64 } + + [<Struct>] type World = { Tick: int64 - Avatar: Avatar } + Time: float + Rng: RngState + Avatar: Avatar + NoHost: NoHost } + + let ticksPerSecond = 60L + let secondsPerDay = 86400L + let ticksPerDay = secondsPerDay * ticksPerSecond + let dtSeconds = 1.0 / float ticksPerSecond + let dtSecondsF = 1.0f / float32 ticksPerSecond let mapWidthTiles = 64 let mapHeightTiles = 48 let tilePixels = 32 let avatarSpeed = 160.0f - let initialWorld () : World = + let initialWorld (seed: uint64) : World = let centerX = float32 (mapWidthTiles * tilePixels / 2 - tilePixels / 2) let centerY = float32 (mapHeightTiles * tilePixels / 2 - tilePixels / 2) { Tick = 0L - Avatar = { Pos = { X = centerX; Y = centerY } } } + Time = 0.0 + Rng = Rng.ofSeed seed + Avatar = { Pos = { X = centerX; Y = centerY } } + NoHost = { Reserved = 0UL } } let clamp v lo hi = if v < lo then lo elif v > hi then hi else v let step (ts: TimeStep) (world: World) : World = + let tick = world.Tick + 1L let maxX = float32 (mapWidthTiles * tilePixels - tilePixels) let maxY = float32 (mapHeightTiles * tilePixels - tilePixels) - let dx = ts.Input.MoveX * avatarSpeed * ts.Dt - let dy = ts.Input.MoveY * avatarSpeed * ts.Dt - { Tick = world.Tick + 1L + let dx = ts.Input.MoveX * avatarSpeed * dtSecondsF + let dy = ts.Input.MoveY * avatarSpeed * dtSecondsF + { Tick = tick + Time = float tick * dtSeconds + Rng = world.Rng Avatar = { Pos = { X = clamp (world.Avatar.Pos.X + dx) 0.0f maxX - Y = clamp (world.Avatar.Pos.Y + dy) 0.0f maxY } } } + Y = clamp (world.Avatar.Pos.Y + dy) 0.0f maxY } } + NoHost = world.NoHost } |
