feat(pixelworld): add deterministic Noita-style simulation stack

This commit is contained in:
西风逍遥游 committed 2026-08-31 17:55:05 +08:00
1 parent 55ed73d4c1
commit 8ccfd5ecbe
75 files changed
+10429 -12

No files matched your search

+5
View File
@@ -93,6 +93,8 @@ option(EVENGINE_BUILD_HOST
"Build the interactive/native eve host (disabled by core/headless/server profiles)" ON)
option(EVENGINE_BUILD_PROFILE_CHECKS
"Build independent public-header and optional-capability profile checks" OFF)
option(EVENGINE_BUILD_PIXELWORLD_BENCHMARK
"Build the renderer-independent PixelWorld million-cell benchmark" OFF)
option(EVENGINE_BUILD_DEMO "Build optional Demo module (procedural demo assets + default demo.nut)" ON)
set(EVENGINE_THIRD_PARTY_BINARY_DIR "" CACHE PATH
"Use a prebuilt third-party install tree (include/lib/bin) without rebuilding or modifying it")
@@ -453,6 +455,9 @@ function(main)
endif()
add_subdirectory(src/engine)
add_subdirectory(src/modules)
if(EVENGINE_BUILD_PIXELWORLD_BENCHMARK)
add_subdirectory(benchmarks/pixelworld)
endif()
if(EVENGINE_BUILD_HOST)
add_subdirectory(src/scripts)
endif()
+16
View File
@@ -0,0 +1,16 @@
add_executable(pixelworld_benchmark
main.cpp
${CMAKE_SOURCE_DIR}/src/modules/pixelworld/PixelMaterial.cpp
${CMAKE_SOURCE_DIR}/src/modules/pixelworld/PixelMaterialCatalogCodec.cpp
${CMAKE_SOURCE_DIR}/src/modules/pixelworld/PixelWorld.cpp
${CMAKE_SOURCE_DIR}/src/modules/pixelworld/PixelWorldControl.cpp)
eve_thirdparty_libs(_pixelworld_benchmark_poco poco)
find_package(Threads REQUIRED)
target_link_libraries(pixelworld_benchmark PRIVATE
eve_engine_includes ${_pixelworld_benchmark_poco} Threads::Threads)
if(WIN32)
target_compile_definitions(pixelworld_benchmark PRIVATE
POCO_STATIC POCO_NO_AUTOMATIC_LIBS NOMINMAX)
endif()
+187
View File
@@ -0,0 +1,187 @@
#include "pixelworld/PixelWorld.h"
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cstddef>
#include <condition_variable>
#include <cstdint>
#include <cstdlib>
#include <functional>
#include <iostream>
#include <mutex>
#include <span>
#include <string_view>
#include <thread>
#include <vector>
namespace {
class BenchmarkScheduler final : public eve::pixelworld::PixelWorkScheduler {
public:
explicit BenchmarkScheduler(std::size_t workerCount)
: workerCount_(std::max<std::size_t>(1, workerCount)) {
workers_.reserve(workerCount_);
for (std::size_t index = 0; index < workerCount_; ++index)
workers_.emplace_back([this] { workerLoop(); });
}
~BenchmarkScheduler() override {
{
std::lock_guard lock(mutex_);
stopping_ = true;
++generation_;
}
wake_.notify_all();
}
void parallelFor(std::size_t workItems,
const std::function<void(std::size_t)>& body) override {
if (workItems == 0) return;
{
std::lock_guard lock(mutex_);
body_ = &body;
workItems_ = workItems;
next_.store(0, std::memory_order_relaxed);
remaining_ = workerCount_;
++generation_;
}
wake_.notify_all();
std::unique_lock lock(mutex_);
complete_.wait(lock, [this] { return remaining_ == 0; });
body_ = nullptr;
}
[[nodiscard]] std::size_t workerCount() const noexcept override { return workerCount_; }
private:
void workerLoop() {
std::size_t observedGeneration = 0;
for (;;) {
const std::function<void(std::size_t)>* body = nullptr;
std::size_t workItems = 0;
{
std::unique_lock lock(mutex_);
wake_.wait(lock, [this, &observedGeneration] {
return stopping_ || generation_ != observedGeneration;
});
if (stopping_) return;
observedGeneration = generation_;
body = body_;
workItems = workItems_;
}
for (;;) {
const std::size_t index = next_.fetch_add(1, std::memory_order_relaxed);
if (index >= workItems) break;
(*body)(index);
}
{
std::lock_guard lock(mutex_);
if (--remaining_ == 0) complete_.notify_one();
}
}
}
std::size_t workerCount_ = 1;
std::vector<std::jthread> workers_;
std::mutex mutex_;
std::condition_variable wake_;
std::condition_variable complete_;
const std::function<void(std::size_t)>* body_ = nullptr;
std::atomic<std::size_t> next_ = 0;
std::size_t workItems_ = 0;
std::size_t remaining_ = 0;
std::size_t generation_ = 0;
bool stopping_ = false;
};
std::size_t parsePositive(char* text, std::size_t fallback) {
if (text == nullptr) return fallback;
char* end = nullptr;
const unsigned long long value = std::strtoull(text, &end, 10);
return end != text && *end == '\0' && value > 0 ? std::size_t(value) : fallback;
}
std::uint64_t hashSnapshot(std::span<const std::byte> bytes) noexcept {
std::uint64_t hash = 1469598103934665603ULL;
for (const std::byte byte : bytes) {
hash ^= std::uint8_t(byte);
hash *= 1099511628211ULL;
}
return hash;
}
std::string_view benchmarkMaterial(std::string_view workload, int x, int y) noexcept {
if (workload == "mixed-reaction") return ((x + y) & 1) != 0 ? "fire" : "water";
if (workload == "reaction-only") return ((x + y) & 1) != 0 ? "acid" : "stone";
return "sand";
}
} // namespace
int main(int argc, char** argv) {
using namespace eve::pixelworld;
const std::size_t workers = parsePositive(argc > 1 ? argv[1] : nullptr, 4);
const std::size_t samples = parsePositive(argc > 2 ? argv[2] : nullptr, 10);
const std::string_view workload = argc > 3 ? std::string_view(argv[3]) : "sand";
const bool mixedReaction = workload == "mixed-reaction";
const bool reactionOnly = workload == "reaction-only";
const bool thermalOnly = workload == "thermal-only";
BenchmarkScheduler scheduler(workers);
std::vector<double> milliseconds;
milliseconds.reserve(samples);
std::uint64_t totalVisited = 0;
std::uint64_t totalMoved = 0;
std::uint64_t totalReactions = 0;
std::uint64_t totalThermalTransfers = 0;
std::uint64_t totalPhaseChanges = 0;
std::uint64_t snapshotHash = 0;
for (std::size_t sample = 0; sample < samples; ++sample) {
PixelWorld world(7003);
for (int y = 0; y < 1024; ++y)
for (int x = 0; x < 1024; ++x) {
if (thermalOnly)
world.setCell(x, y, {MaterialId::Stone,
std::int16_t(((x + y) & 1) != 0 ? 700 : 20), 0});
else
world.setMaterial(x, y, benchmarkMaterial(workload, x, y));
}
const auto started = std::chrono::steady_clock::now();
const StepStats stats = world.advanceScheduled(eve::SimulationTick(1), scheduler)
.expect("million-mobile-pixel scheduled tick");
milliseconds.push_back(std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - started).count());
totalVisited += stats.cellsVisited;
totalMoved += stats.cellsMoved;
totalReactions += stats.reactions;
totalThermalTransfers += stats.temperatureTransfers;
totalPhaseChanges += stats.phaseChanges;
const auto snapshot = world.saveSnapshot().expect("million-mobile-pixel snapshot");
const std::uint64_t sampleHash = hashSnapshot(snapshot);
if (sample == 0)
snapshotHash = sampleHash;
else if (sampleHash != snapshotHash) {
std::cerr << "non-deterministic snapshot at sample " << sample << '\n';
return 2;
}
}
std::sort(milliseconds.begin(), milliseconds.end());
const double p50 = milliseconds[milliseconds.size() / 2];
const std::size_t p95Index = std::min(milliseconds.size() - 1,
(milliseconds.size() * 95 + 99) / 100 - 1);
std::cout << "PIXELWORLD_ACTIVE_BENCHMARK_JSON={\"occupiedPixels\":1048576,\"mobilePixels\":"
<< (thermalOnly ? 0 : mixedReaction || reactionOnly ? 524288 : 1048576)
<< ",\"chunks\":256,\"workload\":\""
<< (mixedReaction ? "checkerboard-fire-water"
: reactionOnly ? "checkerboard-acid-stone"
: thermalOnly ? "checkerboard-hot-cold-stone" : "packed-sand-over-empty")
<< "\",\"samples\":"
<< samples << ",\"workers\":" << workers << ",\"visited\":" << totalVisited
<< ",\"moved\":" << totalMoved << ",\"snapshotHash\":" << snapshotHash
<< ",\"reactions\":" << totalReactions
<< ",\"thermalTransfers\":" << totalThermalTransfers
<< ",\"phaseChanges\":" << totalPhaseChanges
<< ",\"p50Ms\":" << p50
<< ",\"p95Ms\":" << milliseconds[p95Index] << "}\n";
return 0;
}
@@ -73,6 +73,8 @@ eve_declare_module(NAME spatial LAYER 0 SCRIPT Spatial SLOT spatial
# 与渲染解耦;examples/crowd 演示渲染由游戏脚本自行完成)。
eve_declare_module(NAME crowd LAYER 0 SCRIPT Crowd SLOT crowd
GROUP 2d 3d web)
eve_declare_module(NAME pixelworld LAYER 0 SCRIPT PixelWorldModule SLOT pixelworld
GROUP 2d 3d web)
eve_declare_module(NAME ik LIB EVIK LAYER 0 SCRIPT IK
GROUP 2d 3d web)
# L6 -- editor orchestration
@@ -86,6 +88,18 @@ eve_declare_module(NAME plugins LAYER 0 SCRIPT Plugins
eve_declare_module(NAME database LAYER 0 SCRIPT Database
THIRDPARTY poco_data poco)
# L1 -- gameplay domain adapter
eve_declare_module(NAME pixelworld_thread LAYER 1
DEPS pixelworld thread
GROUP 2d 3d web)
eve_declare_module(NAME pixelworld_replay LAYER 1
DEPS pixelworld
GROUP 2d 3d web)
eve_declare_module(NAME pixelworld_streaming LAYER 1
DEPS asset pixelworld
GROUP 2d 3d web)
eve_declare_module(NAME pixelworld_editor LAYER 6 SCRIPT PixelWorldEditorModule SLOT pixelworldEditor
DEPS pixelworld ui
GROUP 2d 3d web)
eve_declare_module(NAME rpg LIB EVRPG LAYER 1 SCRIPT RPG
DEPS action attributes decision definitions effects inventory settlement)
# L0 -- foundation (continued)
@@ -14,6 +14,10 @@ eve_declare_module(NAME asset_graphics LAYER 4
DEPS asset graphics
GROUP minimal 2d 3d web)
eve_declare_module(NAME pixelworld_graphics LAYER 4 SCRIPT PixelWorldGraphics SLOT pixelworldGraphics
DEPS graphics pixelworld
GROUP 2d 3d web)
eve_declare_module(NAME camera LAYER 4 SCRIPT Camera SLOT camera
DEPS platform_event graphics scene
GROUP minimal 2d 3d web)
@@ -48,6 +52,9 @@ eve_declare_module(NAME weapon LAYER 4 SCRIPT Weapon SLOT weapon
DEPS action attributes effects transaction definitions
GROUP 2d 3d)
# L5 -- vehicle adapter
eve_declare_module(NAME pixelworld_physics LAYER 5
DEPS pixelworld physics
GROUP 2d)
# Optional editing satellite. Runtime-only profiles can enable physics without
# pulling editing/editor contracts or AssetDB adapters.
eve_declare_module(NAME physics_editing LAYER 5
+2 -2
View File
@@ -124,7 +124,7 @@ function(eve_resolve_modules)
"Core boundary selected by the active profile" FORCE)
elseif(EVENGINE_PROFILE STREQUAL "headless")
set(_hostless_profile TRUE)
set(_profile_seed common data event timer)
set(_profile_seed common data event timer pixelworld)
set(EVENGINE_PROFILE_CORE_KIND headless CACHE INTERNAL
"Core boundary selected by the active profile" FORCE)
elseif(EVENGINE_PROFILE STREQUAL "server")
@@ -132,7 +132,7 @@ function(eve_resolve_modules)
set(_profile_seed
common data event timer network authority decision definitions effects
game_event orders schema social statepatch steering tags transaction
economy attributes sensing spatial action settlement tactics)
economy attributes sensing spatial action settlement tactics pixelworld)
set(EVENGINE_PROFILE_CORE_KIND server CACHE INTERNAL
"Core boundary selected by the active profile" FORCE)
else()
+4
View File
@@ -118,6 +118,10 @@ set(_doc_module_pages
"mouse.md|mod_mouse|鼠标模块"
"network.md|mod_network|网络模块"
"particles.md|mod_particles|粒子模块"
"pixelworld.md|mod_pixelworld|像素物质世界模块"
"pixelworld_replay.md|mod_pixelworld_replay|PixelWorld 确定性重放模块"
"pixelworld_streaming.md|mod_pixelworld_streaming|PixelWorld 兴趣区 Streaming 模块"
"pixelworld_editor.md|mod_pixelworld_editor|PixelWorld Catalog 编辑器模块"
"physics.md|mod_physics|物理模块(Box2D + Box3D + 布料 + 流体)"
"plugins.md|mod_plugins|原生插件模块"
"procgen.md|mod_procgen|程序化生成模块"
+210
View File
@@ -0,0 +1,210 @@
# PixelWorld / Noita 技术路线
状态:实施中。本文定义“完整 Noita 技术路线”的能力边界和验收证据,当前原型不等于完成。
## 总体契约
- `PixelWorld` 是材料、温度、寿命和结构连通性的唯一权威 owner;渲染、物理、音频、网络只消费版本化投影。
- CPU 参考后端在相同 seed、输入命令流和 `SimulationTick` 下 bit-exact。并行 CPU 后端必须与参考后端 bit-exact;GPU 仿真若引入则明确标为 tolerance-bounded,不能静默替换回放后端。
- 64×64 Chunk 是调度、睡眠、脏区、存档、网络和 GPU 上传的共同粒度。跨 Chunk 写入必须唤醒邻域并参与同一 revision。
- 游戏脚本和未知回调只在 Tick 边界调用,不在 Chunk 锁内调用。Worker 仅处理 owning candidate buffer,提交按确定性 Chunk 顺序完成。
- 持久格式使用 `eve.pixelworld` schema/version;未知新版本拒绝,迁移和恢复失败不改变可观察世界。
## P0:确定性材料核心(已具备纵切,继续扩展)
- 稀疏 Chunk、负坐标、固定 Tick、命名 seed、粉末/液体/气体/固体/能量状态。
- 活跃/休眠 Chunk、跨边界唤醒、单 Tick 防重复更新。
- 权威 revision 和 `snapshotChangedChunks()` owning 增量投影。
- 数据驱动 `MaterialCatalog`:密度、黏度、导热率、热容量、燃点、熔/沸点、腐蚀、寿命、颜色和标签;加载时 schema 验证,运行中只用紧凑 ID。
- 数据驱动反应表:输入材料、邻域条件、温度/概率门限、产物、热量和事件;规则冲突按稳定 priority/id 排序。
验收:跨平台 golden snapshot;10k Tick 重放 hash 一致;边界交换物质守恒;未知材料/规则以结构化诊断拒绝。
## P1:热力学、化学与破坏
- 定点温度扩散、热源/冷源、点燃、熔化、冻结、蒸发、凝结。
- 油、木、火药、酸、熔岩、冰、烟等代表性链路;反应产物和放热守恒可诊断。
- 圆刷、射线、爆炸、腐蚀和布尔 stamp 统一为 Tick 边界 `PixelEditCommand`,支持命令日志、undo/replay。
- 静态材料连通域失支撑后生成碎片候选;碎片光栅从世界原子移除,失败不留下半提交状态。
验收:每条反应有正向/边界/失败测试;爆炸回放确定;大面积编辑只唤醒覆盖 Chunk 与一圈邻域。
当前结构破坏纵切:`extractUnsupportedFragments()` 在显式有限区域内以四邻域扫描
Solid,连接支撑线或区域外 Solid 的分量继续由世界拥有;浮空分量按左上顺序生成
owning `PixelFragment` bitmap,并在同一提交中从世界移除。`rasterizeFragment()` 先
验证 runtime world/epoch link、bitmap 和所有目标格,再一次提交;冲突、restore 或
clear 后的 stale link 均不产生部分写入。Physics adapter 与动态轮廓仍属于 P2。
## P2:刚体碎片与角色碰撞
- `PixelWorld` 产出轮廓/碎片 owning artifact,经 capability adapter 交给 Physics;Physics 不回写第二份材料真源。
- Marching Squares/轮廓简化生成静态碰撞;按 dirty revision 增量重建。
- 刚体碎片保存材料 bitmap 和 PixelWorld Link;碎片破裂或休眠时事务化栅格化回世界。
- 角色使用探针/扫掠碰撞和材料接触查询,避免每像素创建刚体或 ECS entity。
验收:世界先销毁、刚体先销毁、restore/hot reload 两种顺序均无 stale 引用;碰撞重建预算可观测。
当前动态碎片纵切:可选 L5 `pixelworld_physics` 把 owning `PixelFragment` 以确定性
top-left 贪心矩形分解为受显式 budget 限制的 Box2D fixtures。适配器跨帧只保存
`PhysicsLink`,不保存 World/Body 裸指针;休眠时将 bitmap snap 到最近 90°,先请求
PixelWorld 全量事务回填,成功后才销毁刚体。目标冲突会保留刚体,physics world
先销毁、body 先释放和 PixelWorld restore stale 三条生命周期路径均有测试。静态 terrain
现以带一格邻域采样的二值边界轮廓、共线简化及 Box2D chain/loop fixture 按 revision 增量
重建;边界修改会同时重建已投影的相邻 Chunk,避免残留 seam。角色圆形 probe 与连续
sweep 直接查询权威材料,带候选预算、确定性 tie-break 和精确圆角测试,不逐像素创建刚体。
## P3:规模化调度
- Chunk phase/color 调度,把无写冲突 Chunk 分批并行;跨边界写入 staging buffer,按 canonical 顺序提交。
- 活跃矩形、睡眠迟滞、空 Chunk 回收、世界 streaming 和相机兴趣区。
- SoA/packed cell 热数据,冷属性与事件分离;SIMD 温度/邻域计算。
- 性能预算:桌面 CPU 参考目标 1M 活跃像素 60 Hz,常规场景按活跃比例降本;基准必须报告硬件、活跃像素、反应数和 p50/p95。
验收:单线程/并行 snapshot hash 一致;TSAN;百万像素 benchmark;越界 Chunk streaming 压测。
当前 P3 纵切:L0 `PixelWorkScheduler` 只允许同步执行 index-owned candidate slots,
L1 `pixelworld_thread` 使用 JobSystem fork/join。热传导从同一 post-movement 只读状态
并行生成 owning contribution buffers,再按 canonical Chunk/坐标提交;4 worker 与参考
后端连续 100 Tick 的完整 version-3 snapshot 字节一致。Chunk 经过 3 Tick 无写迟滞才
睡眠,空 Chunk 回收会发布 revisioned tombstone,使渲染和静态碰撞删除旧投影。
运动阶段同样由 worker 对只读 Chunk 生成 index-owned、行序稳定的候选列表,再按原有
Chunk/行/列 canonical 顺序提交;提交时重新读取源状态并保留下落、两侧对角线及液体
横移的原 fallback 顺序,因此跨 Chunk 冲突结果不依赖 worker 调度。
Chunk 写入路径增量维护持久化 phase metadata(非空/可移动计数、每材质计数、温度范围;
温度极值被覆盖时延迟重算),每 Tick 只投影紧凑摘要:
无可移动材质时跳过运动,世界不存在可配对反应物时跳过反应,均匀且邻域同温时跳过
热传导且不创建空 worker task,phase transition 仅扫描同时含候选材质且温度范围可能
越过阈值的 Chunk。反应优化使用世界级快照判定,
不使用遍历中可能过期的逐 Chunk 邻域结论,以保持跨 Chunk 反应传播语义。
当前真实性能边界:AMD Ryzen 7 5700X、Debug、4 worker,1024×1024 均匀 stone、
256 个每样本强制唤醒的 Chunk、10 样本,优化前 p50 2760.26 ms / p95 2807.64 ms,
初始保守 phase summary 后 p50 319.325 ms / p95 326.232 ms;改为持久化 metadata、
温度阈值筛选并消除空 thermal worker 后 p50 0.909 ms / p95 1.756 ms。该工作负载
是 1,048,576 occupied pixels 但不是 1M 移动物质;它证明静态占用已低于 16.7 ms,
并不证明 1M active 目标。移动/反应负载仍需继续改为 packed Chunk hot data、细粒度
activity mask、无冲突运动候选和 SIMD。不得用睡眠空转或均匀固体 fast-path 宣称
1M active 达标。
移动压力基线:同机 Debug、4 worker,初始 1024×1024 packed sand(1,048,576 个
mobile pixels)连续 5 Tick,累计 visited 3,858,432、moved 777,142;并行候选初版
p50 1190.67 ms / p95 1246.21 ms,改用 Chunk 内直接扫描并在提交中复用 source 后
p50 784.636 ms / p95 906.146 ms;进一步按源 Chunk 批处理、同 Chunk 直接交换 cell
slot 且仅在边界唤醒邻居后,p50 253.206 ms / p95 259.187 ms。该结果明确证明真正
mobile 工作负载的逐像素候选随后改为每 Chunk 64×64 位 packed row mask,提交阶段以
count-leading/trailing-zero 保持原左右 canonical 次序;Catalog 同时维护事务热重载时重建的
material-state/位移矩阵,避免每次尝试重复解释 density/state。Clang Debug、4 worker、同一
5 Tick 工作负载两次无并发复测为 p50 169.540/170.316 ms、p95 175.814/176.425 ms,snapshot
和 moved/visited 计数保持不变。独立 Clang Release 构建曾在进入仿真前崩于
`MaterialCatalog::builtIn()`:根因是 `Result<T>` 的私有观察 token 受 `ZEROERR_NO_ASSERT`
控制对象布局,而 Release 库与保留断言的测试目标采用不同宏,形成跨目标 ODR/ABI 冲突。
观察 token 现保持固定布局,裁剪 Release 探针已验证 13 项 built-in catalog 与稳定指纹可正常
跨宏边界返回。`EVENGINE_BUILD_PIXELWORLD_BENCHMARK=ON` 现提供 renderer-independent 的
`pixelworld_benchmark [workers] [samples]`,每个计时样本都重新创建 1024×1024 packed sand,
保证每 Tick 确实访问 1,048,576 个 mobile pixels,初始化和 snapshot hash 不计入 Tick 时间。
AMD Ryzen 7 5700X、Clang 21.1.8 Release、4 worker 的两次 10 样本结果为
p50 11.078/13.055 ms、p95 12.821/14.753 ms,累计 visited 10,485,760、moved 976,960、
reactions 0;1/4 worker 产生相同 snapshot hash `5924589683504813959`。这证明隔离的纯移动
压力纵切已达到 16.7 ms 参考预算,但不代表热、相变和高反应密度同时开启的完整仿真已达标。
系统缺少 `g++` 且自动 sudo 无法安装,GCC 结果仍未验证。下一瓶颈转向 canonical reaction
commit、热传导 SIMD 和混合材质压力,需要颜色批次或确定性目标仲裁继续拆分。
同一 benchmark 还提供 `reaction-only`(acid/stone checkerboard)和 `mixed-reaction`
(fire/water checkerboard)模式。Catalog runtime cache 现为每个有序材质对保存 canonical
规则索引序列,热重载时原子重建;这避免每个邻居重复遍历全部规则,同时仍允许高优先级
温度规则不匹配后落到低优先级规则。4 worker、Release 的纯反应 5 样本从
p50 59.675 / p95 61.510 ms 降到 p50 45.805 / p95 49.319 ms;随后同 Chunk 反应读取和
metadata 写入改为直接 slot,跨 Chunk/可能创建 Air 的自定义规则仍走安全稀疏路径并在插入后
重取引用,进一步降至 p50 30.141 / p95 30.544 ms。反应数 2,615,830、snapshot hash
`6722933230463575247` 始终不变。热阶段随后把每次传热的 heap contribution 和百万项
坐标哈希/排序替换为每源 Chunk 的 local/right/bottom 固定 delta buffer,再归并到目标
Chunk 的 4096 项稠密 delta;计算预取 own/right/bottom Chunk 和 Catalog 热容/导热率表,
提交按互斥 Chunk 并行并跳过已归一化 cell 的重复除法。fire/water 混合压力由
p50 223.694 / p95 247.968 ms 降至稳定约 p50 90 ms / p95 98 ms(5 样本、
10,449,965 thermal transfers),snapshot hash `16111782154737202544` 不变。独立
hot/cold stone 热压力约 p50 36.1 / p95 38.0 ms;完整混合仿真仍未达到 16.7 ms,
不能用纯移动达标替代该目标。
## P4:渲染、视觉和 GPU 上传
- 每个变化 Chunk 一次紧凑材质/温度纹理上传,禁止逐像素脚本调用和逐像素 draw call。
- Chunk atlas/array texture、调色板 LUT、温度发光、火焰/烟雾扰动、法线/液面边缘和屏幕后处理。
- CPU readback 只用于 MCP snapshot/诊断;渲染缓存携带 source revision,可检测落后和重建。
- 粒子 VFX 是可丢失投影,不反向成为材料权威状态。
验收:MCP `eve_screenshot` 来自引擎 framebuffer;渲染 revision 追上世界 revision;1M 像素上传与绘制不产生 1M draw call。`pixelworld_graphics` 已将脚本侧绘制降为一张图集/一次 draw,并按 chunk revision 更新 CPU 图集;Vulkan/WebGPU 均实现原纹理子区域上传,不再为动态像素重建 GPU 纹理。Vulkan 批量接口把多个 dirty region 紧密打包进一次 staging/command submission;百万像素测试覆盖 256 个可见 Chunk,随后两个相距很远的修改只提交 2 个 region。真实桌面 MCP framebuffer 证据为 `/tmp/pixelworld_multiregion_mcp.png`,Validation 未报告 VUID。
## P5:世界生成、存档、回放和网络
- 确定性 biome/洞穴生成、材料 stamp、兴趣区 streaming、Chunk 压缩与增量存档。
- Snapshot v2 使用公共 `SnapshotEnvelope`、内容 hash、Chunk 压缩、N/N-1 migration 和损坏隔离。
- 输入命令日志 + 周期 checkpoint;分歧定位到 Tick/Chunk/revision。
- 联机默认同步命令与权威 Chunk 修正,不承诺把每个像素作为独立复制对象。
验收:旧存档迁移、截断/未知字段/坏 hash 不部分恢复;长回放 hash;断线重连 Chunk 校正。
当前 `pixelworld_replay` 已实现严格 sequence/Tick 命令日志、周期 world/Chunk
checkpoint、空世界位精确重放和首个 Tick/Chunk/revision 分歧报告。replay
codec 使用公共 `SnapshotEnvelope` 的 `pixelworld:replay-log` version 2,完整验证
hash 后事务化解码,接受缺少 Chunk 诊断的 version 1;坏 hash、截断、未知字段和
未知新版本不部分修改旧日志。`pixelworld_streaming` 已实现有界 Chunk 兴趣区、revision
增量、离区 tombstone、source epoch 全量重同步,以及带 Catalog/seed/revision/Tick/
edit-sequence 校验的事务化权威 Chunk correction;断线后增量校正已有组合测试。
`pixelworld:chunk-batch` SnapshotEnvelope version 3 对每个 Chunk 独立使用 none/zstd、
decoded-content hash 和有界解码,接受 version 1/2 legacy cell 并将缺失热能 residual 迁移为
0;坏外层/Chunk hash 均在 authority 写入前拒绝。可靠会话已实现有界发送/接收窗口、
单 Chunk 分片、丢包重传、乱序缓冲、冲突重复包拒绝、整 transfer 原子提交和累计应用层
ACK;发送端在 ACK 前保留 owning correction/tombstone,窗口或分片预算失败不推进 cursor。
该状态机可承载于 `UdpLink::Reliable`/可靠 RPC,拥塞、认证和加密继续由宿主网络层负责。
确定性世界生成纵切现由 `generatePixelWorld` 提供:version 1 有界请求使用全局整数坐标
hash 和插值地表生成 forest/desert/fungal/volcanic biome、无 Chunk 接缝洞穴、水穴与熔岩
特征,并在地形之后应用可跨 Chunk 的 owning row-major material stamps。生成器不持有或
修改世界,只返回 canonical `PixelChunkBatch`、逐 Chunk 统计和稳定 content hash;调用方
通过现有 `applyChunkBatch` 的 fingerprint/seed/revision/Tick/edit-sequence 校验事务式提交。
测试证明同 seed 重复生成 bit-exact、单 Chunk 与大区域中的同坐标 Chunk 完全一致、换 seed
改变 hash、跨 Chunk stamp 正确分片,未知 schema 在 authority 外即被拒绝且世界 snapshot 不变。
`eve.pixelworld.generation-request` version 1 JSON codec 已持久化全部生成参数与 owning stamp
cells;seed 以 canonical 十进制 uint64 字符串保存,避免 JSON number 精度丢失。解码严格拒绝
未知/缺失字段、未知版本、窄整数溢出、尺寸不匹配和超预算 stamp,并且不会接触 live world。
## P6:创作与诊断工具链
- 材料/反应 schema 编辑器、实时校验、调色板预览、热更新事务。
- Chunk 活跃度、睡眠、温度、反应、脏 revision、上传延迟和模拟预算 overlay。
- MCP:查询材料/区域、提交编辑命令、暂停/单步、snapshot/restore、性能采样和引擎 framebuffer 截图。
- 自动场景:沙水守恒、火灾、爆炸、酸蚀、碎片、百万像素压力与存档迁移。
验收:UI/CLI/MCP 复用同一领域操作;无绕过 authority 的调试写入口;每个自动场景同时产出数值断言和引擎截图证据。
当前进度:共享 `PixelWorldControlService`、权威暂停/显式单步、严格序列编辑、有界
Chunk 活跃度/温度诊断、事务化 snapshot/restore 已实现;MCP 通过模块中立的
`IPixelWorldAutomation` capability 暴露对应工具。每世界 256 条有界性能历史和可切换
Chunk/温度/耗时 overlay 已接入示例。真实桌面 MCP 已查询 live world、Chunk diagnostics
和 samples,并通过 `eve_screenshot` 生成 `/tmp/pixelworld_p6_mcp.png`(960x576);未使用
Xvfb。材料 Catalog version 1 JSON codec、颜色/标签权威数据、validate/canonicalize MCP 工具,
以及 paused + optimistic fingerprint 的事务化 live reload 已完成;规则变更会推进 epoch 并
显式使旧 replay history 失效。`pixelworld_editor` 现提供独立的事务式 Catalog draft、材料
浏览/颜色预览/完整属性编辑、二元反应和相变规则增删改,以及命名 UI host 的
open/close/refresh 生命周期;脚本 `pixelworldEditor.openCatalog()` 是生产入口,示例把 UI
pass 合入最终帧。面板通过共享 control service 发布,不保存 world 指针或绕过 authority。
真实桌面引擎 MCP 已确认 `eve_pixelworld_catalog` 的 94 节点树,并由 `eve_screenshot`
生成 `/tmp/pixelworld_catalog_editor_mcp.png`(960x576);未使用 Xvfb。
## 当前完成边界
已完成的是 P0 的稀疏确定性纵切、经验证的 owning `MaterialCatalog`、确定性
`MaterialReactionRule`、带 Catalog fingerprint、edit sequence、热能 residual 和 v1/v2/v3
读取迁移的 version 4 snapshot、
Chunk 增量投影和可交互示例。P1 已有固定阶段的整数热扩散、数据驱动
`MaterialPhaseRule`、水/冰/蒸汽、石头/熔岩、酸蚀、火药与淬火代表链路,以及
严格 sequenced 的绘制/加热/爆炸 `PixelEditCommand` 与事务 receipt。
P1 还具备失支撑 Solid 连通域的事务化提取和带 epoch stale detection 的 bitmap
回填;P4 已具备单 atlas draw、按 dirty Chunk 的多 region GPU 增量上传和百万像素
覆盖测试;P2 已有动态碎片 Physics adapter、fixture budget、休眠回填和双销毁顺序
测试。P3 已有并行热候选、bit-exact 对比、睡眠迟滞、空 Chunk tombstone 与真实性能
基线,但完整并行运动/反应、SIMD 和 1M active/60 Hz 尚未达到。P5 已有确定性命令日志、
checkpoint、首个分歧定位、兴趣区 streaming 和事务化权威 Chunk 校正。材料 Catalog 外部
schema JSON 加载与事务热重载、静态轮廓碰撞、角色 probe/sweep、整数热能 residual 守恒,
以及带应用提交 ACK/tombstone 保留的可靠 Chunk transport 状态机、专用可视 Catalog 编辑
面板均已完成。路线仍未完整交付:P3 的完整并行反应/SIMD 与高密度热+反应 1M/60 Hz
尚未达到;P5 已有 biome/洞穴/stamp 确定性纵切和生成请求持久化 codec,但增量按需生成
调度与生成器接入示例/编辑器仍需完成,不能据现有演示宣称完整路线已交付。
+4
View File
@@ -39,6 +39,10 @@
* @subpage mod_physics
* @subpage mod_map
* @subpage mod_particles
* @subpage mod_pixelworld
* @subpage mod_pixelworld_replay
* @subpage mod_pixelworld_streaming
* @subpage mod_pixelworld_editor
* @subpage mod_animation
* @subpage mod_rpg
* @subpage mod_inventory
+4
View File
@@ -40,6 +40,10 @@
- [物理(Box2D / Box3D)](modules/physics.md):2D World/Body/Fixture(像素坐标 + meter)与 3D World3D/Body3D/Shape3D(米);接触事件与 `rayCast` / `queryAABB` / `testPoint` 拾取查询。
- [Tilemap](modules/map.md):创建或载入 TileLayer,设置瓦片、投影、图层并提交渲染。
- [粒子](modules/particles.md):用代码或 JSON 创建发射器,配置运动、颜色、寿命并进行更新和渲染。
- [像素物质世界](modules/pixelworld.md):64×64 分块的确定性沙、水、火焰与可破坏二维材料仿真。
- [PixelWorld 确定性重放](modules/pixelworld_replay.md):命令日志、周期 checkpoint 与首个 Tick/Chunk 分歧定位。
- [PixelWorld 兴趣区 Streaming](modules/pixelworld_streaming.md):Chunk 兴趣区、增量更新、驱逐 tombstone 与权威校正。
- [PixelWorld Catalog 编辑器](modules/pixelworld_editor.md):可挂载的材质浏览、属性、反应与相变规则事务式编辑面板。
- [动画](modules/animation.md):Tween 补间、3D 骨骼播放(状态机 / Motion Matching)、控制论程序动画(`ControlAnim` / `ControlPose`)、以及拖尾轨迹(`AnimTrail`)。
- [攀爬与跑酷](modules/climbing.md):前方障碍/顶部落点探测、确定性动作选择,以及受 Physics 胶囊体约束的 vault / mantle 执行。
- [RPG 系统](modules/rpg.md):组合属性、效果、状态、技能、施法与伤害结算。
+6
View File
@@ -166,6 +166,12 @@ Agent 可直接决定是否继续纠正;无效期望会在写入前拒绝。
重新上传到由当前 Graphics 后端创建的已有纹理;纹理对象保持不变,可继续被材质引用。
该方法必须在渲染线程调用,后端所有权、格式或尺寸不匹配时抛出异常。
C++ 渲染适配器可调用 `updateTextureRegion(texture,x,y,width,height,rgba,bytesPerRow)`
更新单 mip RGBA8 纹理的子矩形。Vulkan 使用 staging buffer 和原图 `copyBufferToImage`,
WebGPU 使用带 origin 的 `WriteTexture`;两者都不重建 Texture、采样器或描述符。
多个离散矩形应使用 `updateTextureRegions(texture, regions)`:它在任何写入前验证整个
批次;Vulkan 将所有 region 紧密打包进一个 staging buffer 并只提交一次。
下列方法名来自当前 Squirrel 绑定;同一模块创建的辅助对象(例如 `World`、`Body`、`Source`)的方法也列在这里。
- `bakeMeshMorph()`、`newMeshFromArrays()`、`updateMeshVertices()`、`clear()`、`clearMorphWeights()`、`declareFloat()`、`declareMatrix()`、`declareVec2()`、`declareVec3()`、`declareVec4()`
+146
View File
@@ -0,0 +1,146 @@
# 像素物质世界(PixelWorld)
`pixelworld` 是与渲染解耦的二维 falling-material 仿真模块。每个像素保存权威材料状态,世界按 64×64 chunk 稀疏分配,并在固定 tick 下确定性推进。它适合制作可挖掘地形、沙、水、油、火焰和蒸汽等 Noita 风格交互。
```squirrel
local pixels = eve.PixelWorldModule();
local world = pixels.newWorld(42);
world.paintCircle(80, 10, 8, "sand");
world.paintCircle(100, 10, 8, "water");
world.setMaterial(80, 70, "stone");
eve_update = function(dt) {
world.step(); // 一次调用严格推进一个整数 simulation tick
};
```
内置材料名为 `air`、`stone`、`sand`、`water`、`oil`、`fire`、`steam`、
`wood`、`ice`、`lava`、`smoke`、`gunpowder` 和 `acid`。当前规则覆盖粉末
下落、不同密度流体交换、液体横向流动、气体上升、燃烧、酸蚀、熔岩淬火
以及水/冰/蒸汽和石头/熔岩相变。
底层不再以分支硬编码这些定义。`MaterialCatalog::create()` 接受 owning
`MaterialDefinition`、`MaterialReactionRule` 和相变规则表,事务化验证连续 ID、唯一名称、
正热容量、颜色、规范化标签、规则 ID 和全部材料引用,再按 `priority/id` 固化规则顺序。每个世界
拥有自己的不可变 Catalog;未知材料通过 `setMaterialChecked()` /
`paintCircleChecked()` 返回结构化 `NotFound`,不会静默写成空气。
材料资产使用 JSON schema `eve.pixelworld.material-catalog` version 1。文档完整携带材料
状态、物理/热参数、`displayRgba`、标签、二元反应和相变规则;读取会拒绝未知/缺失字段、
错误类型、窄整数溢出、超预算数组和悬空材料引用,并返回 canonical JSON 与确定性
fingerprint。颜色和标签由 Catalog 唯一拥有,渲染器不再维护硬编码颜色副本。
每 Tick 的 CPU 参考顺序固定为:材料运动、定点热传导、相变、邻域化学反应、
气体/能量上升。热传导先从同一 pre-phase 状态计算 owning delta,再按坐标排序
提交,因此不依赖 `unordered_map` 迭代顺序。`StepStats` 分别报告
`temperatureTransfers`、`phaseChanges`、`reactions`、`parallelTasks`、
`chunksReclaimed` 和 `cellsChanged`。
Chunk 连续 3 个 Tick 未被写入后才睡眠。完全为空的睡眠 Chunk 会被回收,之后
`snapshotChangedChunks()` 返回 `removed=true`、空 cells 的 revisioned tombstone;
增量渲染、碰撞和网络消费者必须用它删除旧投影。
C++ 可向 `advanceScheduled()` 传入同步 `PixelWorkScheduler`。运动候选和热传导
owning delta 按 Chunk 并行计算;热 delta 完成确定性归并后,各 worker 只提交自己拥有的
Chunk,统计按 canonical index 归并。可选 `pixelworld_thread` 提供 JobSystem adapter。
它不改变 seed/tick 的 bit-exact 契约。
可选 `pixelworld_replay` 记录按 Tick 排序的 canonical edit command 和周期 checkpoint,
可在空世界中位精确重放,并报告首个 Tick/Chunk 分歧。它只拥有输入与摘要,不拥有
第二份可变地形;详见 [PixelWorld 确定性重放](pixelworld_replay.md)。
可选 `pixelworld_streaming` 按有界 Chunk 兴趣区产生增量 owning batch。
`applyChunkBatch()` 是存档/网络权威校正的 canonical 写入口,同时验证 Catalog、seed、
revision、Tick、edit sequence 和 Chunk 内容,失败不部分修改世界。详见
[PixelWorld 兴趣区 Streaming](pixelworld_streaming.md)。
`generatePixelWorld()` 提供 C++ 的 version 1 确定性世界生成入口。请求包含 seed、有限
Chunk 区域、地表振幅、水位、洞穴阈值和 owning material stamps;输出 forest/desert/
fungal/volcanic biome、洞穴与材料特征组成的 canonical `PixelChunkBatch`、逐 Chunk 统计和
稳定 content hash。生成器不修改世界;将 batch 交给 `applyChunkBatch()` 才会通过现有
Catalog/seed/revision/Tick/edit-sequence 检查事务提交。噪声只使用全局整数坐标,因此
单独生成一个 Chunk 与在大区域中生成相同 Chunk 的 cells 完全相同,跨 Chunk stamp 也不会
产生接缝。未知 schema、超大区域、无效 stamp 和不兼容 Catalog 在生成前拒绝。
`encodePixelWorldGenerationRequestJson()` / `decodePixelWorldGenerationRequestJson()` 使用
`eve.pixelworld.generation-request` version 1 保存与恢复请求;未知字段和版本严格拒绝,
64 位 seed 不经浮点 JSON number,解码结果完整拥有 stamp cells 且不修改 live world。
PixelWorld 是材料状态的唯一 owner。模块不逐像素创建 ECS entity,也不依赖 Graphics 或 Physics;渲染器、碰撞轮廓和刚体切割应作为只消费查询/快照的 adapter 接入。C++ 的 `advance(SimulationTick)`、`saveSnapshot()` 和 `restoreSnapshot()` 返回必须检查的 `Result`;恢复会先完整验证候选世界,失败不改变现有状态。
`PixelWorldControlService` 是编辑器、CLI 与 MCP 共用的 main-thread-affine 控制面。每个
世界在构造时自动注册,析构时注销,move 会原子重绑定借用指针;控制面不会保留已销毁
世界。暂停状态属于 PixelWorld 权威状态,因此普通 `advance()`、`advanceScheduled()` 和
`step()` 都不能绕过暂停;显式工具单步调用同一私有推进实现,并限制为每次 1–1024 Tick。
控制面还提供严格序列编辑、有界 Chunk 诊断和事务化 snapshot capture/restore。
启用 DevTools/MCP 时可用 `eve_pixelworld_worlds`、`eve_pixelworld_pause`、
`eve_pixelworld_step`、`eve_pixelworld_edit`、`eve_pixelworld_diagnostics`、
`eve_pixelworld_samples`、`eve_pixelworld_snapshot_capture`、
`eve_pixelworld_snapshot_restore`、`eve_pixelworld_catalog_builtin`、
`eve_pixelworld_catalog_validate` 和 `eve_pixelworld_catalog_apply`。每个世界只保留最近 256 条 Tick 性能样本,查询上限
同样为 256,世界析构时同步清除。MCP 只通过公共
`IPixelWorldAutomation` capability 转发到上述控制面,不直接持有或修改世界。
Catalog authoring 采用“导出内置模板 → 编辑 → validate/canonicalize → pause → apply”的
事务流程。实时 apply 使用当前 fingerprint 做乐观并发检查,并要求材料数量、ID 和名称稳定,
因此已有 cell 的语义不会漂移;成功会重建全部 Chunk 元数据、推进 world epoch 并明确报告
`replayHistoryInvalidated=true`。验证失败、指纹过期、不兼容或未暂停都不会部分修改世界。
增加、删除或重命名材料需要创建新世界或显式迁移存档,不属于 live reload。
当前 snapshot schema 为 `eve.pixelworld` version 4。v2 起写入材料 Catalog 的确定性
fingerprint,恢复到不同 Catalog 时事务化拒绝,避免相同数值 ID 被解释成不同材料。
v3 额外保存最后接受的 edit sequence;v4 为每个 cell 保存规范化热能残差。内置 Catalog
可读取 version 1/2/3;自定义 Catalog 拒绝缺少 fingerprint 的 v1 数据,v1/2 迁移后的
edit sequence 为 0,v1/2/3 迁移后的热能残差为 0。
未知版本、尾随和截断数据均拒绝且不改变现有世界。
热扩散以整数能量而非分别截断两个温度增量。每个非空气 cell 的权威能量为
`temperature * heatCapacity + thermalRemainder`,其中 residual 始终位于
`[0, heatCapacity)`;每对邻居提交大小相反的能量候选,因此在未触及 int16 温度边界时
严格守恒。`StepStats::thermalEnergyTransferred` 和 `thermalEnergyClamped` 分别报告传递量
与温度表示范围造成的饱和损失。Snapshot、Chunk correction、archive 和 replay digest
均包含 residual,避免恢复或联机校正后产生亚温度级确定性漂移。
`applyEdit(PixelEditCommand)` 是绘制、加热和爆炸的 canonical C++ 写入口。命令
sequence 必须严格等于上一条 + 1;半径、坐标、材料和爆炸强度在任何写入前完整
验证。成功返回 `PixelEditReceipt`,包含 revision 前后值和修改/移除/加热像素数;
拒绝不会改变 cell、revision 或 sequence。爆炸使用材料 `blastResistance` 和整数
径向衰减,未被移除的像素仍可接收径向热量。
## 结构碎片
`extractUnsupportedFragments(region, supportY, minimumCells)` 是结构脱落的 canonical
simulation-thread 操作。它只扫描显式的 inclusive 有限区域,保留连接 `supportY`
或区域外 Solid 的连通分量,并将浮空分量原子转换为 owning `PixelFragment` bitmap。
`rasterizeFragment(fragment, originX, originY)` 是全有或全无提交。来自其他世界、被
`clear()`/snapshot restore 失效的 fragment、错误 bitmap、坐标溢出或任一目标格被占用
都会返回结构化失败且不写入。Physics/gameplay 可以持有 bitmap,但不得将其当作第二份
可变地形权威状态。
## API 快查
- 模块:`newWorld()`。
- 世界写入与推进:兼容 facade `setMaterial()`、`paintCircle()`、`step()`、`clear()`;
C++ 新代码使用带结构化失败的 `setMaterialChecked()`、`paintCircleChecked()` 和 `advance()`。
- 世界查询:`getMaterial()`、`getSeed()`、`getRevision()`、`getTick()`、`getChunkCount()`、`getActiveChunkCount()`。
- 脚本增量诊断:`getChangedChunkCountSince(revision)` 返回该 revision 之后需要重新投影的 Chunk 数。
- 脚本破坏 facade:`explode(x,y,radius,strength,heat)` 自动分配下一 sequence,并返回移除像素数;`getLastEditSequence()` 可用于诊断。
- 可选 `pixelworld_graphics` 卫星模块提供 `newRenderer(originX,originY,width,height)`;
`renderer.sync(world,gfx)` 仅转换 revision 更新后的 64x64 chunk,并维护一张 RGBA8
最近邻图集。渲染时对 `renderer.getTexture()` 调用一次 `drawTexturedRect()`,避免逐像素
Squirrel 查询和 draw call。dirty Chunk 通过批量 texture-region API 增量上传;Vulkan
将同帧 regions 合并为一次 staging buffer 和 command submission。
- `renderer.drawDiagnostics(world,gfx,scale)` 绘制 active/sleep/最高温度 Chunk 边框和
最近 60 Tick 耗时图;它只消费有界诊断与性能样本,不持有新的材料状态副本。
- C++ 增量投影:`snapshotChangedChunks(sinceRevision)` 按确定性顺序返回 owning Chunk
副本,用于渲染纹理上传、碰撞轮廓重建和网络增量;投影不会成为第二权威状态。
二进制 snapshot API 当前仅向 C++ 开放;脚本侧后续应通过统一 `ByteData` Result 投影接入,而不是增加 `lastError` 兼容入口。
示例:[`examples/pixelworld`](../../../examples/pixelworld/)。
源码:[`src/modules/pixelworld/`](../../../src/modules/pixelworld/)。测试:
[`test/pixelworld.cpp`](../../../test/pixelworld.cpp) 与
[`test/pixelworld_control.cpp`](../../../test/pixelworld_control.cpp)、
[`test/pixelworld_generation.cpp`](../../../test/pixelworld_generation.cpp)。
+25
View File
@@ -0,0 +1,25 @@
# PixelWorld 材料与反应编辑器
`pixelworld_editor` 提供专用的 `PixelWorldCatalogPanel`。它把材料浏览器、颜色预览、
物态与热学参数、标签、二元反应和相变规则组织成通用 `ui::WidgetDesc` 视觉树,可由
桌面 ImGui UI、游戏内编辑器或自动化 host 挂载。
Panel 只拥有经过完整验证的 Catalog draft,不保存 `PixelWorld` 指针,也不成为运行时
材料状态的第二权威来源。材料或规则修改会复制完整 candidate,经
`MaterialCatalog::create()` 验证后一次替换;失败保持 draft、revision 和选择状态不变。
JSON 导入导出复用 `pixelworld:material-catalog` 的 canonical codec。
发布时调用 `apply()`,它通过 `PixelWorldControlService`、world id 和预期 Catalog
fingerprint 进入已有的暂停世界事务热重载路径。世界不存在、未暂停、fingerprint stale、
材料 id/name 不兼容或规则无效都会以结构化 `Result` 拒绝,不会部分修改世界。
调用 `open()` 会创建或复用名为 `eve_pixelworld_catalog` 的生产 UI host,并立即挂载视觉
树;`close()` 只隐藏 host,不丢弃 draft。返回的视觉树包含借用 Panel 的交互 callback,
因此 Panel 析构时会清空并隐藏 host。交互回调接受一次 edit 后自动 reconcile 最新内容;
外部批量修改也可显式调用 `refresh()`。
脚本侧的 `pixelworldEditor.openCatalog()`、`closeCatalog()` 与 `isCatalogOpen()` 提供应用
入口;`examples/pixelworld` 默认打开该面板,便于边运行材料仿真边编辑 Catalog draft。
源码:[`src/modules/pixelworld_editor/`](../../../src/modules/pixelworld_editor/)。
测试:[`test/pixelworld_editor.cpp`](../../../test/pixelworld_editor.cpp)。
+29
View File
@@ -0,0 +1,29 @@
# PixelWorld Graphics
`pixelworld_graphics` 是可裁剪的 PixelWorld 渲染适配层。它依赖 PixelWorld 与 Graphics,
但 PixelWorld 核心不反向依赖渲染模块。
```squirrel
local renderer = pixelworldGraphics.newRenderer(0, 0, 1024, 1024);
eve_render = function() {
gfx.clear();
renderer.sync(world, gfx);
gfx.drawTexturedRect(renderer.getTexture(), 0.0, 0.0, 1024.0, 1024.0,
1.0, 1.0, 1.0, 1.0);
};
```
`sync()` 必须在渲染线程调用,参数只在调用期间借用。它返回本次写入 CPU 图集的脏
chunk 数;revision 未变化时返回 0 且不上传。同一帧所有脏 chunk 作为离散 region
批量写入原 GPU 纹理:Vulkan 只分配一个 staging buffer、录制一个 command buffer 并
提交一次,不会上传 chunk 间的空白包围区域,也不重建纹理、采样器或描述符。
`getTexture()` 返回 Graphics 拥有的借用
句柄,不得比 Graphics 活得更久。
`drawDiagnostics(world, gfx, scale)` 在材料图集上叠加 active/sleep/最高温度 Chunk
边框,并在右上角绘制最近 60 Tick 的耗时柱状图。参数仅在调用期间借用,返回实际绘制
的已分配 Chunk 数;该投影不拥有或修改 PixelWorld 状态。
诊断接口:`getOriginX()`、`getOriginY()`、`getWidth()`、`getHeight()`、
`getRenderedRevision()`、`getTotalUploadedChunks()` 与 `getUploadCount()`。
+35
View File
@@ -0,0 +1,35 @@
# PixelWorld Physics
`pixelworld_physics` is an optional L5 composition module. It converts an owning
`PixelFragment` detached by PixelWorld into one Box2D dynamic body without making
Physics a second source of live terrain.
`PixelFragmentBody::create()` performs deterministic top-left greedy rectangle
decomposition, creating one fixture per maximal rectangle under an explicit fixture
budget. The adapter retains the detached bitmap and a generation-qualified
`PhysicsLink`; it never retains `World*` or `Body*` across calls.
`settleIfSleeping()` resolves the link, leaves awake bodies unchanged, and snaps a
sleeping bitmap to the nearest quarter turn. It first asks PixelWorld to rasterize the
entire bitmap transactionally and destroys the physics body only after that succeeds.
Occupied terrain, stale PixelWorld epochs, or stale physics handles therefore leave
the body/bitmap ownership intact. `releasePhysics()` explicitly destroys a live body
without returning its bitmap to terrain.
`PixelTerrainCollisionCache` is the static projection. `sync()` consumes only owning
dirty-Chunk snapshots newer than its represented revision and stages every replacement
body before changing the live cache. Static terrain uses deterministic binary boundary
contours with collinear simplification and Box2D chain/loop fixtures, rather than filled
per-pixel rectangles. Extraction samples a one-cell authoritative neighbor halo, suppresses
internal Chunk seams, and rebuilds an already-projected cardinal neighbor when a dirty border
can invalidate its contour. Every Chunk has explicit contour/vertex budgets. A failed candidate
destroys all staged bodies and preserves existing cache entries; PixelWorld restore, Catalog
epoch change, or a different Physics world forces a full rebuild without dereferencing stale
pointers.
Character controllers can call `probeTerrainCircle()` for current penetration/material contact
and `sweepTerrainCircle()` for continuous movement. Both query PixelWorld directly, ignore
non-solid materials, enforce candidate-cell budgets and use deterministic cell tie-breaking.
Sweep tests straight faces and rounded cell corners, so diagonal corner motion does not inherit
the false positives of a merely expanded AABB. These queries return owning value receipts and
retain no PixelWorld or Physics pointer across calls.
+24
View File
@@ -0,0 +1,24 @@
# PixelWorld 确定性重放
`pixelworld_replay` 是可裁剪的 L1 重放适配模块。它依赖 `pixelworld`,保存按
simulation tick 排序的 canonical edit command,并周期性记录世界与各 Chunk 的
位精确摘要;它不复制或拥有可变地形状态。
`PixelReplayLog::append()` 要求 edit sequence 严格连续、tick 不倒退。
`captureCheckpoint()` 从同步借用的世界生成 checkpoint,不保留世界引用。
`replay()` 只接受空目标世界,并在每个目标 tick 推进前提交对应命令。返回的
`PixelReplayReceipt` 区分完整匹配与数据分歧;发生分歧时包含首个错误 tick、revision
以及首个不一致 Chunk 的摘要,便于 replay、网络校正和存档诊断。
所有 API 均为 simulation-thread affine,不执行回调。日志是输入和诊断投影,不是
第二份 world authority。当前摘要用于确定性一致性验证而非密码学认证。
`snapshot(instanceId, hashProvider)` 生成公共 `SnapshotEnvelope`,type 为
`pixelworld.replay-log`,schema 为 `pixelworld:replay-log`,当前 version 2。内容
hash 必须由宿主注入,模块不默认选择弱 hash。`restoreSnapshot()` 在解析前
验证 envelope,使用有界 candidate 解码后一次性替换;坏 hash、截断、未知字段、
非 canonical Chunk 顺序和未知新版本都不会部分修改旧日志。version 1 可迁移到
version 2,其 checkpoint 保留 world digest,但没有当时尚未编码的每 Chunk 诊断。
源码:[`src/modules/pixelworld_replay/`](../../../src/modules/pixelworld_replay/)。
测试:[`test/pixelworld_replay.cpp`](../../../test/pixelworld_replay.cpp)。
+37
View File
@@ -0,0 +1,37 @@
# PixelWorld 兴趣区 Streaming
`pixelworld_streaming` 是可裁剪的 L1 投影模块。`PixelChunkStreamCursor` 按 Chunk 坐标
兴趣区捕获新进入、发生 revision 变化和离开兴趣区的 Chunk;离开区域使用 tombstone
通知消费方释放投影。Cursor 只拥有 source link、上次 revision 与已知坐标,不保留
`PixelWorld` 指针,也不成为材料状态的第二权威来源。
每个 `PixelChunkBatch` 携带材料 Catalog fingerprint、世界 seed、权威 revision、
simulation Tick 和最后 edit sequence。`PixelWorld::applyChunkBatch()` 使用
`expectedRevision` 拒绝 stale writer,先完整验证唯一 canonical y/x 顺序、固定 64×64
cell 数量、材料 ID 和单调 metadata,再一次性提交全部替换与 tombstone。失败不修改世界;
成功会推进 world epoch,使旧结构碎片 link 失效。
兴趣区上限为 65536 个 Chunk 坐标。source epoch 改变会强制 cursor 全量重同步。
该批次是 transport-neutral 的 owning 数据,可用于增量存档或网络消息。source epoch
变化产生的 `fullResync` 批次会原子替换消费方投影,因此允许权威 revision/Tick 回退,
同时清除旧 epoch 中未显式列出的 Chunk;普通增量批次仍拒绝 metadata 回退。
`ReliablePixelChunkSender`/`ReliablePixelChunkReceiver` 在字节传输之上提供应用层可靠会话:
每个 transfer 默认按单 Chunk 分片,发送端采用有界 in-flight 窗口并保留 owning payload
和 tombstone,直到收到证明整批已提交的累计 ACK;接收端有界缓存乱序 part,只在下一
连续 transfer 完整、metadata 一致且 `applyChunkBatch()` 成功后推进 ACK。重复 part/ACK
幂等,冲突重复包和越过发送窗口的 ACK 会被拒绝,窗口或分片预算失败也不会推进 capture
cursor。调用方可把 part 与 ACK 编码后放入 `UdpLink::Reliable`/可靠 RPC;UDP 的包级 ACK
不能替代这里的应用提交 ACK。拥塞控制、身份认证与会话加密仍属于宿主网络层。
`archiveChunkBatch()` 使用公共 `SnapshotEnvelope`,type 为 `pixelworld.chunk-batch`、
schema 为 `pixelworld:chunk-batch`,当前 version 3。每个 present Chunk 独立编码为固定
little-endian cell bytes,可选择 `None` 或 zstd level 3,并保存独立 decoded-content hash;
外层 envelope hash 同时认证 metadata、目录和压缩 payload。`decodeChunkBatchArchive()`
先验证外层,再按 512 MiB 总解码预算逐 Chunk 解压和验 hash,只返回 owning candidate,
不会写世界。version 3 同时持久化 `fullResync`,cell payload 包含热能 residual;
version 1/2 的 5-byte legacy cell
仍可迁移,缺失 residual 恢复为 0,version 1 缺失的 `sourceLastEditSequence` 也恢复为 0。
源码:[`src/modules/pixelworld_streaming/`](../../../src/modules/pixelworld_streaming/)。
测试:[`test/pixelworld_streaming.cpp`](../../../test/pixelworld_streaming.cpp)。
+12
View File
@@ -0,0 +1,12 @@
# PixelWorld Thread Scheduler
`pixelworld_thread` is an optional L1 adapter between the L0 PixelWorld candidate
scheduler contract and the engine JobSystem. `JobSystemPixelScheduler` borrows a
running JobSystem, submits one synchronous fork/join range, and returns only after
all workers have finished.
The current scheduled backend parallelizes thermal contribution generation. Each
worker reads the same immutable post-movement phase and writes only one Chunk-owned
buffer. PixelWorld then merges buffers in canonical Chunk/coordinate order. A worker
failure triggers a serial overwrite of every result slot, preserving reference-backend
semantics without exposing partial candidates.
+1
View File
@@ -36,6 +36,7 @@ make run/<platform>-debug GAME=examples/<name>
| [galgame](galgame/README.md) | 完整视觉小说《潮汐电台》:原创美术、分支结局、AUTO/SKIP/回想与存读档 |
| [building](building/README.md) | 建筑放置:地形约束、道路邻接、鬼影预览、旋转拆除 |
| [building-tilemap](building-tilemap/README.md) | 在等距 / 六角 tilemap 上放置建筑(2D 精灵穿插) |
| [pixelworld](pixelworld/README.md) | Noita 风格的分块沙、水、油、火焰与蒸汽像素物质仿真 |
| [iso-grid-walk](iso-grid-walk/README.md) | 独立 2.5D PNG 经可插拔 pipeline 生成 TileSet,方格移动与 A* |
| [dynamic-water-grid](dynamic-water-grid/README.md) | 双网格岸线、逐格守恒水量、等距连续水面 Shader 与素材替换契约 |
| [metroidvania](metroidvania/README.md) | 物理驱动的横版动作游戏:连击、蹬墙跳、空中冲刺、Boss |
+20
View File
@@ -0,0 +1,20 @@
# PixelWorld
一个可直接运行的 Noita 风格二维材料仿真切片:沙与水按密度流动,火焰点燃木材/油并把水转成蒸汽。世界逻辑使用固定 tick,渲染只是对权威材料网格的投影。
```sh
cd examples/pixelworld
../../build/linux-debug/src/engine/eve run
```
操作:
- 鼠标左键:绘制当前材料;右键:挖除地形;中键:点火。
- `1`–`9`:选择沙、水、油、木材、石头、火焰、酸液、火药、熔岩。
- `-` / `+`:调整笔刷半径。
- `F`:在油池附近点火;`O`:投放油;`R`:重置场景。
- `E`:在鼠标位置触发带径向热量的确定性爆炸。
- `D`:切换 Chunk 活跃/休眠/温度边框和右上角最近 60 Tick 耗时图。
地形和动态材料共享同一权威像素网格。挖掘会直接将目标材料替换为
`air`,不存在与材料状态可能失同步的第二套破坏遮罩。
+7
View File
@@ -0,0 +1,7 @@
config = {
width = 960
height = 576
title = "EVEngine PixelWorld"
debug = true
hotReload = true
};
+1
View File
@@ -0,0 +1 @@
{"native":{"anim":[],"card":{"cards":[]},"dialogue":[{"characters":[],"choices":[],"currentLineId":"","fullText":"","globalVars":{},"lipSyncAmplitude":0.8500000238418579,"lipSyncEnabled":true,"lipSyncParameter":"mouthOpen","phase":"idle","rngState":2654435769,"sceneVars":{},"selectedChoiceId":"","slotX":{},"speakerId":"","typeSpeed":40,"typed":0}],"rpg":[]},"roots":{"PI":3.1415927410125732,"RAND_MAX":2147483647,"_async":{"continuations":[],"draining":false,"eventWaits":[],"generation":2,"maxDrain":1000,"nextId":1,"nextTicks":[],"taskWaits":[],"timers":[],"waits":[]},"_charsize_":1,"_floatsize_":4,"_frame_count":7384,"_input_edge_state":{"k_1":false,"k_2":false,"k_3":false,"k_4":false,"k_5":false,"k_6":false,"k_7":false,"k_8":false,"k_9":false,"k_d":false,"k_e":false,"k_equals":false,"k_f":false,"k_minus":false,"k_o":false,"k_plus":false,"k_r":false},"_intsize_":8,"_startup_first_frame":true,"_startup_first_present":true,"_startup_t0":0.5241100192070007,"_versionnumber_":310,"accumulator":0.015962159261107445,"brushMaterial":"sand","brushRadius":4,"eve_module_contract":[{"classes":[],"deps":[],"enabled":true,"layer":-1,"name":"common","optionalDeps":[],"profiles":[],"required":true,"slots":[]},{"classes":[],"deps":["filesystem"],"enabled":true,"layer":-1,"name":"cmdline","optionalDeps":[],"profiles":[],"required":true,"slots":[]},{"classes":[],"deps":["platform_event"],"enabled":true,"layer":-1,"name":"devtools","optionalDeps":[],"profiles":["3d"],"required":false,"slots":[]},{"classes":["Math"],"deps":[],"enabled":true,"layer":0,"name":"math","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":["math"]},{"classes":[],"deps":[],"enabled":true,"layer":0,"name":"grid","optionalDeps":[],"profiles":["2d","3d","web"],"required":false,"slots":[]},{"classes":[],"deps":[],"enabled":true,"layer":0,"name":"property_access","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":[]},{"classes":[],"deps":["property_access"],"enabled":true,"layer":1,"name":"scriptmodel","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":[]},{"classes":[],"deps":["property_access","schema","transaction"],"enabled":true,"layer":1,"name":"editing","optionalDeps":[],"profiles":[],"required":false,"slots":[]},{"classes":["DataModule"],"deps":[],"enabled":true,"layer":0,"name":"data","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":[]},{"classes":["Filesystem","HotReload"],"deps":[],"enabled":true,"layer":0,"name":"filesystem","optionalDeps":[],"profiles":[],"required":true,"slots":["fs","hot"]},{"classes":[],"deps":["data"],"enabled":true,"layer":0,"name":"asset","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":[]},{"classes":["PlatformEvent"],"deps":[],"enabled":true,"layer":0,"name":"platform_event","optionalDeps":[],"profiles":[],"required":true,"slots":["platform_event"]},{"classes":["Timer"],"deps":[],"enabled":true,"layer":0,"name":"timer","optionalDeps":[],"profiles":[],"required":true,"slots":["timer"]},{"classes":["System"],"deps":[],"enabled":true,"layer":0,"name":"system","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":["system"]},{"classes":["Profiler"],"deps":[],"enabled":true,"layer":0,"name":"profiler","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":["profiler"]},{"classes":["Thread"],"deps":[],"enabled":true,"layer":0,"name":"thread","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":["thread"]},{"classes":["Spatial"],"deps":[],"enabled":true,"layer":0,"name":"spatial","optionalDeps":[],"profiles":["2d","3d","web"],"required":false,"slots":["spatial"]},{"classes":["Crowd"],"deps":[],"enabled":true,"layer":0,"name":"crowd","optionalDeps":[],"profiles":["2d","3d","web"],"required":false,"slots":["crowd"]},{"classes":["PixelWorldModule"],"deps":[],"enabled":true,"layer":0,"name":"pixelworld","optionalDeps":[],"profiles":["2d","3d","web"],"required":false,"slots":["pixelworld"]},{"classes":["IK"],"deps":[],"enabled":true,"layer":0,"name":"ik","optionalDeps":[],"profiles":["2d","3d","web"],"required":false,"slots":[]},{"classes":["Editor"],"deps":["action","asset","editing","material_editing","property_access","scene_editing","tags","transaction"],"enabled":true,"layer":6,"name":"editor","optionalDeps":["animation","audio","avatar","building","camera","crowd","daynight","definitions","dialogue","fluids","graphics","hd2d","housegen","image","network","orders","particles","physics","physics_editing","production","procgen","profiler","map","scene","sceneloader","schema","snow","social","spritestack","ui","virtualgeometry","voxel","weather"],"profiles":["3d","web"],"required":false,"slots":["editor"]},{"classes":["Plugins"],"deps":[],"enabled":true,"layer":0,"name":"plugins","optionalDeps":[],"profiles":["3d"],"required":false,"slots":[]},{"classes":["Database"],"deps":[],"enabled":true,Line truncated
+118
View File
@@ -0,0 +1,118 @@
// Deterministic falling-material playground. Left paints the selected material,
// right digs terrain, middle ignites it. The core owns no rendering dependency.
persist world = null;
persist accumulator = 0.0;
persist brushMaterial = "sand";
persist brushRadius = 4;
persist atlasRenderer = null;
persist showDiagnostics = true;
const WORLD_W = 160;
const WORLD_H = 96;
const SCALE = 6.0;
const STEP = 0.016666667;
function buildScene() {
world.clear();
// A thick authoritative stone field: caves and later mouse digging replace
// cells with air instead of maintaining a separate destruction mask.
for (local x = 0; x < WORLD_W; x += 1) {
local surface = 66 + ((x / 13).tointeger() % 3) * 3;
for (local y = surface; y < WORLD_H; y += 1)
world.setMaterial(x, y, "stone");
}
for (local y = 42; y < WORLD_H; y += 1) {
world.setMaterial(0, y, "stone");
world.setMaterial(WORLD_W - 1, y, "stone");
}
world.paintCircle(44, 68, 15, "air");
world.paintCircle(67, 74, 11, "air");
world.paintCircle(116, 70, 17, "air");
world.paintCircle(39, 18, 10, "sand");
world.paintCircle(116, 30, 11, "water");
world.paintCircle(83, 61, 7, "wood");
world.paintCircle(96, 56, 5, "oil");
world.paintCircle(135, 55, 5, "acid");
world.paintCircle(22, 56, 4, "gunpowder");
world.paintCircle(148, 53, 4, "lava");
}
function selectBrush(key, material) {
if (key_just_pressed(key)) brushMaterial = material;
}
function updateBrush() {
selectBrush("1", "sand");
selectBrush("2", "water");
selectBrush("3", "oil");
selectBrush("4", "wood");
selectBrush("5", "stone");
selectBrush("6", "fire");
selectBrush("7", "acid");
selectBrush("8", "gunpowder");
selectBrush("9", "lava");
if (key_just_pressed("minus")) brushRadius = max(1, brushRadius - 1);
if (key_just_pressed("plus") || key_just_pressed("equals"))
brushRadius = min(12, brushRadius + 1);
local cellX = (mouse.getX().tofloat() / SCALE).tointeger();
local cellY = (mouse.getY().tofloat() / SCALE).tointeger();
if (cellX < 0 || cellX >= WORLD_W || cellY < 0 || cellY >= WORLD_H) return;
if (mouse.isDown(1)) world.paintCircle(cellX, cellY, brushRadius, brushMaterial);
if (mouse.isDown(2)) world.paintCircle(cellX, cellY, brushRadius + 2, "air");
if (mouse.isDown(3)) world.paintCircle(cellX, cellY, brushRadius, "fire");
}
eve_init = function() {
gfx.setBackgroundColor(0.025, 0.025, 0.04, 1.0);
if (world == null) {
world = pixelworld.newWorld(1337);
buildScene();
}
if (atlasRenderer == null)
atlasRenderer = pixelworldGraphics.newRenderer(0, 0, WORLD_W, WORLD_H);
pixelworldEditor.openCatalog();
};
eve_update = function(dt) {
updateBrush();
accumulator += dt;
while (accumulator >= STEP) {
world.step();
accumulator -= STEP;
}
if (key_just_pressed("r")) buildScene();
if (key_just_pressed("f")) world.paintCircle(96, 54, 5, "fire");
if (key_just_pressed("o")) world.paintCircle(62, 8, 8, "oil");
if (key_just_pressed("e")) {
local x = (mouse.getX().tofloat() / SCALE).tointeger();
local y = (mouse.getY().tofloat() / SCALE).tointeger();
world.explode(x, y, 10, 190, 420);
}
if (key_just_pressed("d")) showDiagnostics = !showDiagnostics;
};
eve_render = function() {
gfx.clear();
atlasRenderer.sync(world, gfx);
gfx.drawTexturedRect(atlasRenderer.getTexture(), 0.0, 0.0,
WORLD_W * SCALE, WORLD_H * SCALE,
1.0, 1.0, 1.0, 1.0);
if (showDiagnostics) atlasRenderer.drawDiagnostics(world, gfx, SCALE);
local cursorCellX = (mouse.getX().tofloat() / SCALE).tointeger();
local cursorCellY = (mouse.getY().tofloat() / SCALE).tointeger();
if (cursorCellX > 0 && cursorCellX < WORLD_W - 1 &&
cursorCellY > 0 && cursorCellY < WORLD_H - 1) {
local cursorX = cursorCellX * SCALE;
local cursorY = cursorCellY * SCALE;
local cursorSize = (brushRadius * 2 + 1) * SCALE;
gfx.drawSolidRect(cursorX - brushRadius * SCALE, cursorY - brushRadius * SCALE,
cursorSize, 1.0, 1.0, 1.0, 1.0, 0.8);
gfx.drawSolidRect(cursorX - brushRadius * SCALE,
cursorY + (brushRadius + 1) * SCALE,
cursorSize, 1.0, 1.0, 1.0, 1.0, 0.8);
}
ui.beginFrameAndRender();
};
+49
View File
@@ -48,6 +48,29 @@
"restore": "Rebuild process-local links from persistent identity after transactional world restore or hot reload.",
"stale": "Generation and owner identity are checked by resolve; encoded validity alone is insufficient."
},
{
"id": "pixelworld-fragment-link-contract",
"rule": "link",
"scope": "src/modules/pixelworld/PixelWorld.h",
"owner": "pixelworld::PixelWorld",
"issue": "EVENGINE-PIXELWORLD-FRAGMENTS",
"reason": "Detached fragment bitmaps need process-local source identity without becoming a second mutable terrain authority.",
"expiry": "2027-12-31",
"evidence": [
"src/modules/pixelworld/PixelWorld.h",
"docs/dev/PixelWorld-Noita技术路线.md"
],
"tests": [
"test/pixelworld.cpp",
"scripts/tests/test_architecture_contracts.py"
],
"symbols": ["PixelWorldLink"],
"create": "PixelWorld stamps a world id and epoch into every atomically detached PixelFragment.",
"ownership": "PixelWorld solely owns live terrain; a fragment owns only its detached bitmap and a non-owning source link.",
"destroy_order": "Fragment destruction is independent; world destruction makes rasterization impossible without dereferencing retained world pointers.",
"restore": "Transactional restore and clear increment the world epoch; callers must discard or explicitly recreate stale physics projections.",
"stale": "Rasterization compares both runtime world identity and epoch and returns StaleHandle before any terrain write."
},
{
"id": "rts-link-contract",
"rule": "link",
@@ -150,6 +173,32 @@
"authoritative_owner": "The domain registry/container/world named by the contract is the sole writer of the fact.",
"projections": "Links, handles, ECS views and UI/script values are invalidatable projections and cannot become a second store."
},
{
"id": "pixelworld-thread-system-contract",
"rule": "ecs-system",
"scope": "src/modules/pixelworld_thread/PixelWorldThread.h",
"owner": "pixelworld::PixelWorld",
"issue": "EVENGINE-PIXELWORLD-PARALLEL",
"reason": "PixelWorld worker scheduling must declare immutable phase reads, isolated candidate writes and canonical commit ordering.",
"expiry": "2027-12-31",
"evidence": [
"src/modules/pixelworld_thread/PixelWorldThread.h",
"docs/dev/PixelWorld-Noita技术路线.md"
],
"tests": [
"test/pixelworld_parallel.cpp",
"scripts/tests/test_architecture_contracts.py"
],
"systems": ["JobSystem", "JobSystemPixelScheduler"],
"entity_scope": "canonical active PixelWorld Chunk indices for one injected SimulationTick",
"view": "immutable post-movement PixelCell phase state plus one owning candidate slot per Chunk index",
"read_set": "PixelCell material and temperature, immutable MaterialCatalog definitions and canonical Chunk order",
"write_set": "index-owned ThermalContribution buffers and transfer counters only; workers never write PixelWorld",
"structural_changes": "none on workers; owner thread performs sorted temperature commits and later Chunk tombstone publication",
"events": "StepStats parallelTasks and deterministic phase counters after synchronous join",
"services": "borrowed running thread::JobSystem through JobSystemPixelScheduler",
"phase": "thermal candidate generation after canonical movement and before owner-thread thermal commit"
},
{
"id": "climbing-system-contracts",
"rule": "ecs-system",
+19
View File
@@ -0,0 +1,19 @@
#pragma once
#include "common/Export.h"
#include <string>
namespace eve {
/** @brief Module-neutral JSON bridge from DevTools/MCP to PixelWorld tooling. */
class EVENGINE_API IPixelWorldAutomation {
public:
static constexpr const char* capabilityName = "IPixelWorldAutomation";
virtual ~IPixelWorldAutomation() = default;
/** @brief Invoke a stable PixelWorld tooling operation and return a JSON object. */
virtual std::string invoke(const std::string& operation,
const std::string& requestJson) = 0;
};
} // namespace eve
+9 -8
View File
@@ -24,8 +24,9 @@ namespace detail {
*
* The token starts active for a newly-created Result. A move transfers its
* responsibility after payload movement succeeds; a moved-from Result is
* disarmed. In non-assert builds this class has no data member and its methods
* compile to no-ops.
* disarmed. The two bytes remain present in non-assert builds so Result keeps
* one ABI when a Release engine library is consumed by an assertion-enabled
* test or tool; the observation operations themselves compile to no-ops.
*/
class ResultObservation {
public:
@@ -37,9 +38,7 @@ public:
/** @brief Start an inactive token used while a Result move is constructed. */
struct InactiveTag {};
explicit ResultObservation(InactiveTag) noexcept {
#if !defined(ZEROERR_NO_ASSERT)
mustObserve_ = false;
#endif
}
#if !defined(ZEROERR_NO_ASSERT)
@@ -76,10 +75,11 @@ protected:
}
private:
#if !defined(ZEROERR_NO_ASSERT)
// Do not condition these fields on ZEROERR_NO_ASSERT. Public Result<T>
// values cross static/shared-library boundaries whose assertion policy may
// intentionally differ from the consumer (notably Release unit tests).
mutable bool observed_ = false;
bool mustObserve_ = true;
#endif
};
template <class T>
@@ -93,8 +93,9 @@ using ResultReturn = std::invoke_result_t<T>;
* Every Result is checked in an assertion-enabled build before destruction.
* Calling `ok`, `status`, `value`, `error`, a composition helper, `ignore`, or
* `expect` counts as an explicit observation. In release-style builds where
* `ZEROERR_NO_ASSERT` is defined, observation has no storage or branch cost;
* the class remains `[[nodiscard]]` at compile time.
* `ZEROERR_NO_ASSERT` is defined, observation checks have no branch cost. The
* small observation token retains a stable ABI and the class remains
* `[[nodiscard]]` at compile time.
*
* @tparam T Owning value type. References and void use a different form.
*/
+44
View File
@@ -21,6 +21,7 @@
#include "common/EditorHost.h"
#include "common/Module.h"
#include "common/ParticlesQuery.h"
#include "common/PixelWorldAutomation.h"
#include "common/PhysicsQuery.h"
#include "common/ProcgenQuery.h"
#include "common/RenderCapture.h"
@@ -436,6 +437,9 @@ eve::IAudioQuery* mcpAudio() { return eve::cap::query<eve::IAudioQuery>();
eve::IEditorHost* mcpHost() { return eve::cap::query<eve::IEditorHost>(); }
eve::IUIAutomation* mcpUI() { return eve::cap::query<eve::IUIAutomation>(); }
eve::IEditorAutomation* mcpEditor() { return eve::cap::query<eve::IEditorAutomation>(); }
eve::IPixelWorldAutomation* mcpPixelWorld() {
return eve::cap::query<eve::IPixelWorldAutomation>();
}
Poco::JSON::Object::Ptr renderableObservation(eve::IRenderCapture& capture, int entityId, int generation) {
Poco::JSON::Object::Ptr output = Poco::JSON::Object::Ptr(new Poco::JSON::Object());
@@ -695,6 +699,13 @@ std::string callTool(McpServer& mcp, const std::string& name, Poco::JSON::Object
if (name == "eve_status") return engineStatusJson(mcp);
if (name.rfind("eve_pixelworld_", 0) == 0) {
auto* provider = mcpPixelWorld();
if (!provider) return "{\"ok\":false,\"error\":\"pixelworld module not available\"}";
return provider->invoke(name.substr(std::string("eve_pixelworld_").size()),
mcpStringify(Poco::Dynamic::Var(args)));
}
// ============================= Game UI =============================
if (name == "eve_ui_tree") {
return mcpUI() ? mcpUI()->tree(argString(args, "host")) : "error: ui module not available";
@@ -1634,6 +1645,39 @@ std::string handleToolsList(const std::string& idJson) {
"{\"tools\":["
"{\"name\":\"eve_status\",\"description\":\"Runtime + debugger + MCP/DAP status JSON.\","
"\"inputSchema\":{\"type\":\"object\",\"properties\":{}}},"
"{\"name\":\"eve_pixelworld_worlds\",\"description\":\"List live PixelWorld simulations and status.\","
"\"inputSchema\":{\"type\":\"object\",\"properties\":{}}},"
"{\"name\":\"eve_pixelworld_catalog_builtin\",\"description\":\"Return the canonical versioned built-in material/reaction Catalog document.\","
"\"inputSchema\":{\"type\":\"object\",\"properties\":{}}},"
"{\"name\":\"eve_pixelworld_catalog_validate\",\"description\":\"Transactionally validate and canonicalize a material/reaction Catalog document.\","
"\"inputSchema\":{\"type\":\"object\",\"properties\":{\"catalog\":{}},\"required\":[\"catalog\"]}},"
"{\"name\":\"eve_pixelworld_catalog_apply\",\"description\":\"Hot-reload a compatible Catalog into a paused world using optimistic fingerprint concurrency.\","
"\"inputSchema\":{\"type\":\"object\",\"properties\":{\"world\":{\"type\":\"integer\"},\"catalog\":{},"
"\"expectedFingerprint\":{\"type\":\"string\"}},\"required\":[\"world\",\"catalog\",\"expectedFingerprint\"]}},"
"{\"name\":\"eve_pixelworld_pause\",\"description\":\"Pause or resume one PixelWorld.\","
"\"inputSchema\":{\"type\":\"object\",\"properties\":{\"world\":{\"type\":\"integer\"},"
"\"paused\":{\"type\":\"boolean\"}},\"required\":[\"world\",\"paused\"]}},"
"{\"name\":\"eve_pixelworld_step\",\"description\":\"Explicitly advance one PixelWorld by 1-1024 ticks, including while paused.\","
"\"inputSchema\":{\"type\":\"object\",\"properties\":{\"world\":{\"type\":\"integer\"},"
"\"count\":{\"type\":\"integer\",\"minimum\":1,\"maximum\":1024}},\"required\":[\"world\"]}},"
"{\"name\":\"eve_pixelworld_edit\",\"description\":\"Apply a strictly sequenced paint, heat, or explosion edit.\","
"\"inputSchema\":{\"type\":\"object\",\"properties\":{\"world\":{\"type\":\"integer\"},"
"\"sequence\":{\"type\":\"integer\"},\"kind\":{\"type\":\"string\",\"enum\":[\"paint\",\"heat\",\"explosion\"]},"
"\"x\":{\"type\":\"integer\"},\"y\":{\"type\":\"integer\"},\"radius\":{\"type\":\"integer\"},"
"\"material\":{\"type\":\"integer\"},\"strength\":{\"type\":\"integer\"},"
"\"temperatureDelta\":{\"type\":\"integer\"}},\"required\":[\"world\",\"sequence\",\"kind\"]}},"
"{\"name\":\"eve_pixelworld_diagnostics\",\"description\":\"Inspect bounded per-Chunk activity, material and temperature diagnostics.\","
"\"inputSchema\":{\"type\":\"object\",\"properties\":{\"world\":{\"type\":\"integer\"},"
"\"minX\":{\"type\":\"integer\"},\"minY\":{\"type\":\"integer\"},\"maxX\":{\"type\":\"integer\"},"
"\"maxY\":{\"type\":\"integer\"}},\"required\":[\"world\"]}},"
"{\"name\":\"eve_pixelworld_samples\",\"description\":\"Read the latest bounded PixelWorld simulation performance samples.\","
"\"inputSchema\":{\"type\":\"object\",\"properties\":{\"world\":{\"type\":\"integer\"},"
"\"limit\":{\"type\":\"integer\",\"minimum\":1,\"maximum\":256}},\"required\":[\"world\"]}},"
"{\"name\":\"eve_pixelworld_snapshot_capture\",\"description\":\"Capture a canonical PixelWorld snapshot as hex.\","
"\"inputSchema\":{\"type\":\"object\",\"properties\":{\"world\":{\"type\":\"integer\"}},\"required\":[\"world\"]}},"
"{\"name\":\"eve_pixelworld_snapshot_restore\",\"description\":\"Transactionally restore a canonical hex PixelWorld snapshot.\","
"\"inputSchema\":{\"type\":\"object\",\"properties\":{\"world\":{\"type\":\"integer\"},"
"\"snapshot\":{\"type\":\"string\"}},\"required\":[\"world\",\"snapshot\"]}},"
"{\"name\":\"eve_ui_tree\",\"description\":\"Inspect retained game UI hosts and semantic widget trees (not "
"only eve_host editors).\","
"\"inputSchema\":{\"type\":\"object\",\"properties\":{\"host\":{\"type\":\"string\",\"description\":\"optional "
+45
View File
@@ -5,6 +5,7 @@
#include <glm/glm.hpp>
#include <memory>
#include <optional>
#include <span>
#include <string>
#include <vector>
#include "common/Module.h"
@@ -42,6 +43,20 @@ class GrassField;
class Material;
class Mesh;
/**
* @brief One borrowed RGBA8 source rectangle for a batched texture update.
* @ownership `rgba` remains owned by the caller and is never retained.
* @lifetime The byte span must remain valid through updateTextureRegions().
*/
struct TextureRegionUpload {
int x = 0;
int y = 0;
int width = 0;
int height = 0;
std::span<const std::uint8_t> rgba;
std::size_t bytesPerRow = 0;
};
/**
* @brief Backend-owned layout facts for a mesh uploaded through Graphics.
*
@@ -486,6 +501,36 @@ public:
virtual bool updateTexture(Texture *texture, int width, int height,
const uint8_t *rgba) = 0;
/**
* @brief Upload one tightly packed or row-strided RGBA8 rectangle into mip level zero.
* @param texture Borrowed texture owned by this Graphics backend.
* @param x Destination pixel offset from the left edge.
* @param y Destination pixel offset from the top edge.
* @param width Rectangle width in pixels.
* @param height Rectangle height in pixels.
* @param rgba Owning-external bytes borrowed only for this synchronous call.
* @param bytesPerRow Source row stride; zero means `width * 4`.
* @return Success after the upload is visible to subsequent draws, or a diagnostic.
* @ownership Graphics retains neither `texture` nor `rgba`; it already owns texture storage.
* @lifetime `texture` and `rgba` must remain valid only for this render-thread call.
* @remarks Textures with mip chains are rejected because partial mip regeneration is undefined.
*/
[[nodiscard]] virtual eve::Result<void> updateTextureRegion(
Texture *texture, int x, int y, int width, int height,
std::span<const std::uint8_t> rgba, std::size_t bytesPerRow = 0) = 0;
/**
* @brief Validate then upload multiple independent mip-zero RGBA8 regions as one batch.
* @param texture Borrowed single-mip texture owned by this Graphics backend.
* @param regions Borrowed descriptors and source spans, consumed synchronously.
* @return Success after every region is accepted and uploaded, otherwise no upload occurs.
* @ownership Graphics retains neither the texture nor region/source spans.
* @lifetime All arguments need remain valid only through this render-thread call.
* @remarks Vulkan guarantees one staging allocation and queue submission for the batch.
*/
[[nodiscard]] virtual eve::Result<void> updateTextureRegions(
Texture *texture, std::span<const TextureRegionUpload> regions) = 0;
/**
* @brief Recreate the sampler for an existing texture (keeps image / mip chain).
* No-op when texture is null or not owned by this Graphics.
+5
View File
@@ -404,6 +404,11 @@ public:
bool releaseTexture(Texture *texture) override;
bool updateTexture(Texture *texture, int width, int height,
const uint8_t *rgba) override;
eve::Result<void> updateTextureRegion(Texture *texture, int x, int y, int width,
int height, std::span<const std::uint8_t> rgba,
std::size_t bytesPerRow = 0) override;
eve::Result<void> updateTextureRegions(
Texture *texture, std::span<const TextureRegionUpload> regions) override;
bool replaceTexturePixels(Texture *tex, image::ImageData *data);
bool replaceTexturePixelsRGBA(Texture *tex, int w, int h, const uint8_t *rgba);
void drawTexturedRect(Texture *texture, float x, float y, float w, float h, const Color &color) override;
@@ -680,6 +680,111 @@ bool Graphics::updateTexture(Texture *tex, int width, int height, const uint8_t
return replaceTexturePixelsRGBA(tex, width, height, rgba);
}
eve::Result<void> Graphics::updateTextureRegion(Texture *tex, int x, int y, int width,
int height,
std::span<const std::uint8_t> rgba,
std::size_t bytesPerRow) {
const TextureRegionUpload upload{x, y, width, height, rgba, bytesPerRow};
return updateTextureRegions(tex, std::span<const TextureRegionUpload>(&upload, 1));
}
eve::Result<void> Graphics::updateTextureRegions(
Texture *tex, std::span<const TextureRegionUpload> regions) {
if (!tex)
return eve::Result<void>::failure(eve::Diagnostic::error(
eve::DiagnosticCode::InvalidArgument, "texture must be non-null",
"graphics.updateTextureRegions.texture"));
if (tex->mipmapCount != 1)
return eve::Result<void>::failure(eve::Diagnostic::error(
eve::DiagnosticCode::Unsupported, "partial updates require a single-mip texture",
"graphics.updateTextureRegions.mipmaps"));
auto *gpu = static_cast<GpuTexture *>(tex->gpuHandle);
const auto owned = std::find_if(ownedGpuTextures.begin(), ownedGpuTextures.end(),
[gpu](const std::unique_ptr<GpuTexture> &candidate) {
return candidate.get() == gpu;
});
if (!gpu || owned == ownedGpuTextures.end() || gpu->isCube)
return eve::Result<void>::failure(eve::Diagnostic::error(
eve::DiagnosticCode::InvalidArgument, "texture is not an owned 2D texture",
"graphics.updateTextureRegions.texture"));
if (regions.empty()) return eve::Result<void>::success();
std::vector<std::size_t> packedOffsets;
packedOffsets.reserve(regions.size());
std::size_t packedSize = 0;
for (const TextureRegionUpload &region : regions) {
if (region.x < 0 || region.y < 0 || region.width <= 0 || region.height <= 0 ||
region.x > tex->width - region.width || region.y > tex->height - region.height)
return eve::Result<void>::failure(eve::Diagnostic::error(
eve::DiagnosticCode::InvalidArgument, "invalid texture region",
"graphics.updateTextureRegions.region"));
const std::size_t packedRow = std::size_t(region.width) * 4U;
const std::size_t stride = region.bytesPerRow == 0 ? packedRow : region.bytesPerRow;
const std::size_t requiredBytes = stride * std::size_t(region.height - 1) + packedRow;
if (stride < packedRow || region.rgba.size() < requiredBytes)
return eve::Result<void>::failure(eve::Diagnostic::error(
eve::DiagnosticCode::InvalidArgument,
"source bytes do not cover the texture region",
"graphics.updateTextureRegions.bytes"));
packedOffsets.push_back(packedSize);
packedSize += packedRow * std::size_t(region.height);
}
std::vector<std::uint8_t> packed(packedSize);
for (std::size_t index = 0; index < regions.size(); ++index) {
const TextureRegionUpload &region = regions[index];
const std::size_t packedRow = std::size_t(region.width) * 4U;
const std::size_t stride = region.bytesPerRow == 0 ? packedRow : region.bytesPerRow;
for (int row = 0; row < region.height; ++row)
std::copy_n(region.rgba.data() + std::size_t(row) * stride, packedRow,
packed.data() + packedOffsets[index] + std::size_t(row) * packedRow);
}
vkb::GenericBuffer staging(
device, vk::BufferUsageFlagBits::eTransferSrc, vk::DeviceSize(packed.size()),
vk::MemoryPropertyFlagBits::eHostVisible | vk::MemoryPropertyFlagBits::eHostCoherent);
staging.updateLocal(vkb::FrameSlot::gpuIdle(), packed.data(), vk::DeviceSize(packed.size()));
waitForSharedGpuResources();
vkb::executeImmediately(device.instance, uploadPool,
device.getQueue(vkb::QueueType::graphics),
[&](vk::CommandBuffer cb) {
gpu->image.setLayout(cb, vk::ImageLayout::eTransferDstOptimal);
std::vector<vk::BufferImageCopy> copies;
copies.reserve(regions.size());
for (std::size_t index = 0; index < regions.size(); ++index) {
const TextureRegionUpload &source = regions[index];
vk::BufferImageCopy copy{};
copy.bufferOffset = packedOffsets[index];
copy.imageSubresource = {vk::ImageAspectFlagBits::eColor, 0, 0, 1};
copy.imageOffset = vk::Offset3D{source.x, source.y, 0};
copy.imageExtent = vk::Extent3D{std::uint32_t(source.width),
std::uint32_t(source.height), 1};
copies.push_back(copy);
}
cb.copyBufferToImage(staging.buffer, gpu->image.image(),
vk::ImageLayout::eTransferDstOptimal, copies);
vk::ImageMemoryBarrier barrier{};
barrier.srcAccessMask = vk::AccessFlagBits::eTransferWrite;
barrier.dstAccessMask = vk::AccessFlagBits::eShaderRead;
barrier.oldLayout = vk::ImageLayout::eTransferDstOptimal;
barrier.newLayout = vk::ImageLayout::eShaderReadOnlyOptimal;
barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
barrier.image = gpu->image.image();
barrier.subresourceRange = {vk::ImageAspectFlagBits::eColor, 0, 1, 0, 1};
cb.pipelineBarrier(vk::PipelineStageFlagBits::eTransfer,
vk::PipelineStageFlagBits::eVertexShader |
vk::PipelineStageFlagBits::eFragmentShader |
vk::PipelineStageFlagBits::eComputeShader,
{}, 0, nullptr, 0, nullptr, 1, &barrier);
gpu->image.setCurrentLayout(vk::ImageLayout::eShaderReadOnlyOptimal);
});
staging.release();
return eve::Result<void>::success();
}
bool Graphics::replaceTexturePixelsRGBA(Texture *tex, int w, int h, const uint8_t *rgba) {
if (!tex || !rgba || w <= 0 || h <= 0 || !initialized) return false;
+59
View File
@@ -2265,6 +2265,65 @@ bool Graphics::updateTexture(Texture *texture, int width, int height,
return true;
}
eve::Result<void> Graphics::updateTextureRegion(Texture *texture, int x, int y, int width,
int height,
std::span<const std::uint8_t> rgba,
std::size_t bytesPerRow) {
const TextureRegionUpload upload{x, y, width, height, rgba, bytesPerRow};
return updateTextureRegions(texture, std::span<const TextureRegionUpload>(&upload, 1));
}
eve::Result<void> Graphics::updateTextureRegions(
Texture *texture, std::span<const TextureRegionUpload> regions) {
auto *gpu = gpuForTexture(texture);
if (!texture || !gpu || gpu->isCube)
return eve::Result<void>::failure(eve::Diagnostic::error(
eve::DiagnosticCode::InvalidArgument, "texture is not an owned 2D texture",
"graphics.updateTextureRegions.texture"));
if (texture->mipmapCount != 1)
return eve::Result<void>::failure(eve::Diagnostic::error(
eve::DiagnosticCode::Unsupported, "partial updates require a single-mip texture",
"graphics.updateTextureRegions.mipmaps"));
for (const TextureRegionUpload &region : regions) {
if (region.x < 0 || region.y < 0 || region.width <= 0 || region.height <= 0 ||
region.x > texture->width - region.width ||
region.y > texture->height - region.height)
return eve::Result<void>::failure(eve::Diagnostic::error(
eve::DiagnosticCode::InvalidArgument, "invalid texture region",
"graphics.updateTextureRegions.region"));
const std::size_t packedRow = std::size_t(region.width) * 4U;
const std::size_t stride = region.bytesPerRow == 0 ? packedRow : region.bytesPerRow;
const std::size_t requiredBytes = stride * std::size_t(region.height - 1) + packedRow;
if (stride < packedRow || region.rgba.size() < requiredBytes)
return eve::Result<void>::failure(eve::Diagnostic::error(
eve::DiagnosticCode::InvalidArgument,
"source bytes do not cover the texture region",
"graphics.updateTextureRegions.bytes"));
}
for (const TextureRegionUpload &region : regions) {
const std::size_t packedRow = std::size_t(region.width) * 4U;
const std::size_t stride = region.bytesPerRow == 0 ? packedRow : region.bytesPerRow;
const std::size_t requiredBytes = stride * std::size_t(region.height - 1) + packedRow;
WGPUTexelCopyTextureInfo dst{};
dst.texture = gpu->texture.Get();
dst.mipLevel = 0;
dst.origin = {std::uint32_t(region.x), std::uint32_t(region.y), 0};
dst.aspect = WGPUTextureAspect_All;
WGPUTexelCopyBufferLayout layout{};
layout.offset = 0;
layout.bytesPerRow = std::uint32_t(stride);
layout.rowsPerImage = std::uint32_t(region.height);
WGPUExtent3D extent{std::uint32_t(region.width), std::uint32_t(region.height), 1};
queue.WriteTexture(reinterpret_cast<const wgpu::TexelCopyTextureInfo *>(&dst),
region.rgba.data(), requiredBytes,
reinterpret_cast<const wgpu::TexelCopyBufferLayout *>(&layout),
reinterpret_cast<const wgpu::Extent3D *>(&extent));
}
return eve::Result<void>::success();
}
GpuTexture *Graphics::gpuForTexture(Texture *t) const {
return t ? static_cast<GpuTexture *>(t->gpuHandle) : nullptr;
}
+5
View File
@@ -265,6 +265,11 @@ public:
bool releaseTexture(Texture *texture) override;
bool updateTexture(Texture *texture, int width, int height,
const uint8_t *rgba) override;
eve::Result<void> updateTextureRegion(Texture *texture, int x, int y, int width,
int height, std::span<const std::uint8_t> rgba,
std::size_t bytesPerRow = 0) override;
eve::Result<void> updateTextureRegions(
Texture *texture, std::span<const TextureRegionUpload> regions) override;
void drawTexturedRect(Texture *texture, float x, float y, float w, float h,
const Color &color) override;
+252
View File
@@ -0,0 +1,252 @@
#include "pixelworld/PixelMaterial.h"
#include "common/Diagnostic.h"
#include <algorithm>
#include <limits>
#include <type_traits>
#include <unordered_set>
#include <utility>
namespace eve::pixelworld {
namespace {
eve::Result<MaterialCatalog> invalidCatalog(std::string message, std::string path) {
return eve::Result<MaterialCatalog>::failure(eve::Diagnostic::error(
eve::DiagnosticCode::InvalidArgument, std::move(message), std::move(path), {}, "pixelworld.catalog"));
}
void hashBytes(std::uint64_t& hash, std::string_view bytes) noexcept {
for (const unsigned char byte : bytes) {
hash ^= byte;
hash *= 1099511628211ULL;
}
}
template <class T>
void hashValue(std::uint64_t& hash, T value) noexcept {
using Unsigned = std::make_unsigned_t<T>;
const auto bits = static_cast<Unsigned>(value);
for (std::size_t i = 0; i < sizeof(T); ++i) {
hash ^= std::uint8_t(bits >> (i * 8U));
hash *= 1099511628211ULL;
}
}
} // namespace
eve::Result<MaterialCatalog> MaterialCatalog::create(std::vector<MaterialDefinition> definitions,
std::vector<MaterialReactionRule> reactions,
std::vector<MaterialPhaseRule> phaseRules) {
if (definitions.empty()) return invalidCatalog("material catalog must contain air", "materials");
if (definitions.size() > std::size_t(std::numeric_limits<std::uint16_t>::max()))
return invalidCatalog("material catalog exceeds uint16 id capacity", "materials");
std::unordered_set<std::string> names;
for (std::size_t index = 0; index < definitions.size(); ++index) {
auto& definition = definitions[index];
if (std::size_t(definition.id) != index)
return invalidCatalog("material ids must be contiguous and match canonical order",
"materials[" + std::to_string(index) + "].id");
if (definition.name.empty() || !names.emplace(definition.name).second)
return invalidCatalog("material names must be non-empty and unique",
"materials[" + std::to_string(index) + "].name");
if (definition.heatCapacity == 0)
return invalidCatalog("material heat capacity must be positive",
"materials[" + std::to_string(index) + "].heatCapacity");
std::sort(definition.tags.begin(), definition.tags.end());
if (std::any_of(definition.tags.begin(), definition.tags.end(), [](const std::string& tag) {
return tag.empty();
}) || std::adjacent_find(definition.tags.begin(), definition.tags.end()) !=
definition.tags.end())
return invalidCatalog("material tags must be non-empty and unique",
"materials[" + std::to_string(index) + "].tags");
}
if (definitions[0].id != MaterialId::Air || definitions[0].state != MaterialState::Empty)
return invalidCatalog("material zero must be empty air", "materials[0]");
std::unordered_set<std::string> ruleIds;
const auto validId = [&definitions](MaterialId id) { return std::size_t(id) < definitions.size(); };
for (std::size_t index = 0; index < reactions.size(); ++index) {
const auto& rule = reactions[index];
if (rule.id.empty() || !ruleIds.emplace(rule.id).second)
return invalidCatalog("reaction ids must be non-empty and unique",
"reactions[" + std::to_string(index) + "].id");
if (!validId(rule.first) || !validId(rule.second) || !validId(rule.firstResult) ||
!validId(rule.secondResult))
return invalidCatalog("reaction references an unknown material",
"reactions[" + std::to_string(index) + "]");
}
std::sort(reactions.begin(), reactions.end(), [](const auto& lhs, const auto& rhs) {
return lhs.priority != rhs.priority ? lhs.priority > rhs.priority : lhs.id < rhs.id;
});
std::unordered_set<std::string> phaseIds;
for (std::size_t index = 0; index < phaseRules.size(); ++index) {
const auto& rule = phaseRules[index];
if (rule.id.empty() || !phaseIds.emplace(rule.id).second)
return invalidCatalog("phase rule ids must be non-empty and unique",
"phaseRules[" + std::to_string(index) + "].id");
if (!validId(rule.source) || !validId(rule.result))
return invalidCatalog("phase rule references an unknown material",
"phaseRules[" + std::to_string(index) + "]");
}
std::sort(phaseRules.begin(), phaseRules.end(), [](const auto& lhs, const auto& rhs) {
return lhs.priority != rhs.priority ? lhs.priority > rhs.priority : lhs.id < rhs.id;
});
MaterialCatalog catalog;
catalog.definitions_ = std::move(definitions);
catalog.reactions_ = std::move(reactions);
catalog.phaseRules_ = std::move(phaseRules);
catalog.reactionMask_.assign(catalog.definitions_.size(), 0);
for (const auto& rule : catalog.reactions_) {
catalog.reactionMask_[std::size_t(rule.first)] = 1;
catalog.reactionMask_[std::size_t(rule.second)] = 1;
}
catalog.phaseMask_.assign(catalog.definitions_.size(), 0);
for (const auto& rule : catalog.phaseRules_) catalog.phaseMask_[std::size_t(rule.source)] = 1;
std::uint64_t hash = 1469598103934665603ULL;
for (const auto& definition : catalog.definitions_) {
hashValue(hash, std::uint16_t(definition.id));
hashBytes(hash, definition.name);
hashValue(hash, std::uint8_t(definition.state));
hashValue(hash, definition.density);
hashValue(hash, definition.viscosity);
hashValue(hash, definition.thermalConductivity);
hashValue(hash, definition.heatCapacity);
hashValue(hash, definition.defaultTemperature);
hashValue(hash, definition.ignitionTemperature);
hashValue(hash, definition.meltingTemperature);
hashValue(hash, definition.boilingTemperature);
hashValue(hash, definition.defaultLifetime);
hashValue(hash, std::uint8_t(definition.flammable));
hashValue(hash, definition.blastResistance);
hashValue(hash, definition.displayRgba);
hashValue(hash, std::uint32_t(definition.tags.size()));
for (const auto& tag : definition.tags) hashBytes(hash, tag);
}
for (const auto& rule : catalog.reactions_) {
hashBytes(hash, rule.id);
hashValue(hash, std::uint16_t(rule.first));
hashValue(hash, std::uint16_t(rule.second));
hashValue(hash, std::uint16_t(rule.firstResult));
hashValue(hash, std::uint16_t(rule.secondResult));
hashValue(hash, rule.minimumTemperature);
hashValue(hash, rule.heatDelta);
hashValue(hash, rule.priority);
}
for (const auto& rule : catalog.phaseRules_) {
hashBytes(hash, rule.id);
hashValue(hash, std::uint16_t(rule.source));
hashValue(hash, std::uint16_t(rule.result));
hashValue(hash, std::uint8_t(rule.direction));
hashValue(hash, rule.threshold);
hashValue(hash, rule.temperatureDelta);
hashValue(hash, rule.priority);
}
catalog.fingerprint_ = hash;
return eve::Result<MaterialCatalog>::success(std::move(catalog));
}
MaterialCatalog MaterialCatalog::builtIn() {
std::vector<MaterialDefinition> materials{
{MaterialId::Air, "air", MaterialState::Empty, 0, 0, 8, 1},
{MaterialId::Stone, "stone", MaterialState::Solid, 255, 255, 30, 180},
{MaterialId::Sand, "sand", MaterialState::Powder, 180, 35, 18, 80},
{MaterialId::Water, "water", MaterialState::Liquid, 100, 24, 45, 120, 32767, 0, 100},
{MaterialId::Oil, "oil", MaterialState::Liquid, 80, 70, 12, 90, 180, 32767, 280, 0, true},
{MaterialId::Fire, "fire", MaterialState::Energy, 1, 0, 80, 1, 32767, 32767, 32767, 30,
false, 700},
{MaterialId::Steam, "steam", MaterialState::Gas, 2, 5, 22, 30, 32767, 32767, 32767, 180,
false, 110},
{MaterialId::Wood, "wood", MaterialState::Solid, 220, 255, 8, 140, 240, 32767, 32767, 0, true},
{MaterialId::Ice, "ice", MaterialState::Solid, 105, 255, 35, 110, 32767, 0, 32767, 0, false, -10},
{MaterialId::Lava, "lava", MaterialState::Liquid, 210, 180, 55, 200, 32767, 900, 32767, 0, false, 1300},
{MaterialId::Smoke, "smoke", MaterialState::Gas, 3, 8, 12, 20, 32767, 32767, 32767, 220},
{MaterialId::Gunpowder, "gunpowder", MaterialState::Powder, 165, 40, 20, 70, 160, 32767, 32767, 0, true},
{MaterialId::Acid, "acid", MaterialState::Liquid, 110, 45, 32, 100, 32767, 32767, 118},
};
std::vector<MaterialReactionRule> reactions{
{"fire.water.boil", MaterialId::Fire, MaterialId::Water, MaterialId::Fire, MaterialId::Steam,
-32768, 40, 200},
{"fire.oil.ignite", MaterialId::Fire, MaterialId::Oil, MaterialId::Fire, MaterialId::Fire,
-32768, 80, 150},
{"fire.wood.ignite", MaterialId::Fire, MaterialId::Wood, MaterialId::Fire, MaterialId::Fire,
-32768, 60, 140},
{"fire.gunpowder.ignite", MaterialId::Fire, MaterialId::Gunpowder, MaterialId::Fire,
MaterialId::Fire, -32768, 300, 180},
{"acid.stone.dissolve", MaterialId::Acid, MaterialId::Stone, MaterialId::Water,
MaterialId::Sand, -32768, 0, 120},
{"lava.water.quench", MaterialId::Lava, MaterialId::Water, MaterialId::Stone,
MaterialId::Steam, -32768, 80, 220},
};
materials[std::size_t(MaterialId::Air)].blastResistance = 0;
materials[std::size_t(MaterialId::Stone)].blastResistance = 240;
materials[std::size_t(MaterialId::Sand)].blastResistance = 8;
materials[std::size_t(MaterialId::Water)].blastResistance = 0;
materials[std::size_t(MaterialId::Oil)].blastResistance = 0;
materials[std::size_t(MaterialId::Fire)].blastResistance = 0;
materials[std::size_t(MaterialId::Steam)].blastResistance = 0;
materials[std::size_t(MaterialId::Wood)].blastResistance = 45;
materials[std::size_t(MaterialId::Ice)].blastResistance = 70;
materials[std::size_t(MaterialId::Lava)].blastResistance = 0;
materials[std::size_t(MaterialId::Smoke)].blastResistance = 0;
materials[std::size_t(MaterialId::Gunpowder)].blastResistance = 5;
materials[std::size_t(MaterialId::Acid)].blastResistance = 0;
const auto decorate = [&materials](MaterialId id, std::uint32_t color,
std::vector<std::string> tags) {
auto& material = materials[std::size_t(id)];
material.displayRgba = color;
material.tags = std::move(tags);
};
decorate(MaterialId::Air, 0x00000000U, {"empty"});
decorate(MaterialId::Stone, 0x4D5261FFU, {"terrain", "solid"});
decorate(MaterialId::Sand, 0xE0B04DFFU, {"granular", "mobile"});
decorate(MaterialId::Water, 0x1F61F2DBU, {"liquid", "mobile"});
decorate(MaterialId::Oil, 0x423314FFU, {"flammable", "liquid", "mobile"});
decorate(MaterialId::Fire, 0xFF3D08FFU, {"energy", "hot", "mobile"});
decorate(MaterialId::Steam, 0xB8CCE08CU, {"gas", "mobile"});
decorate(MaterialId::Wood, 0x6B3312FFU, {"flammable", "solid", "terrain"});
decorate(MaterialId::Ice, 0xADDBFFF2U, {"cold", "solid", "terrain"});
decorate(MaterialId::Lava, 0xFF2E03FFU, {"hot", "liquid", "mobile"});
decorate(MaterialId::Smoke, 0x4D4A5294U, {"gas", "mobile"});
decorate(MaterialId::Gunpowder, 0x3D3833FFU, {"explosive", "granular", "mobile"});
decorate(MaterialId::Acid, 0x4DEB3DE0U, {"corrosive", "liquid", "mobile"});
std::vector<MaterialPhaseRule> phaseRules{
{"water.freeze", MaterialId::Water, MaterialId::Ice, TemperatureDirection::AtOrBelow, 0, 0, 100},
{"water.boil", MaterialId::Water, MaterialId::Steam, TemperatureDirection::AtOrAbove, 100, -20, 100},
{"ice.melt", MaterialId::Ice, MaterialId::Water, TemperatureDirection::AtOrAbove, 1, 0, 100},
{"stone.melt", MaterialId::Stone, MaterialId::Lava, TemperatureDirection::AtOrAbove, 1200, -200, 100},
{"lava.solidify", MaterialId::Lava, MaterialId::Stone, TemperatureDirection::AtOrBelow, 800, 100, 100},
};
return std::move(MaterialCatalog::create(std::move(materials), std::move(reactions), std::move(phaseRules)))
.expect("built-in PixelWorld material catalog must be valid");
}
eve::Result<MaterialId> MaterialCatalog::resolve(std::string_view name) const {
for (const auto& definition : definitions_)
if (definition.name == name) return eve::Result<MaterialId>::success(definition.id);
return eve::Result<MaterialId>::failure(eve::Diagnostic::error(
eve::DiagnosticCode::NotFound, "unknown pixel material: " + std::string(name), "material", {},
"pixelworld.catalog"));
}
const MaterialDefinition& MaterialCatalog::definition(MaterialId id) const noexcept {
const std::size_t index = std::size_t(id);
return definitions_[index < definitions_.size() ? index : 0];
}
std::span<const MaterialDefinition> MaterialCatalog::definitions() const noexcept { return definitions_; }
std::span<const MaterialReactionRule> MaterialCatalog::reactions() const noexcept { return reactions_; }
std::span<const MaterialPhaseRule> MaterialCatalog::phaseRules() const noexcept { return phaseRules_; }
bool MaterialCatalog::canReact(MaterialId id) const noexcept {
const std::size_t index = std::size_t(id);
return index < reactionMask_.size() && reactionMask_[index] != 0;
}
bool MaterialCatalog::canPhaseTransition(MaterialId id) const noexcept {
const std::size_t index = std::size_t(id);
return index < phaseMask_.size() && phaseMask_[index] != 0;
}
std::uint64_t MaterialCatalog::fingerprint() const noexcept { return fingerprint_; }
} // namespace eve::pixelworld
+118
View File
@@ -0,0 +1,118 @@
#pragma once
#include "common/Result.h"
#include <cstdint>
#include <span>
#include <string>
#include <string_view>
#include <vector>
namespace eve::pixelworld {
/** @brief Compact stable material identifier stored in each authoritative cell. */
enum class MaterialId : std::uint16_t {
Air = 0,
Stone,
Sand,
Water,
Oil,
Fire,
Steam,
Wood,
Ice,
Lava,
Smoke,
Gunpowder,
Acid,
};
/** @brief Broad movement class for one pixel material. */
enum class MaterialState : std::uint8_t { Empty, Solid, Powder, Liquid, Gas, Energy };
/** @brief Immutable validated properties for one catalog material. */
struct MaterialDefinition {
MaterialId id = MaterialId::Air;
std::string name = "air";
MaterialState state = MaterialState::Empty;
std::uint16_t density = 0;
std::uint8_t viscosity = 0;
std::uint8_t thermalConductivity = 0;
std::uint16_t heatCapacity = 1;
std::int16_t ignitionTemperature = 32767;
std::int16_t meltingTemperature = 32767;
std::int16_t boilingTemperature = 32767;
std::uint16_t defaultLifetime = 0;
bool flammable = false;
std::int16_t defaultTemperature = 20;
std::uint16_t blastResistance = 100;
/** @brief Canonical preview/render color encoded as 0xRRGGBBAA. */
std::uint32_t displayRgba = 0xFF00FFFFU;
/** @brief Canonical sorted semantic tags used by authoring and rule queries. */
std::vector<std::string> tags;
};
/** @brief Comparison direction for a deterministic temperature transition. */
enum class TemperatureDirection : std::uint8_t { AtOrBelow, AtOrAbove };
/** @brief Data-driven phase transition evaluated after heat diffusion. */
struct MaterialPhaseRule {
std::string id;
MaterialId source = MaterialId::Air;
MaterialId result = MaterialId::Air;
TemperatureDirection direction = TemperatureDirection::AtOrAbove;
std::int16_t threshold = 0;
std::int16_t temperatureDelta = 0;
std::int32_t priority = 0;
};
/** @brief Deterministically ordered binary material reaction. */
struct MaterialReactionRule {
std::string id;
MaterialId first = MaterialId::Air;
MaterialId second = MaterialId::Air;
MaterialId firstResult = MaterialId::Air;
MaterialId secondResult = MaterialId::Air;
std::int16_t minimumTemperature = -32768;
std::int16_t heatDelta = 0;
std::int32_t priority = 0;
};
/** @brief Owning immutable material and reaction table used by one PixelWorld. */
class MaterialCatalog {
public:
/** @brief Validate and own a complete catalog candidate transactionally. */
[[nodiscard]] static eve::Result<MaterialCatalog> create(
std::vector<MaterialDefinition> definitions,
std::vector<MaterialReactionRule> reactions = {},
std::vector<MaterialPhaseRule> phaseRules = {});
/** @brief Construct the engine's canonical built-in catalog. */
static MaterialCatalog builtIn();
/** @brief Resolve a stable name or return a structured NotFound diagnostic. */
[[nodiscard]] eve::Result<MaterialId> resolve(std::string_view name) const;
/** @brief Resolve a validated id; invalid ids safely project to air. */
const MaterialDefinition& definition(MaterialId id) const noexcept;
/** @brief Borrow immutable definitions until this catalog is destroyed. */
std::span<const MaterialDefinition> definitions() const noexcept;
/** @brief Borrow immutable canonical-priority reaction rules. */
std::span<const MaterialReactionRule> reactions() const noexcept;
/** @brief Borrow immutable canonical-priority phase rules. */
std::span<const MaterialPhaseRule> phaseRules() const noexcept;
/** @brief Whether this material participates in at least one binary reaction. */
bool canReact(MaterialId id) const noexcept;
/** @brief Whether this material owns at least one phase transition rule. */
bool canPhaseTransition(MaterialId id) const noexcept;
/** @brief Stable deterministic fingerprint included in world snapshots. */
std::uint64_t fingerprint() const noexcept;
private:
std::vector<MaterialDefinition> definitions_;
std::vector<MaterialReactionRule> reactions_;
std::vector<MaterialPhaseRule> phaseRules_;
std::vector<std::uint8_t> reactionMask_;
std::vector<std::uint8_t> phaseMask_;
std::uint64_t fingerprint_ = 0;
};
} // namespace eve::pixelworld
@@ -0,0 +1,296 @@
#include "pixelworld/PixelMaterialCatalogCodec.h"
#include <Poco/Dynamic/Var.h>
#include <Poco/JSON/Array.h>
#include <Poco/JSON/Object.h>
#include <Poco/JSON/Parser.h>
#include <Poco/JSON/Stringifier.h>
#include <algorithm>
#include <limits>
#include <sstream>
#include <stdexcept>
#include <type_traits>
#include <unordered_set>
namespace eve::pixelworld {
namespace {
using Object = Poco::JSON::Object;
using Array = Poco::JSON::Array;
eve::Result<MaterialCatalog> malformed(std::string message, std::string path = "document") {
return eve::Result<MaterialCatalog>::failure(eve::Diagnostic::error(
eve::DiagnosticCode::SerializationError, std::move(message), std::move(path), {},
"pixelworld.catalog-codec"));
}
bool exactFields(const Object& object, std::initializer_list<std::string_view> allowed) {
std::unordered_set<std::string> names;
for (const auto name : allowed) names.emplace(name);
if (object.size() != names.size()) return false;
for (const auto& name : object.getNames())
if (!names.contains(name)) return false;
for (const auto& name : names)
if (!object.has(name)) return false;
return true;
}
std::string stateName(MaterialState state) {
switch (state) {
case MaterialState::Empty: return "empty";
case MaterialState::Solid: return "solid";
case MaterialState::Powder: return "powder";
case MaterialState::Liquid: return "liquid";
case MaterialState::Gas: return "gas";
case MaterialState::Energy: return "energy";
}
return "empty";
}
bool parseState(const std::string& name, MaterialState& state) {
if (name == "empty") state = MaterialState::Empty;
else if (name == "solid") state = MaterialState::Solid;
else if (name == "powder") state = MaterialState::Powder;
else if (name == "liquid") state = MaterialState::Liquid;
else if (name == "gas") state = MaterialState::Gas;
else if (name == "energy") state = MaterialState::Energy;
else return false;
return true;
}
template <class T>
bool readNumber(const Object& object, const std::string& key, T& result) {
static_assert(std::is_integral_v<T> && !std::is_same_v<T, bool>);
if (!object.has(key) || object.isNull(key)) return false;
try {
const auto wide = object.get(key).convert<std::int64_t>();
if constexpr (std::is_unsigned_v<T>) {
if (wide < 0 || std::uint64_t(wide) > std::numeric_limits<T>::max()) return false;
} else if (wide < std::numeric_limits<T>::min() ||
wide > std::numeric_limits<T>::max()) {
return false;
}
result = static_cast<T>(wide);
return true;
} catch (...) {
return false;
}
}
Object::Ptr materialJson(const MaterialDefinition& material) {
Object::Ptr out(new Object);
out->set("id", std::uint16_t(material.id));
out->set("name", material.name);
out->set("state", stateName(material.state));
out->set("density", material.density);
out->set("viscosity", material.viscosity);
out->set("thermalConductivity", material.thermalConductivity);
out->set("heatCapacity", material.heatCapacity);
out->set("ignitionTemperature", material.ignitionTemperature);
out->set("meltingTemperature", material.meltingTemperature);
out->set("boilingTemperature", material.boilingTemperature);
out->set("defaultLifetime", material.defaultLifetime);
out->set("flammable", material.flammable);
out->set("defaultTemperature", material.defaultTemperature);
out->set("blastResistance", material.blastResistance);
out->set("displayRgba", material.displayRgba);
Array::Ptr tags(new Array);
for (const auto& tag : material.tags) tags->add(tag);
out->set("tags", tags);
return out;
}
} // namespace
eve::Result<std::string> encodeMaterialCatalogJson(const MaterialCatalog& catalog) {
Object::Ptr root(new Object);
root->set("schema", "eve.pixelworld.material-catalog");
root->set("version", 1);
Array::Ptr materials(new Array);
for (const auto& material : catalog.definitions()) materials->add(materialJson(material));
root->set("materials", materials);
Array::Ptr reactions(new Array);
for (const auto& reaction : catalog.reactions()) {
Object::Ptr value(new Object);
value->set("id", reaction.id);
value->set("first", std::uint16_t(reaction.first));
value->set("second", std::uint16_t(reaction.second));
value->set("firstResult", std::uint16_t(reaction.firstResult));
value->set("secondResult", std::uint16_t(reaction.secondResult));
value->set("minimumTemperature", reaction.minimumTemperature);
value->set("heatDelta", reaction.heatDelta);
value->set("priority", reaction.priority);
reactions->add(value);
}
root->set("reactions", reactions);
Array::Ptr phases(new Array);
for (const auto& phase : catalog.phaseRules()) {
Object::Ptr value(new Object);
value->set("id", phase.id);
value->set("source", std::uint16_t(phase.source));
value->set("result", std::uint16_t(phase.result));
value->set("direction", phase.direction == TemperatureDirection::AtOrBelow
? "at_or_below"
: "at_or_above");
value->set("threshold", phase.threshold);
value->set("temperatureDelta", phase.temperatureDelta);
value->set("priority", phase.priority);
phases->add(value);
}
root->set("phaseRules", phases);
std::ostringstream text;
Poco::JSON::Stringifier::stringify(Poco::Dynamic::Var(root), text, 2, -1);
return eve::Result<std::string>::success(text.str());
}
eve::Result<MaterialCatalog> decodeMaterialCatalogJson(std::string_view json) {
Object::Ptr root;
try {
root = Poco::JSON::Parser().parse(std::string(json)).extract<Object::Ptr>();
} catch (...) {
return malformed("material Catalog JSON is malformed");
}
if (!root || !exactFields(*root, {"schema", "version", "materials", "reactions", "phaseRules"}))
return malformed("root contains missing or unknown fields");
if (root->optValue<std::string>("schema", "") != "eve.pixelworld.material-catalog")
return malformed("unknown material Catalog schema", "schema");
int version = 0;
if (!readNumber(*root, "version", version) || version != 1)
return malformed("unknown material Catalog version", "version");
Array::Ptr materials;
Array::Ptr reactions;
Array::Ptr phases;
try {
materials = root->getArray("materials");
reactions = root->getArray("reactions");
phases = root->getArray("phaseRules");
} catch (...) {
return malformed("material Catalog arrays are invalid");
}
if (!materials || !reactions || !phases || materials->size() > 65535U ||
reactions->size() > 1'000'000U || phases->size() > 1'000'000U)
return malformed("material Catalog arrays are missing or exceed their budgets");
std::vector<MaterialDefinition> definitions;
definitions.reserve(materials->size());
for (std::size_t index = 0; index < materials->size(); ++index) {
Object::Ptr value;
try {
value = materials->getObject(unsigned(index));
} catch (...) {
return malformed("material entry is not an object",
"materials[" + std::to_string(index) + "]");
}
if (!value || !exactFields(*value, {"id", "name", "state", "density", "viscosity",
"thermalConductivity", "heatCapacity", "ignitionTemperature",
"meltingTemperature", "boilingTemperature", "defaultLifetime",
"flammable", "defaultTemperature", "blastResistance",
"displayRgba", "tags"}))
return malformed("material contains missing or unknown fields",
"materials[" + std::to_string(index) + "]");
MaterialDefinition material;
std::uint16_t id = 0;
if (!readNumber(*value, "id", id) || !readNumber(*value, "density", material.density) ||
!readNumber(*value, "viscosity", material.viscosity) ||
!readNumber(*value, "thermalConductivity", material.thermalConductivity) ||
!readNumber(*value, "heatCapacity", material.heatCapacity) ||
!readNumber(*value, "ignitionTemperature", material.ignitionTemperature) ||
!readNumber(*value, "meltingTemperature", material.meltingTemperature) ||
!readNumber(*value, "boilingTemperature", material.boilingTemperature) ||
!readNumber(*value, "defaultLifetime", material.defaultLifetime) ||
!readNumber(*value, "defaultTemperature", material.defaultTemperature) ||
!readNumber(*value, "blastResistance", material.blastResistance) ||
!readNumber(*value, "displayRgba", material.displayRgba))
return malformed("material contains an invalid number",
"materials[" + std::to_string(index) + "]");
material.id = MaterialId(id);
material.name = value->optValue<std::string>("name", "");
if (!parseState(value->optValue<std::string>("state", ""), material.state))
return malformed("material state is unknown",
"materials[" + std::to_string(index) + "].state");
if (!value->has("flammable") || !value->has("tags"))
return malformed("material fields are missing",
"materials[" + std::to_string(index) + "]");
try {
material.flammable = value->getValue<bool>("flammable");
Array::Ptr tags = value->getArray("tags");
if (!tags || tags->size() > 1024U) throw std::runtime_error("tags");
for (std::size_t tag = 0; tag < tags->size(); ++tag)
material.tags.push_back(tags->getElement<std::string>(unsigned(tag)));
} catch (...) {
return malformed("material tags or flammable value are invalid",
"materials[" + std::to_string(index) + "]");
}
definitions.push_back(std::move(material));
}
std::vector<MaterialReactionRule> reactionRules;
reactionRules.reserve(reactions->size());
for (std::size_t index = 0; index < reactions->size(); ++index) {
Object::Ptr value;
try {
value = reactions->getObject(unsigned(index));
} catch (...) {
return malformed("reaction entry is not an object",
"reactions[" + std::to_string(index) + "]");
}
if (!value || !exactFields(*value, {"id", "first", "second", "firstResult", "secondResult",
"minimumTemperature", "heatDelta", "priority"}))
return malformed("reaction contains missing or unknown fields",
"reactions[" + std::to_string(index) + "]");
MaterialReactionRule rule;
std::uint16_t first = 0, second = 0, firstResult = 0, secondResult = 0;
rule.id = value->optValue<std::string>("id", "");
if (!readNumber(*value, "first", first) || !readNumber(*value, "second", second) ||
!readNumber(*value, "firstResult", firstResult) ||
!readNumber(*value, "secondResult", secondResult) ||
!readNumber(*value, "minimumTemperature", rule.minimumTemperature) ||
!readNumber(*value, "heatDelta", rule.heatDelta) ||
!readNumber(*value, "priority", rule.priority))
return malformed("reaction contains an invalid number",
"reactions[" + std::to_string(index) + "]");
rule.first = MaterialId(first);
rule.second = MaterialId(second);
rule.firstResult = MaterialId(firstResult);
rule.secondResult = MaterialId(secondResult);
reactionRules.push_back(std::move(rule));
}
std::vector<MaterialPhaseRule> phaseRules;
phaseRules.reserve(phases->size());
for (std::size_t index = 0; index < phases->size(); ++index) {
Object::Ptr value;
try {
value = phases->getObject(unsigned(index));
} catch (...) {
return malformed("phase rule entry is not an object",
"phaseRules[" + std::to_string(index) + "]");
}
if (!value || !exactFields(*value, {"id", "source", "result", "direction", "threshold",
"temperatureDelta", "priority"}))
return malformed("phase rule contains missing or unknown fields",
"phaseRules[" + std::to_string(index) + "]");
MaterialPhaseRule rule;
std::uint16_t source = 0, result = 0;
rule.id = value->optValue<std::string>("id", "");
const std::string direction = value->optValue<std::string>("direction", "");
if (direction == "at_or_below") rule.direction = TemperatureDirection::AtOrBelow;
else if (direction == "at_or_above") rule.direction = TemperatureDirection::AtOrAbove;
else return malformed("phase direction is unknown",
"phaseRules[" + std::to_string(index) + "].direction");
if (!readNumber(*value, "source", source) || !readNumber(*value, "result", result) ||
!readNumber(*value, "threshold", rule.threshold) ||
!readNumber(*value, "temperatureDelta", rule.temperatureDelta) ||
!readNumber(*value, "priority", rule.priority))
return malformed("phase rule contains an invalid number",
"phaseRules[" + std::to_string(index) + "]");
rule.source = MaterialId(source);
rule.result = MaterialId(result);
phaseRules.push_back(std::move(rule));
}
return MaterialCatalog::create(std::move(definitions), std::move(reactionRules),
std::move(phaseRules));
}
} // namespace eve::pixelworld
@@ -0,0 +1,21 @@
#pragma once
#include "common/Result.h"
#include "pixelworld/PixelMaterial.h"
#include <string>
#include <string_view>
namespace eve::pixelworld {
/** @brief Encode schema `eve.pixelworld.material-catalog` version 1 canonically. */
[[nodiscard]] eve::Result<std::string> encodeMaterialCatalogJson(const MaterialCatalog& catalog);
/**
* @brief Decode and validate schema `eve.pixelworld.material-catalog` version 1.
* @remarks Unknown fields, versions, invalid references and malformed values are rejected
* before a MaterialCatalog is returned; no live world is mutated by this operation.
*/
[[nodiscard]] eve::Result<MaterialCatalog> decodeMaterialCatalogJson(std::string_view json);
} // namespace eve::pixelworld
File diff suppressed because it is too large. Load diff
+361
View File
@@ -0,0 +1,361 @@
#pragma once
#include "common/Result.h"
#include "common/Time.h"
#include "pixelworld/PixelMaterial.h"
#include <cstddef>
#include <cstdint>
#include <functional>
#include <memory>
#include <span>
#include <vector>
namespace eve::pixelworld {
class PixelWorldControlService;
inline constexpr int kPixelChunkSize = 64;
inline constexpr std::uint8_t kPixelSleepHysteresisTicks = 3;
/** @brief Compact authoritative state for one simulated world pixel. */
struct PixelCell {
MaterialId material = MaterialId::Air;
std::int16_t temperature = 20;
std::uint8_t lifetime = 0;
/** @brief Canonical sub-degree thermal energy in [0, material heatCapacity). */
std::uint16_t thermalRemainder = 0;
friend bool operator==(const PixelCell&, const PixelCell&) = default;
};
/** @brief Counters produced by one deterministic simulation step. */
struct StepStats {
eve::SimulationTick tick = eve::SimulationTick::zero();
std::uint32_t chunksVisited = 0;
std::uint32_t cellsVisited = 0;
std::uint32_t cellsChanged = 0;
std::uint32_t cellsMoved = 0;
std::uint32_t temperatureTransfers = 0;
std::uint64_t thermalEnergyTransferred = 0;
std::uint64_t thermalEnergyClamped = 0;
std::uint32_t phaseChanges = 0;
std::uint32_t reactions = 0;
std::uint32_t parallelTasks = 0;
std::uint32_t chunksReclaimed = 0;
};
/**
* @brief Synchronous executor for owning PixelWorld candidate buffers.
*
* Implementations may schedule indices on worker threads but must join before
* returning. PixelWorld controls the callback, which reads immutable phase state
* and writes only its index-owned buffer; implementations must not retain it.
*/
class PixelWorkScheduler {
public:
virtual ~PixelWorkScheduler() = default;
/** @brief Execute every index in [0, workItems) exactly once and synchronously join. */
virtual void parallelFor(std::size_t workItems,
const std::function<void(std::size_t)>& body) = 0;
/** @brief Observable worker capacity used for diagnostics only. */
[[nodiscard]] virtual std::size_t workerCount() const noexcept = 0;
};
/**
* @brief Owning, immutable projection of one authoritative simulation chunk.
*
* The snapshot remains valid independently of subsequent world mutation. Cells
* are row-major and contain `kPixelChunkSize * kPixelChunkSize` entries unless
* `removed` is true, in which case cells is empty and consumers must erase projection.
*/
struct PixelChunkSnapshot {
int x = 0;
int y = 0;
std::uint64_t revision = 0;
bool removed = false;
std::vector<PixelCell> cells;
friend bool operator==(const PixelChunkSnapshot&, const PixelChunkSnapshot&) = default;
};
/** @brief Owning authoritative Chunk correction with source world metadata. */
struct PixelChunkBatch {
std::uint64_t catalogFingerprint = 0;
std::uint64_t sourceSeed = 0;
std::uint64_t sourceRevision = 0;
eve::SimulationTick sourceTick = eve::SimulationTick::zero();
std::uint64_t sourceLastEditSequence = 0;
/** @brief Replace the replica projection instead of applying an incremental correction. */
bool fullResync = false;
std::vector<PixelChunkSnapshot> chunks;
friend bool operator==(const PixelChunkBatch&, const PixelChunkBatch&) = default;
};
/** @brief Inclusive finite rectangle expressed in Chunk coordinates. */
struct PixelChunkRegion {
int minX = 0;
int minY = 0;
int maxX = -1;
int maxY = -1;
friend bool operator==(const PixelChunkRegion&, const PixelChunkRegion&) = default;
};
/** @brief Read-only per-Chunk simulation diagnostics for tooling and overlays. */
struct PixelChunkDiagnostic {
int x = 0;
int y = 0;
std::uint64_t revision = 0;
std::uint32_t nonAirCells = 0;
std::uint32_t mobileCells = 0;
std::int16_t minimumTemperature = 20;
std::int16_t maximumTemperature = 20;
std::uint8_t idleTicks = 0;
bool active = false;
};
/** @brief Receipt for one all-or-nothing authoritative Chunk batch application. */
struct PixelChunkApplyReceipt {
std::uint64_t revisionBefore = 0;
std::uint64_t revisionAfter = 0;
std::uint32_t chunksReplaced = 0;
std::uint32_t chunksRemoved = 0;
std::uint64_t worldEpoch = 0;
};
/** @brief Receipt for one transactional compatible material Catalog hot reload. */
struct PixelCatalogReloadReceipt {
std::uint64_t fingerprintBefore = 0;
std::uint64_t fingerprintAfter = 0;
std::uint64_t revisionBefore = 0;
std::uint64_t revisionAfter = 0;
std::uint64_t worldEpoch = 0;
std::uint32_t chunksRebuilt = 0;
bool replayHistoryInvalidated = false;
};
/** @brief Canonical deterministic edit operation admitted at a Tick boundary. */
enum class PixelEditKind : std::uint8_t { PaintCircle, HeatCircle, Explosion };
/**
* @brief Owning replayable edit command. Sequence must be exactly previous + 1.
* @remarks `material` is used by PaintCircle; `strength` by Explosion; and
* `temperatureDelta` by HeatCircle/Explosion.
*/
struct PixelEditCommand {
std::uint64_t sequence = 0;
PixelEditKind kind = PixelEditKind::PaintCircle;
int centerX = 0;
int centerY = 0;
int radius = 0;
MaterialId material = MaterialId::Air;
int strength = 0;
std::int16_t temperatureDelta = 0;
};
/** @brief Deterministic receipt for one atomically accepted edit command. */
struct PixelEditReceipt {
std::uint64_t sequence = 0;
std::uint64_t revisionBefore = 0;
std::uint64_t revisionAfter = 0;
std::uint32_t cellsChanged = 0;
std::uint32_t cellsRemoved = 0;
std::uint32_t cellsHeated = 0;
};
/** @brief Finite inclusive world-space rectangle used by structural queries. */
struct PixelRegion {
int minX = 0;
int minY = 0;
int maxX = -1;
int maxY = -1;
};
/** @brief Runtime identity of the world epoch from which a fragment was detached. */
struct PixelWorldLink {
std::uint64_t world = 0;
std::uint64_t epoch = 0;
friend bool operator==(const PixelWorldLink&, const PixelWorldLink&) = default;
};
/**
* @brief Owning material bitmap detached atomically from a PixelWorld.
* @remarks `cells` is row-major in the tight `width` by `height` bounds. Air
* preserves holes. The artifact remains readable after its source world dies;
* its link is accepted for rasterization only by the same live world epoch.
*/
struct PixelFragment {
PixelWorldLink source;
std::uint64_t id = 0;
int originX = 0;
int originY = 0;
int width = 0;
int height = 0;
std::uint32_t solidCellCount = 0;
std::vector<PixelCell> cells;
};
/** @brief Receipt for one all-or-nothing fragment rasterization. */
struct PixelFragmentRasterReceipt {
std::uint64_t fragmentId = 0;
std::uint64_t revisionBefore = 0;
std::uint64_t revisionAfter = 0;
std::uint32_t cellsPlaced = 0;
};
/**
* @brief Sparse, chunked, deterministic 2D falling-material world.
*
* PixelWorld is the sole authority for material state. It is simulation-thread
* affine and invokes no callbacks. Rendering and physics consume snapshots or
* queries through adapters; neither owns a mutable copy of the world.
*/
class PixelWorld {
public:
/** @brief Construct an empty world with an explicit deterministic seed. */
explicit PixelWorld(std::uint64_t seed = 1);
/** @brief Construct an empty world owning a validated immutable material catalog. */
PixelWorld(std::uint64_t seed, MaterialCatalog catalog);
~PixelWorld();
PixelWorld(PixelWorld&&) noexcept;
PixelWorld& operator=(PixelWorld&&) noexcept;
PixelWorld(const PixelWorld&) = delete;
PixelWorld& operator=(const PixelWorld&) = delete;
/** @brief Return the cell at world coordinates; absent chunks read as air. */
PixelCell getCell(int x, int y) const noexcept;
/** @brief Convenience query returning the numeric material id. */
int getMaterial(int x, int y) const noexcept;
/** @brief Query whether a validated material id participates in structural solid collision. */
bool isSolidMaterial(MaterialId material) const noexcept;
/** @brief Query the Catalog-owned preview/render color encoded as 0xRRGGBBAA. */
std::uint32_t materialDisplayRgba(MaterialId material) const noexcept;
/**
* @brief Transactionally hot-reload a compatible Catalog while paused.
* @remarks Material count, ids and names must remain stable so existing cells keep their
* meaning. Success rebuilds Chunk metadata, advances epoch, and invalidates prior replay
* history because rule semantics changed. Failure leaves all observable state unchanged.
*/
[[nodiscard]] eve::Result<PixelCatalogReloadReceipt> reloadMaterialCatalog(
MaterialCatalog catalog, std::uint64_t expectedFingerprint);
/** @brief Set one cell and wake its chunk and neighbors. Air never allocates a missing chunk. */
void setCell(int x, int y, PixelCell cell);
/** @brief Set one cell from a stable built-in material name. */
void setMaterial(int x, int y, std::string_view material);
/** @brief Canonical checked name-based setter; unknown names leave the world unchanged. */
[[nodiscard]] eve::Result<void> setMaterialChecked(int x, int y, std::string_view material);
/** @brief Paint a filled material circle and return the number of changed cells. */
std::size_t paintCircle(int centerX, int centerY, int radius, std::string_view material);
/** @brief Canonical checked circle edit; unknown names leave the world unchanged. */
[[nodiscard]] eve::Result<std::size_t> paintCircleChecked(int centerX, int centerY, int radius,
std::string_view material);
/**
* @brief Validate and atomically apply one strictly sequenced edit command.
* @remarks Simulation-thread affine; invokes no callbacks. Rejection leaves
* material cells, revision and last edit sequence unchanged.
*/
[[nodiscard]] eve::Result<PixelEditReceipt> applyEdit(const PixelEditCommand& command);
/** @brief Script/demo explosion facade that emits the next command sequence. */
PixelEditReceipt explode(int centerX, int centerY, int radius, int strength,
std::int16_t temperatureDelta);
/**
* @brief Atomically detach unsupported solid components inside a finite region.
* @param region Inclusive scan bounds; area is limited to prevent accidental unbounded work.
* @param supportY Components touching this y coordinate or below remain authoritative terrain.
* @param minimumCells Components smaller than this remain terrain.
* @return Owning fragments in canonical top-left order. A component touching solid terrain
* outside `region` is treated as supported. Failure leaves the world unchanged.
*/
[[nodiscard]] eve::Result<std::vector<PixelFragment>> extractUnsupportedFragments(
PixelRegion region, int supportY, std::uint32_t minimumCells = 1);
/**
* @brief Transactionally rasterize a detached fragment at a new world-space origin.
* @remarks Rejects stale/wrong-world links and any overlap with non-air cells. The fragment
* is borrowed for the call and is never retained or mutated.
*/
[[nodiscard]] eve::Result<PixelFragmentRasterReceipt> rasterizeFragment(
const PixelFragment& fragment, int originX, int originY);
/** @brief Current runtime link used for stale fragment detection. */
PixelWorldLink worldLink() const noexcept;
/** @brief Remove all chunks and reset the simulation tick. */
void clear() noexcept;
/**
* @brief Advance exactly one scheduler-owned tick.
* @return Deterministic counters for the completed step.
*/
[[nodiscard]] eve::Result<StepStats> advance(eve::SimulationTick tick);
/**
* @brief Advance one deterministic tick using a synchronous candidate scheduler.
* @remarks Simulation-thread affine. Worker callbacks receive owning output slots,
* never mutate chunks, and are joined before canonical commit.
*/
[[nodiscard]] eve::Result<StepStats> advanceScheduled(eve::SimulationTick tick,
PixelWorkScheduler& scheduler);
/** @brief Script/demo facade that advances the next integral tick. */
StepStats step();
/** @brief Pause or resume ordinary simulation entry points without changing state. */
void setPaused(bool paused) noexcept;
/** @brief Whether ordinary simulation entry points are currently paused. */
bool isPaused() const noexcept;
std::uint64_t seed() const noexcept;
std::uint64_t revision() const noexcept;
std::uint64_t tickValue() const noexcept;
std::uint64_t lastEditSequence() const noexcept;
int chunkCount() const noexcept;
int activeChunkCount() const noexcept;
/** @brief Fingerprint of the immutable catalog that gives cell ids their meaning. */
std::uint64_t materialCatalogFingerprint() const noexcept;
/**
* @brief Copy chunks changed after `sinceRevision` in canonical y/x order.
* @remarks Simulation-thread affine. Returned snapshots own their cells and
* remain valid after mutation, restore, or destruction of this world.
*/
std::vector<PixelChunkSnapshot> snapshotChangedChunks(std::uint64_t sinceRevision) const;
/**
* @brief Copy present Chunks and retained tombstones in one finite Chunk-coordinate region.
* @param region Inclusive bounds limited to 65536 Chunk coordinates.
* @param sinceRevision Only projections newer than this revision are returned.
* @return Canonical y/x-ordered owning projections, or bounded-input failure.
*/
[[nodiscard]] eve::Result<std::vector<PixelChunkSnapshot>> snapshotChunksInRegion(
PixelChunkRegion region, std::uint64_t sinceRevision = 0) const;
/** @brief Query allocated Chunk diagnostics in a bounded inclusive region. */
[[nodiscard]] eve::Result<std::vector<PixelChunkDiagnostic>> chunkDiagnostics(
PixelChunkRegion region) const;
/**
* @brief Transactionally apply a canonical authoritative Chunk correction batch.
* @param batch Owning source metadata and unique y/x-ordered Chunk snapshots.
* @param expectedRevision Current local revision used to reject stale writers.
* @return Commit receipt; any validation failure leaves all world state unchanged.
* @remarks The source revision, Tick and edit sequence may advance but never rewind.
* A successful correction invalidates outstanding fragment links by advancing epoch.
*/
[[nodiscard]] eve::Result<PixelChunkApplyReceipt> applyChunkBatch(
const PixelChunkBatch& batch, std::uint64_t expectedRevision);
/** @brief Serialize schema `eve.pixelworld`, version 3, in canonical Chunk order. */
[[nodiscard]] eve::Result<std::vector<std::byte>> saveSnapshot() const;
/**
* @brief Transactionally restore a version 1, 2 or 3 PixelWorld snapshot.
* @remarks Older built-in-Catalog versions migrate explicitly. Unknown versions and malformed
* input are rejected without changing this world; restore advances epoch on success.
*/
[[nodiscard]] eve::Result<void> restoreSnapshot(std::span<const std::byte> bytes);
private:
friend class PixelWorldControlService;
eve::Result<StepStats> advanceImpl(eve::SimulationTick tick, PixelWorkScheduler* scheduler);
struct Impl;
std::unique_ptr<Impl> impl_;
};
} // namespace eve::pixelworld
@@ -0,0 +1,274 @@
#include "common/Capability.h"
#include "common/PixelWorldAutomation.h"
#include "pixelworld/PixelWorldControl.h"
#include "pixelworld/PixelMaterialCatalogCodec.h"
#include <Poco/Dynamic/Var.h>
#include <Poco/JSON/Array.h>
#include <Poco/JSON/Object.h>
#include <Poco/JSON/Parser.h>
#include <Poco/JSON/Stringifier.h>
#include <algorithm>
#include <iomanip>
#include <sstream>
namespace eve::pixelworld {
namespace {
using Object = Poco::JSON::Object;
std::string stringify(Object::Ptr object) {
std::ostringstream out;
Poco::JSON::Stringifier::stringify(Poco::Dynamic::Var(object), out);
return out.str();
}
std::string stringify(const Poco::Dynamic::Var& value) {
std::ostringstream out;
Poco::JSON::Stringifier::stringify(value, out);
return out.str();
}
Object::Ptr response(bool ok) {
Object::Ptr out(new Object);
out->set("ok", ok);
return out;
}
template <class T>
std::string failure(const eve::Result<T>& result) {
Object::Ptr out = response(false);
out->set("status", std::string(eve::statusCodeName(result.status().code())));
if (result.status().hasDiagnostics())
out->set("error", result.status().diagnostics().front().message());
return stringify(out);
}
Object::Ptr parse(const std::string& json) {
try {
return Poco::JSON::Parser().parse(json).extract<Object::Ptr>();
} catch (...) {
return Object::Ptr(new Object);
}
}
std::string hex(std::span<const std::byte> bytes) {
std::ostringstream out;
out << std::hex << std::setfill('0');
for (const std::byte value : bytes) out << std::setw(2) << std::to_integer<unsigned>(value);
return out.str();
}
eve::Result<std::vector<std::byte>> unhex(const std::string& value) {
if ((value.size() & 1U) != 0)
return eve::Result<std::vector<std::byte>>::failure(eve::Diagnostic::error(
eve::DiagnosticCode::InvalidArgument, "snapshot hex length must be even", "snapshot", {},
"pixelworld.automation"));
std::vector<std::byte> bytes;
bytes.reserve(value.size() / 2);
for (std::size_t index = 0; index < value.size(); index += 2) {
unsigned parsed = 0;
std::istringstream in(value.substr(index, 2));
in >> std::hex >> parsed;
if (!in || !in.eof())
return eve::Result<std::vector<std::byte>>::failure(eve::Diagnostic::error(
eve::DiagnosticCode::InvalidArgument, "snapshot contains non-hex data", "snapshot", {},
"pixelworld.automation"));
bytes.push_back(std::byte(parsed));
}
return eve::Result<std::vector<std::byte>>::success(std::move(bytes));
}
class PixelWorldAutomation final : public eve::IPixelWorldAutomation {
public:
std::string invoke(const std::string& operation, const std::string& requestJson) override {
auto args = parse(requestJson);
auto& control = pixelWorldControlService();
if (operation == "worlds") {
Poco::JSON::Array::Ptr items(new Poco::JSON::Array);
for (const auto& world : control.worlds()) {
Object::Ptr item(new Object);
item->set("world", world.link.world);
item->set("epoch", world.link.epoch);
item->set("seed", world.seed);
item->set("revision", world.revision);
item->set("tick", world.tick);
item->set("lastEditSequence", world.lastEditSequence);
item->set("chunks", world.chunks);
item->set("activeChunks", world.activeChunks);
item->set("paused", world.paused);
items->add(item);
}
auto out = response(true);
out->set("worlds", items);
return stringify(out);
}
if (operation == "catalog_builtin") {
const MaterialCatalog catalog = MaterialCatalog::builtIn();
auto encoded = encodeMaterialCatalogJson(catalog);
if (!encoded.ok()) return failure(encoded);
auto out = response(true);
out->set("fingerprint", std::to_string(catalog.fingerprint()));
out->set("catalog", encoded.value());
return stringify(out);
}
if (operation == "catalog_validate") {
std::string document;
if (args->has("catalog")) {
const auto value = args->get("catalog");
document = value.isString() ? value.convert<std::string>() : stringify(value);
}
auto decoded = decodeMaterialCatalogJson(document);
if (!decoded.ok()) return failure(decoded);
auto canonical = encodeMaterialCatalogJson(decoded.value());
if (!canonical.ok()) return failure(canonical);
auto out = response(true);
out->set("fingerprint", std::to_string(decoded.value().fingerprint()));
out->set("materialCount", decoded.value().definitions().size());
out->set("reactionCount", decoded.value().reactions().size());
out->set("phaseRuleCount", decoded.value().phaseRules().size());
out->set("canonicalCatalog", canonical.value());
return stringify(out);
}
if (operation == "catalog_apply") {
std::string document;
if (args->has("catalog")) {
const auto value = args->get("catalog");
document = value.isString() ? value.convert<std::string>() : stringify(value);
}
std::uint64_t expected = 0;
try {
expected = std::stoull(args->optValue<std::string>("expectedFingerprint", "0"));
} catch (...) {
auto out = response(false);
out->set("error", "expectedFingerprint must be an unsigned decimal string");
return stringify(out);
}
auto result = control.reloadMaterialCatalog(
args->optValue<std::uint64_t>("world", 0), document, expected);
if (!result.ok()) return failure(result);
auto out = response(true);
out->set("fingerprintBefore", std::to_string(result.value().fingerprintBefore));
out->set("fingerprintAfter", std::to_string(result.value().fingerprintAfter));
out->set("revisionAfter", result.value().revisionAfter);
out->set("worldEpoch", result.value().worldEpoch);
out->set("chunksRebuilt", result.value().chunksRebuilt);
out->set("replayHistoryInvalidated", result.value().replayHistoryInvalidated);
return stringify(out);
}
const auto worldId = args->optValue<std::uint64_t>("world", 0);
if (operation == "pause") {
auto result = control.setPaused(worldId, args->optValue<bool>("paused", true));
if (!result.ok()) return failure(result);
auto out = response(true);
out->set("paused", control.world(worldId).value().paused);
return stringify(out);
}
if (operation == "step") {
auto result = control.step(worldId, args->optValue<std::uint32_t>("count", 1));
if (!result.ok()) return failure(result);
const auto& receipt = result.value();
auto out = response(true);
out->set("steps", receipt.steps);
out->set("firstTick", receipt.firstTick);
out->set("lastTick", receipt.lastTick);
out->set("elapsedMicroseconds", receipt.elapsedMicroseconds);
out->set("cellsChanged", receipt.finalStep.cellsChanged);
out->set("cellsMoved", receipt.finalStep.cellsMoved);
return stringify(out);
}
if (operation == "edit") {
PixelEditCommand command;
command.sequence = args->optValue<std::uint64_t>("sequence", 0);
const std::string kind = args->optValue<std::string>("kind", "paint");
command.kind = kind == "heat" ? PixelEditKind::HeatCircle
: kind == "explosion" ? PixelEditKind::Explosion
: PixelEditKind::PaintCircle;
command.centerX = args->optValue<int>("x", 0);
command.centerY = args->optValue<int>("y", 0);
command.radius = args->optValue<int>("radius", 0);
command.material = MaterialId(args->optValue<unsigned>("material", 0));
command.strength = args->optValue<int>("strength", 0);
command.temperatureDelta = std::int16_t(std::clamp(
args->optValue<int>("temperatureDelta", 0), -32768, 32767));
auto result = control.applyEdit(worldId, command);
if (!result.ok()) return failure(result);
auto out = response(true);
out->set("revisionBefore", result.value().revisionBefore);
out->set("revisionAfter", result.value().revisionAfter);
out->set("cellsChanged", result.value().cellsChanged);
return stringify(out);
}
if (operation == "diagnostics") {
PixelChunkRegion region{args->optValue<int>("minX", 0), args->optValue<int>("minY", 0),
args->optValue<int>("maxX", 0), args->optValue<int>("maxY", 0)};
auto result = control.chunkDiagnostics(worldId, region);
if (!result.ok()) return failure(result);
Poco::JSON::Array::Ptr chunks(new Poco::JSON::Array);
for (const auto& diagnostic : result.value()) {
Object::Ptr item(new Object);
item->set("x", diagnostic.x);
item->set("y", diagnostic.y);
item->set("revision", diagnostic.revision);
item->set("nonAirCells", diagnostic.nonAirCells);
item->set("mobileCells", diagnostic.mobileCells);
item->set("minimumTemperature", diagnostic.minimumTemperature);
item->set("maximumTemperature", diagnostic.maximumTemperature);
item->set("idleTicks", diagnostic.idleTicks);
item->set("active", diagnostic.active);
chunks->add(item);
}
auto out = response(true);
out->set("chunks", chunks);
return stringify(out);
}
if (operation == "samples") {
auto result = control.performanceSamples(
worldId, args->optValue<std::uint32_t>("limit", 120));
if (!result.ok()) return failure(result);
Poco::JSON::Array::Ptr samples(new Poco::JSON::Array);
for (const auto& sample : result.value()) {
Object::Ptr item(new Object);
item->set("tick", sample.tick);
item->set("elapsedMicroseconds", sample.elapsedMicroseconds);
item->set("chunksVisited", sample.stats.chunksVisited);
item->set("cellsVisited", sample.stats.cellsVisited);
item->set("cellsChanged", sample.stats.cellsChanged);
item->set("cellsMoved", sample.stats.cellsMoved);
item->set("parallelTasks", sample.stats.parallelTasks);
samples->add(item);
}
auto out = response(true);
out->set("samples", samples);
return stringify(out);
}
if (operation == "snapshot_capture") {
auto result = control.captureSnapshot(worldId);
if (!result.ok()) return failure(result);
auto out = response(true);
out->set("encoding", "hex");
out->set("snapshot", hex(result.value()));
return stringify(out);
}
if (operation == "snapshot_restore") {
auto decoded = unhex(args->optValue<std::string>("snapshot", ""));
if (!decoded.ok()) return failure(decoded);
auto result = control.restoreSnapshot(worldId, decoded.value());
if (!result.ok()) return failure(result);
return stringify(response(true));
}
auto out = response(false);
out->set("error", "unknown PixelWorld automation operation");
return stringify(out);
}
};
PixelWorldAutomation automation;
} // namespace
void registerPixelWorldAutomation() { eve::cap::provide<eve::IPixelWorldAutomation>(&automation); }
void unregisterPixelWorldAutomation() { eve::cap::revoke<eve::IPixelWorldAutomation>(&automation); }
} // namespace eve::pixelworld
@@ -0,0 +1,176 @@
#include "pixelworld/PixelWorldControl.h"
#include "pixelworld/PixelMaterialCatalogCodec.h"
#include <algorithm>
#include <chrono>
#include <deque>
#include <unordered_map>
namespace eve::pixelworld {
namespace {
template <class T>
eve::Result<T> missingWorld() {
return eve::Result<T>::failure(eve::Diagnostic::error(
eve::DiagnosticCode::NotFound, "PixelWorld is not live", "world", {},
"pixelworld.control"));
}
PixelWorldSummary summarize(const PixelWorld& world) {
return {world.worldLink(), world.seed(), world.revision(), world.tickValue(),
world.lastEditSequence(), std::uint32_t(world.chunkCount()),
std::uint32_t(world.activeChunkCount()), world.isPaused()};
}
} // namespace
struct PixelWorldControlService::Impl {
std::unordered_map<std::uint64_t, PixelWorld*> worlds;
std::unordered_map<std::uint64_t, std::deque<PixelWorldPerformanceSample>> samples;
};
PixelWorldControlService::Impl& PixelWorldControlService::impl() const {
static auto* state = new Impl;
return *state;
}
PixelWorldControlService& pixelWorldControlService() {
static auto* service = new PixelWorldControlService;
return *service;
}
void PixelWorldControlService::registerWorld(PixelWorld& world) {
impl().worlds.insert_or_assign(world.worldLink().world, &world);
}
void PixelWorldControlService::unregisterWorld(const PixelWorld& world) noexcept {
const auto found = impl().worlds.find(world.worldLink().world);
if (found != impl().worlds.end() && found->second == &world) {
impl().worlds.erase(found);
impl().samples.erase(world.worldLink().world);
}
}
void PixelWorldControlService::rebindWorld(const PixelWorld* previous,
PixelWorld& replacement) noexcept {
auto& worlds = impl().worlds;
const auto found = worlds.find(replacement.worldLink().world);
if (found != worlds.end() && found->second == previous) found->second = &replacement;
}
PixelWorld* PixelWorldControlService::find(std::uint64_t worldId) const noexcept {
const auto found = impl().worlds.find(worldId);
return found == impl().worlds.end() ? nullptr : found->second;
}
void PixelWorldControlService::recordStep(std::uint64_t worldId, const StepStats& stats,
std::uint64_t elapsedMicroseconds) {
auto& samples = impl().samples[worldId];
samples.push_back({stats.tick.value(), elapsedMicroseconds, stats});
if (samples.size() > 256) samples.pop_front();
}
std::vector<PixelWorldSummary> PixelWorldControlService::worlds() const {
std::vector<PixelWorldSummary> result;
result.reserve(impl().worlds.size());
for (const auto& [id, world] : impl().worlds) result.push_back(summarize(*world));
std::sort(result.begin(), result.end(), [](const auto& a, const auto& b) {
return a.link.world < b.link.world;
});
return result;
}
eve::Result<PixelWorldSummary> PixelWorldControlService::world(std::uint64_t worldId) const {
const PixelWorld* value = find(worldId);
return value ? eve::Result<PixelWorldSummary>::success(summarize(*value))
: missingWorld<PixelWorldSummary>();
}
eve::Result<void> PixelWorldControlService::setPaused(std::uint64_t worldId, bool paused) {
PixelWorld* value = find(worldId);
if (!value) return missingWorld<void>();
value->setPaused(paused);
return eve::Result<void>::success();
}
eve::Result<PixelWorldStepReceipt> PixelWorldControlService::step(std::uint64_t worldId,
std::uint32_t count) {
PixelWorld* value = find(worldId);
if (!value) return missingWorld<PixelWorldStepReceipt>();
if (count == 0 || count > 1024)
return eve::Result<PixelWorldStepReceipt>::failure(eve::Diagnostic::error(
eve::DiagnosticCode::InvalidArgument, "step count must be in [1, 1024]", "count", {},
"pixelworld.control"));
PixelWorldStepReceipt receipt;
receipt.world = worldId;
receipt.steps = count;
receipt.firstTick = value->tickValue() + 1;
const auto start = std::chrono::steady_clock::now();
for (std::uint32_t index = 0; index < count; ++index) {
auto advanced = value->advanceImpl(eve::SimulationTick(value->tickValue() + 1), nullptr);
if (!advanced.ok()) return eve::Result<PixelWorldStepReceipt>::failure(advanced.status());
receipt.finalStep = advanced.value();
}
receipt.lastTick = value->tickValue();
receipt.elapsedMicroseconds = std::uint64_t(std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now() - start)
.count());
return eve::Result<PixelWorldStepReceipt>::success(receipt);
}
eve::Result<PixelEditReceipt> PixelWorldControlService::applyEdit(
std::uint64_t worldId, const PixelEditCommand& command) {
PixelWorld* value = find(worldId);
return value ? value->applyEdit(command) : missingWorld<PixelEditReceipt>();
}
eve::Result<PixelCatalogReloadReceipt> PixelWorldControlService::reloadMaterialCatalog(
std::uint64_t worldId, std::string_view catalogJson,
std::uint64_t expectedFingerprint) {
PixelWorld* value = find(worldId);
if (!value) return missingWorld<PixelCatalogReloadReceipt>();
auto catalog = decodeMaterialCatalogJson(catalogJson);
if (!catalog.ok())
return eve::Result<PixelCatalogReloadReceipt>::failure(catalog.status());
return value->reloadMaterialCatalog(std::move(catalog).value(), expectedFingerprint);
}
eve::Result<std::vector<PixelChunkDiagnostic>> PixelWorldControlService::chunkDiagnostics(
std::uint64_t worldId, PixelChunkRegion region) const {
const PixelWorld* value = find(worldId);
return value ? value->chunkDiagnostics(region)
: missingWorld<std::vector<PixelChunkDiagnostic>>();
}
eve::Result<std::vector<PixelWorldPerformanceSample>>
PixelWorldControlService::performanceSamples(std::uint64_t worldId, std::uint32_t limit) const {
if (!find(worldId)) return missingWorld<std::vector<PixelWorldPerformanceSample>>();
if (limit == 0 || limit > 256)
return eve::Result<std::vector<PixelWorldPerformanceSample>>::failure(eve::Diagnostic::error(
eve::DiagnosticCode::InvalidArgument, "sample limit must be in [1, 256]", "limit", {},
"pixelworld.control"));
const auto found = impl().samples.find(worldId);
if (found == impl().samples.end())
return eve::Result<std::vector<PixelWorldPerformanceSample>>::success({});
const auto& samples = found->second;
const std::size_t begin = samples.size() > limit ? samples.size() - limit : 0;
std::vector<PixelWorldPerformanceSample> result(samples.begin() + std::ptrdiff_t(begin),
samples.end());
return eve::Result<std::vector<PixelWorldPerformanceSample>>::success(
std::move(result));
}
eve::Result<std::vector<std::byte>> PixelWorldControlService::captureSnapshot(
std::uint64_t worldId) const {
const PixelWorld* value = find(worldId);
return value ? value->saveSnapshot() : missingWorld<std::vector<std::byte>>();
}
eve::Result<void> PixelWorldControlService::restoreSnapshot(
std::uint64_t worldId, std::span<const std::byte> bytes) {
PixelWorld* value = find(worldId);
return value ? value->restoreSnapshot(bytes) : missingWorld<void>();
}
} // namespace eve::pixelworld
@@ -0,0 +1,91 @@
#pragma once
#include "pixelworld/PixelWorld.h"
#include <cstdint>
#include <span>
#include <vector>
namespace eve::pixelworld {
/** @brief Stable summary of one live PixelWorld registered for local tooling. */
struct PixelWorldSummary {
PixelWorldLink link;
std::uint64_t seed = 0;
std::uint64_t revision = 0;
std::uint64_t tick = 0;
std::uint64_t lastEditSequence = 0;
std::uint32_t chunks = 0;
std::uint32_t activeChunks = 0;
bool paused = false;
};
/** @brief Receipt for an explicit bounded tooling step. */
struct PixelWorldStepReceipt {
std::uint64_t world = 0;
std::uint32_t steps = 0;
std::uint64_t firstTick = 0;
std::uint64_t lastTick = 0;
std::uint64_t elapsedMicroseconds = 0;
StepStats finalStep;
};
/** @brief One bounded process-local simulation performance sample. */
struct PixelWorldPerformanceSample {
std::uint64_t tick = 0;
std::uint64_t elapsedMicroseconds = 0;
StepStats stats;
};
/**
* @brief Main-thread-affine control surface shared by editor, CLI and MCP adapters.
* @remarks The service borrows live worlds only while they are registered by their
* constructors. PixelWorld destruction and move rebinding prevent stale retention.
*/
class PixelWorldControlService {
public:
/** @brief Return canonical world-id-ordered summaries of all live worlds. */
[[nodiscard]] std::vector<PixelWorldSummary> worlds() const;
/** @brief Query one live world summary. */
[[nodiscard]] eve::Result<PixelWorldSummary> world(std::uint64_t worldId) const;
/** @brief Pause or resume one live world. */
[[nodiscard]] eve::Result<void> setPaused(std::uint64_t worldId, bool paused);
/** @brief Advance a live world explicitly even while paused; count is limited to 1024. */
[[nodiscard]] eve::Result<PixelWorldStepReceipt> step(std::uint64_t worldId,
std::uint32_t count = 1);
/** @brief Apply the world's canonical strictly sequenced edit operation. */
[[nodiscard]] eve::Result<PixelEditReceipt> applyEdit(std::uint64_t worldId,
const PixelEditCommand& command);
/** @brief Validate JSON and transactionally hot-reload a compatible paused world Catalog. */
[[nodiscard]] eve::Result<PixelCatalogReloadReceipt> reloadMaterialCatalog(
std::uint64_t worldId, std::string_view catalogJson,
std::uint64_t expectedFingerprint);
/** @brief Copy a bounded read-only Chunk diagnostic region. */
[[nodiscard]] eve::Result<std::vector<PixelChunkDiagnostic>> chunkDiagnostics(
std::uint64_t worldId, PixelChunkRegion region) const;
/** @brief Copy up to the latest 256 simulation performance samples. */
[[nodiscard]] eve::Result<std::vector<PixelWorldPerformanceSample>> performanceSamples(
std::uint64_t worldId, std::uint32_t limit = 120) const;
/** @brief Capture an owning canonical world snapshot. */
[[nodiscard]] eve::Result<std::vector<std::byte>> captureSnapshot(std::uint64_t worldId) const;
/** @brief Transactionally restore a captured world snapshot. */
[[nodiscard]] eve::Result<void> restoreSnapshot(std::uint64_t worldId,
std::span<const std::byte> bytes);
private:
friend class PixelWorld;
void registerWorld(PixelWorld& world);
void unregisterWorld(const PixelWorld& world) noexcept;
void rebindWorld(const PixelWorld* previous, PixelWorld& replacement) noexcept;
void recordStep(std::uint64_t worldId, const StepStats& stats,
std::uint64_t elapsedMicroseconds);
[[nodiscard]] PixelWorld* find(std::uint64_t worldId) const noexcept;
struct Impl;
Impl& impl() const;
};
/** @brief Process-lifetime PixelWorld tooling registry. */
PixelWorldControlService& pixelWorldControlService();
} // namespace eve::pixelworld
@@ -0,0 +1,201 @@
#include "pixelworld/PixelWorldGeneration.h"
#include <algorithm>
#include <limits>
#include <string>
#include <type_traits>
#include <utility>
namespace eve::pixelworld {
namespace {
std::uint64_t mix(std::uint64_t value) noexcept {
value += 0x9E3779B97F4A7C15ULL;
value = (value ^ (value >> 30U)) * 0xBF58476D1CE4E5B9ULL;
value = (value ^ (value >> 27U)) * 0x94D049BB133111EBULL;
return value ^ (value >> 31U);
}
std::uint64_t coordinateHash(std::uint64_t seed, int x, int y, std::uint64_t stream) noexcept {
return mix(seed ^ (std::uint64_t(std::uint32_t(x)) << 32U) ^
std::uint64_t(std::uint32_t(y)) ^ stream);
}
int floorDivGeneration(int value, int divisor) noexcept {
return value >= 0 ? value / divisor : -((-value + divisor - 1) / divisor);
}
int interpolatedNoise(std::uint64_t seed, int coordinate, int spacing,
std::uint64_t stream) noexcept {
const int lattice = floorDivGeneration(coordinate, spacing);
const int offset = coordinate - lattice * spacing;
const int first = int(coordinateHash(seed, lattice, 0, stream) % 20001ULL) - 10000;
const int second = int(coordinateHash(seed, lattice + 1, 0, stream) % 20001ULL) - 10000;
return (first * (spacing - offset) + second * offset) / spacing;
}
PixelBiome biomeAt(std::uint64_t seed, int worldX) noexcept {
const int band = floorDivGeneration(worldX, 192);
return PixelBiome(coordinateHash(seed, band, 0, 0x42494F4D45ULL) % 4ULL);
}
PixelCell defaultCell(const MaterialCatalog& catalog, MaterialId material) {
PixelCell cell;
cell.material = material;
const auto& definition = catalog.definition(material);
cell.temperature = definition.defaultTemperature;
cell.lifetime = std::uint8_t(std::min<std::uint16_t>(
definition.defaultLifetime, std::numeric_limits<std::uint8_t>::max()));
return cell;
}
MaterialId terrainMaterial(PixelBiome biome, int depth, std::uint64_t feature) noexcept {
if (biome == PixelBiome::Desert && depth < 10) return MaterialId::Sand;
if (biome == PixelBiome::Fungal && depth < 4) return MaterialId::Wood;
if (biome == PixelBiome::Volcanic && depth > 18 && feature % 97ULL == 0)
return MaterialId::Lava;
return MaterialId::Stone;
}
eve::Result<PixelWorldGenerationOutput> invalidGeneration(std::string message,
std::string path) {
return eve::Result<PixelWorldGenerationOutput>::failure(eve::Diagnostic::error(
eve::DiagnosticCode::InvalidArgument, std::move(message), std::move(path), {},
"pixelworld.generation"));
}
void hashByte(std::uint64_t& hash, std::uint8_t value) noexcept {
hash ^= value;
hash *= 1099511628211ULL;
}
template <class T>
void hashValue(std::uint64_t& hash, T value) noexcept {
using Unsigned = std::make_unsigned_t<T>;
const Unsigned bits = static_cast<Unsigned>(value);
for (std::size_t index = 0; index < sizeof(T); ++index)
hashByte(hash, std::uint8_t(bits >> (index * 8U)));
}
} // namespace
eve::Result<PixelWorldGenerationOutput> generatePixelWorld(
const PixelWorldGenerationRequest& request, const MaterialCatalog& catalog,
std::uint64_t sourceRevision, eve::SimulationTick sourceTick,
std::uint64_t sourceLastEditSequence) {
if (request.schemaVersion != PixelWorldGenerationRequest::kSchemaVersion)
return invalidGeneration("unsupported PixelWorld generation schema version", "schemaVersion");
if (request.region.minX > request.region.maxX || request.region.minY > request.region.maxY)
return invalidGeneration("generation region bounds are inverted", "region");
const std::int64_t width = std::int64_t(request.region.maxX) - request.region.minX + 1;
const std::int64_t height = std::int64_t(request.region.maxY) - request.region.minY + 1;
if (width * height > 65'536)
return invalidGeneration("generation region exceeds 65536 Chunks", "region");
if (sourceRevision == 0)
return invalidGeneration("source revision must be positive", "sourceRevision");
if (catalog.definitions().size() <= std::size_t(MaterialId::Lava))
return invalidGeneration("generation Catalog must provide the built-in terrain material ids",
"catalog");
if (request.terrainAmplitude < 0 || request.terrainAmplitude > 4096)
return invalidGeneration("terrain amplitude must be in [0, 4096]", "terrainAmplitude");
if (request.caveThreshold > 10'000)
return invalidGeneration("cave threshold must be in [0, 10000]", "caveThreshold");
std::uint64_t stampCells = 0;
for (std::size_t index = 0; index < request.stamps.size(); ++index) {
const auto& stamp = request.stamps[index];
if (stamp.width <= 0 || stamp.height <= 0 ||
std::uint64_t(stamp.width) * std::uint64_t(stamp.height) != stamp.cells.size())
return invalidGeneration("stamp dimensions must match its row-major cells",
"stamps[" + std::to_string(index) + "]");
stampCells += stamp.cells.size();
if (stampCells > 4'194'304)
return invalidGeneration("material stamps exceed the cell budget", "stamps");
for (const PixelCell cell : stamp.cells)
if (std::size_t(cell.material) >= catalog.definitions().size())
return invalidGeneration("stamp references an unknown material",
"stamps[" + std::to_string(index) + "].cells");
}
PixelWorldGenerationOutput output;
output.batch.catalogFingerprint = catalog.fingerprint();
output.batch.sourceSeed = request.seed;
output.batch.sourceRevision = sourceRevision;
output.batch.sourceTick = sourceTick;
output.batch.sourceLastEditSequence = sourceLastEditSequence;
output.batch.chunks.reserve(std::size_t(width * height));
output.chunks.reserve(std::size_t(width * height));
std::uint64_t hash = 1469598103934665603ULL;
for (int chunkY = request.region.minY; chunkY <= request.region.maxY; ++chunkY)
for (int chunkX = request.region.minX; chunkX <= request.region.maxX; ++chunkX) {
PixelChunkSnapshot snapshot;
snapshot.x = chunkX;
snapshot.y = chunkY;
snapshot.revision = sourceRevision;
snapshot.cells.resize(std::size_t(kPixelChunkSize * kPixelChunkSize));
PixelGeneratedChunkSummary summary;
summary.x = chunkX;
summary.y = chunkY;
summary.biome = biomeAt(request.seed, chunkX * kPixelChunkSize + kPixelChunkSize / 2);
for (int localY = 0; localY < kPixelChunkSize; ++localY)
for (int localX = 0; localX < kPixelChunkSize; ++localX) {
const int worldX = chunkX * kPixelChunkSize + localX;
const int worldY = chunkY * kPixelChunkSize + localY;
const PixelBiome biome = biomeAt(request.seed, worldX);
const int surface = request.surfaceY +
interpolatedNoise(request.seed, worldX, 32, 0x53555246414345ULL) *
request.terrainAmplitude / 10000;
PixelCell cell;
if (worldY >= surface) {
const int depth = worldY - surface;
const std::uint64_t caveNoise = coordinateHash(
request.seed, floorDivGeneration(worldX, 5),
floorDivGeneration(worldY, 5), 0x43415645ULL) % 10001ULL;
const bool cave = depth > 5 && caveNoise < request.caveThreshold;
if (cave) {
++summary.caveCells;
if (worldY >= request.waterLevel &&
coordinateHash(request.seed, worldX, worldY, 0x5741544552ULL) % 5ULL == 0)
cell = defaultCell(catalog, MaterialId::Water);
} else {
const std::uint64_t feature = coordinateHash(
request.seed, worldX, worldY, 0x46454154555245ULL);
cell = defaultCell(catalog, terrainMaterial(biome, depth, feature));
++summary.solidCells;
}
}
snapshot.cells[std::size_t(localY * kPixelChunkSize + localX)] = cell;
}
const int chunkMinX = chunkX * kPixelChunkSize;
const int chunkMinY = chunkY * kPixelChunkSize;
for (const PixelMaterialStamp& stamp : request.stamps)
for (int stampY = 0; stampY < stamp.height; ++stampY)
for (int stampX = 0; stampX < stamp.width; ++stampX) {
const int worldX = stamp.originX + stampX;
const int worldY = stamp.originY + stampY;
if (worldX < chunkMinX || worldX >= chunkMinX + kPixelChunkSize ||
worldY < chunkMinY || worldY >= chunkMinY + kPixelChunkSize)
continue;
snapshot.cells[std::size_t(worldY - chunkMinY) * kPixelChunkSize +
std::size_t(worldX - chunkMinX)] =
stamp.cells[std::size_t(stampY) * std::size_t(stamp.width) + stampX];
++summary.stampedCells;
}
hashValue(hash, std::int32_t(chunkX));
hashValue(hash, std::int32_t(chunkY));
for (const PixelCell cell : snapshot.cells) {
hashValue(hash, std::uint16_t(cell.material));
hashValue(hash, cell.temperature);
hashValue(hash, cell.lifetime);
hashValue(hash, cell.thermalRemainder);
}
output.batch.chunks.push_back(std::move(snapshot));
output.chunks.push_back(summary);
}
output.contentHash = hash;
return eve::Result<PixelWorldGenerationOutput>::success(std::move(output));
}
} // namespace eve::pixelworld
@@ -0,0 +1,73 @@
#pragma once
#include "pixelworld/PixelWorld.h"
#include <cstdint>
#include <vector>
namespace eve::pixelworld {
/** @brief Stable coarse biome selected from deterministic world coordinates. */
enum class PixelBiome : std::uint8_t { Forest, Desert, Fungal, Volcanic };
/** @brief Owning row-major material stamp applied after terrain and cave generation. */
struct PixelMaterialStamp {
int originX = 0;
int originY = 0;
int width = 0;
int height = 0;
std::vector<PixelCell> cells;
};
/** @brief Versioned bounded request for deterministic biome and cave generation. */
struct PixelWorldGenerationRequest {
static constexpr std::uint32_t kSchemaVersion = 1;
std::uint32_t schemaVersion = kSchemaVersion;
std::uint64_t seed = 1;
PixelChunkRegion region;
int surfaceY = 0;
int terrainAmplitude = 24;
int waterLevel = 36;
/** @brief Cave admission threshold in [0, 10000]; larger values carve more cells. */
std::uint32_t caveThreshold = 3600;
std::vector<PixelMaterialStamp> stamps;
};
/** @brief Deterministic diagnostics for one generated Chunk. */
struct PixelGeneratedChunkSummary {
int x = 0;
int y = 0;
PixelBiome biome = PixelBiome::Forest;
std::uint32_t solidCells = 0;
std::uint32_t caveCells = 0;
std::uint32_t stampedCells = 0;
friend bool operator==(const PixelGeneratedChunkSummary&,
const PixelGeneratedChunkSummary&) = default;
};
/** @brief Owning generated correction plus reproducibility evidence. */
struct PixelWorldGenerationOutput {
PixelChunkBatch batch;
std::vector<PixelGeneratedChunkSummary> chunks;
std::uint64_t contentHash = 0;
};
/**
* @brief Generate a seamless canonical Chunk batch without mutating a PixelWorld.
* @param request Versioned finite generation request and optional owning stamps.
* @param catalog Catalog whose ids/default cells give generated materials meaning.
* @param sourceRevision Positive authority revision assigned to every output Chunk.
* @param sourceTick Authority Tick carried by the correction batch.
* @param sourceLastEditSequence Authority edit sequence carried by the correction batch.
* @return Owning canonical batch and summaries, or a structured bounded-input error.
* @remarks Integer coordinate hashing makes output bit-exact across worker count and backend.
* The caller may transactionally admit `output.batch` through `PixelWorld::applyChunkBatch`.
* Unknown schema versions are rejected and no world state is observed or changed.
*/
[[nodiscard]] eve::Result<PixelWorldGenerationOutput> generatePixelWorld(
const PixelWorldGenerationRequest& request, const MaterialCatalog& catalog,
std::uint64_t sourceRevision, eve::SimulationTick sourceTick = eve::SimulationTick::zero(),
std::uint64_t sourceLastEditSequence = 0);
} // namespace eve::pixelworld
@@ -0,0 +1,204 @@
#include "pixelworld/PixelWorldGenerationCodec.h"
#include <Poco/Dynamic/Var.h>
#include <Poco/JSON/Array.h>
#include <Poco/JSON/Object.h>
#include <Poco/JSON/Parser.h>
#include <Poco/JSON/Stringifier.h>
#include <charconv>
#include <limits>
#include <sstream>
#include <type_traits>
#include <unordered_set>
namespace eve::pixelworld {
namespace {
using Object = Poco::JSON::Object;
using Array = Poco::JSON::Array;
eve::Result<PixelWorldGenerationRequest> malformed(std::string message,
std::string path = "document") {
return eve::Result<PixelWorldGenerationRequest>::failure(eve::Diagnostic::error(
eve::DiagnosticCode::SerializationError, std::move(message), std::move(path), {},
"pixelworld.generation-codec"));
}
bool exactFields(const Object& object, std::initializer_list<std::string_view> allowed) {
std::unordered_set<std::string> names;
for (const auto name : allowed) names.emplace(name);
if (object.size() != names.size()) return false;
for (const auto& name : object.getNames())
if (!names.contains(name)) return false;
for (const auto& name : names)
if (!object.has(name)) return false;
return true;
}
template <class T>
bool readNumber(const Object& object, const std::string& key, T& result) {
static_assert(std::is_integral_v<T> && !std::is_same_v<T, bool>);
if (!object.has(key) || object.isNull(key)) return false;
try {
const auto wide = object.get(key).convert<std::int64_t>();
if constexpr (std::is_unsigned_v<T>) {
if (wide < 0 || std::uint64_t(wide) > std::numeric_limits<T>::max()) return false;
} else if (wide < std::numeric_limits<T>::min() ||
wide > std::numeric_limits<T>::max()) {
return false;
}
result = static_cast<T>(wide);
return true;
} catch (...) {
return false;
}
}
bool readSeed(const Object& object, std::uint64_t& seed) {
if (!object.has("seed") || object.isNull("seed")) return false;
try {
const std::string text = object.getValue<std::string>("seed");
const char* begin = text.data();
const char* end = begin + text.size();
const auto parsed = std::from_chars(begin, end, seed);
return parsed.ec == std::errc{} && parsed.ptr == end;
} catch (...) {
return false;
}
}
Object::Ptr cellJson(const PixelCell& cell) {
Object::Ptr out(new Object);
out->set("material", std::uint16_t(cell.material));
out->set("temperature", cell.temperature);
out->set("lifetime", cell.lifetime);
out->set("thermalRemainder", cell.thermalRemainder);
return out;
}
} // namespace
eve::Result<std::string> encodePixelWorldGenerationRequestJson(
const PixelWorldGenerationRequest& request) {
Object::Ptr root(new Object);
root->set("schema", "eve.pixelworld.generation-request");
root->set("version", request.schemaVersion);
root->set("seed", std::to_string(request.seed));
Object::Ptr region(new Object);
region->set("minX", request.region.minX);
region->set("minY", request.region.minY);
region->set("maxX", request.region.maxX);
region->set("maxY", request.region.maxY);
root->set("region", region);
root->set("surfaceY", request.surfaceY);
root->set("terrainAmplitude", request.terrainAmplitude);
root->set("waterLevel", request.waterLevel);
root->set("caveThreshold", request.caveThreshold);
Array::Ptr stamps(new Array);
for (const PixelMaterialStamp& stamp : request.stamps) {
Object::Ptr value(new Object);
value->set("originX", stamp.originX);
value->set("originY", stamp.originY);
value->set("width", stamp.width);
value->set("height", stamp.height);
Array::Ptr cells(new Array);
for (const PixelCell& cell : stamp.cells) cells->add(cellJson(cell));
value->set("cells", cells);
stamps->add(value);
}
root->set("stamps", stamps);
std::ostringstream text;
Poco::JSON::Stringifier::stringify(Poco::Dynamic::Var(root), text, 2, -1);
return eve::Result<std::string>::success(text.str());
}
eve::Result<PixelWorldGenerationRequest> decodePixelWorldGenerationRequestJson(
std::string_view json) {
Object::Ptr root;
try {
root = Poco::JSON::Parser().parse(std::string(json)).extract<Object::Ptr>();
} catch (...) {
return malformed("generation request JSON is malformed");
}
if (!root || !exactFields(*root, {"schema", "version", "seed", "region", "surfaceY",
"terrainAmplitude", "waterLevel", "caveThreshold", "stamps"}))
return malformed("root contains missing or unknown fields");
if (root->optValue<std::string>("schema", "") != "eve.pixelworld.generation-request")
return malformed("unknown generation request schema", "schema");
PixelWorldGenerationRequest request;
if (!readNumber(*root, "version", request.schemaVersion) ||
request.schemaVersion != PixelWorldGenerationRequest::kSchemaVersion)
return malformed("unknown generation request version", "version");
if (!readSeed(*root, request.seed)) return malformed("seed is not a canonical uint64", "seed");
Object::Ptr region;
Array::Ptr stamps;
try {
region = root->getObject("region");
stamps = root->getArray("stamps");
} catch (...) {
return malformed("region or stamps has an invalid type");
}
if (!region || !exactFields(*region, {"minX", "minY", "maxX", "maxY"}) || !stamps ||
stamps->size() > 65'536U)
return malformed("region fields or stamp budget are invalid");
if (!readNumber(*region, "minX", request.region.minX) ||
!readNumber(*region, "minY", request.region.minY) ||
!readNumber(*region, "maxX", request.region.maxX) ||
!readNumber(*region, "maxY", request.region.maxY) ||
!readNumber(*root, "surfaceY", request.surfaceY) ||
!readNumber(*root, "terrainAmplitude", request.terrainAmplitude) ||
!readNumber(*root, "waterLevel", request.waterLevel) ||
!readNumber(*root, "caveThreshold", request.caveThreshold))
return malformed("generation request contains an invalid number");
std::uint64_t totalCells = 0;
request.stamps.reserve(stamps->size());
for (std::size_t index = 0; index < stamps->size(); ++index) {
Object::Ptr value;
Array::Ptr cells;
try {
value = stamps->getObject(unsigned(index));
if (value) cells = value->getArray("cells");
} catch (...) {
return malformed("stamp is not an object", "stamps[" + std::to_string(index) + "]");
}
if (!value || !exactFields(*value, {"originX", "originY", "width", "height", "cells"}) ||
!cells)
return malformed("stamp contains missing or unknown fields",
"stamps[" + std::to_string(index) + "]");
PixelMaterialStamp stamp;
if (!readNumber(*value, "originX", stamp.originX) ||
!readNumber(*value, "originY", stamp.originY) ||
!readNumber(*value, "width", stamp.width) ||
!readNumber(*value, "height", stamp.height) || stamp.width <= 0 || stamp.height <= 0 ||
std::uint64_t(stamp.width) * std::uint64_t(stamp.height) != cells->size())
return malformed("stamp dimensions are invalid", "stamps[" + std::to_string(index) + "]");
totalCells += cells->size();
if (totalCells > 4'194'304ULL) return malformed("stamp cell budget is exceeded", "stamps");
stamp.cells.reserve(cells->size());
for (std::size_t cellIndex = 0; cellIndex < cells->size(); ++cellIndex) {
Object::Ptr item;
try {
item = cells->getObject(unsigned(cellIndex));
} catch (...) {
return malformed("stamp cell is not an object", "stamps.cells");
}
PixelCell cell;
std::uint16_t material = 0;
if (!item || !exactFields(*item, {"material", "temperature", "lifetime", "thermalRemainder"}) ||
!readNumber(*item, "material", material) ||
!readNumber(*item, "temperature", cell.temperature) ||
!readNumber(*item, "lifetime", cell.lifetime) ||
!readNumber(*item, "thermalRemainder", cell.thermalRemainder))
return malformed("stamp cell contains invalid fields", "stamps.cells");
cell.material = MaterialId(material);
stamp.cells.push_back(cell);
}
request.stamps.push_back(std::move(stamp));
}
return eve::Result<PixelWorldGenerationRequest>::success(std::move(request));
}
} // namespace eve::pixelworld
@@ -0,0 +1,23 @@
#pragma once
#include "common/Result.h"
#include "pixelworld/PixelWorldGeneration.h"
#include <string>
#include <string_view>
namespace eve::pixelworld {
/** @brief Encode schema `eve.pixelworld.generation-request` version 1 canonically. */
[[nodiscard]] eve::Result<std::string> encodePixelWorldGenerationRequestJson(
const PixelWorldGenerationRequest& request);
/**
* @brief Decode schema `eve.pixelworld.generation-request` version 1.
* @remarks Unknown fields and versions are rejected. The returned request owns every
* stamp cell, and decoding cannot mutate a live PixelWorld.
*/
[[nodiscard]] eve::Result<PixelWorldGenerationRequest>
decodePixelWorldGenerationRequestJson(std::string_view json);
} // namespace eve::pixelworld
@@ -0,0 +1,62 @@
#include "pixelworld/PixelWorldModule.h"
#include <simplesquirrel/simplesquirrel.hpp>
#include <algorithm>
#include <cstdint>
#include <functional>
#include <string>
namespace eve::pixelworld {
void registerPixelWorldAutomation();
void unregisterPixelWorldAutomation();
Module_IMPL(PixelWorldModule, new PixelWorldModule());
PixelWorldModule::PixelWorldModule() { registerPixelWorldAutomation(); }
PixelWorldModule::~PixelWorldModule() { unregisterPixelWorldAutomation(); }
PixelWorld* PixelWorldModule::newWorld(std::uint64_t seed) { return new PixelWorld(seed); }
void PixelWorldModule::expose(ssq::Table& table) {
auto module = table.addClass(name, PixelWorldModule::create, false);
expose(module);
auto world = table.addClass<PixelWorld>(
"PixelWorld", std::function<PixelWorld*()>([]() -> PixelWorld* { return nullptr; }), true);
world.addFunc("getMaterial", [](PixelWorld* self, int x, int y) { return self->getMaterial(x, y); });
world.addFunc("setMaterial", [](PixelWorld* self, int x, int y, const std::string& material) {
self->setMaterial(x, y, material);
});
world.addFunc("paintCircle", [](PixelWorld* self, int x, int y, int radius, const std::string& material) {
return int(self->paintCircle(x, y, radius, material));
});
world.addFunc("step", [](PixelWorld* self) {
return int(self->step().cellsMoved);
});
world.addFunc("clear", [](PixelWorld* self) { self->clear(); });
world.addFunc("getSeed", [](PixelWorld* self) { return std::int64_t(self->seed()); });
world.addFunc("getRevision", [](PixelWorld* self) { return std::int64_t(self->revision()); });
world.addFunc("getTick", [](PixelWorld* self) { return std::int64_t(self->tickValue()); });
world.addFunc("getLastEditSequence", [](PixelWorld* self) {
return std::int64_t(self->lastEditSequence());
});
world.addFunc("getChunkCount", [](PixelWorld* self) { return self->chunkCount(); });
world.addFunc("getActiveChunkCount", [](PixelWorld* self) { return self->activeChunkCount(); });
world.addFunc("getChangedChunkCountSince", [](PixelWorld* self, int revision) {
return int(self->snapshotChangedChunks(revision < 0 ? 0U : std::uint64_t(revision)).size());
});
world.addFunc("explode", [](PixelWorld* self, int x, int y, int radius, int strength, int heat) {
return int(self->explode(x, y, radius, strength,
std::int16_t(std::clamp(heat, -32768, 32767))).cellsRemoved);
});
}
void PixelWorldModule::expose(ssq::Class& cls) {
cls.addFunc("newWorld", [](PixelWorldModule* self, int seed) {
return self->newWorld(std::uint64_t(std::uint32_t(seed)));
});
}
} // namespace eve::pixelworld
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include "common/Module.h"
#include "pixelworld/PixelWorld.h"
#include <cstdint>
namespace eve::pixelworld {
/** @brief Script-facing owner/factory for deterministic pixel material worlds. */
class PixelWorldModule : public Module {
public:
Module_REG(PixelWorldModule);
PixelWorldModule();
~PixelWorldModule() override;
/** @brief Create a caller-owned world using the supplied deterministic seed. */
[[nodiscard("retain and delete the returned PixelWorld")]] PixelWorld* newWorld(std::uint64_t seed = 1);
};
} // namespace eve::pixelworld
@@ -0,0 +1,618 @@
#include "pixelworld_editor/PixelWorldCatalogPanel.h"
#include "pixelworld/PixelMaterialCatalogCodec.h"
#include "ui/UIHost.h"
#include <algorithm>
#include <array>
#include <charconv>
#include <iomanip>
#include <limits>
#include <sstream>
#include <utility>
namespace eve::pixelworld_editor {
namespace {
using namespace eve::pixelworld;
constexpr const char* kCatalogPanelHostName = "eve_pixelworld_catalog";
template <class T>
eve::Result<T> fail(eve::DiagnosticCode code, std::string message, std::string path) {
return eve::Result<T>::failure(eve::Diagnostic::error(
code, std::move(message), std::move(path), {}, "pixelworld.catalog-panel"));
}
std::vector<MaterialDefinition> definitions(const MaterialCatalog& catalog) {
return {catalog.definitions().begin(), catalog.definitions().end()};
}
std::vector<MaterialReactionRule> reactions(const MaterialCatalog& catalog) {
return {catalog.reactions().begin(), catalog.reactions().end()};
}
std::vector<MaterialPhaseRule> phases(const MaterialCatalog& catalog) {
return {catalog.phaseRules().begin(), catalog.phaseRules().end()};
}
std::string stateName(MaterialState state) {
constexpr std::array names{"Empty", "Solid", "Powder", "Liquid", "Gas", "Energy"};
const auto index = static_cast<std::size_t>(state);
return index < names.size() ? names[index] : "Unknown";
}
template <class Integer>
std::optional<Integer> parseInteger(std::string_view text) {
Integer value{};
const auto parsed = std::from_chars(text.data(), text.data() + text.size(), value);
if (parsed.ec != std::errc{} || parsed.ptr != text.data() + text.size()) return std::nullopt;
return value;
}
std::optional<std::uint32_t> parseRgba(std::string_view text) {
if (!text.empty() && text.front() == '#') text.remove_prefix(1);
if (text.size() != 8) return std::nullopt;
std::uint32_t value = 0;
const auto parsed = std::from_chars(text.data(), text.data() + text.size(), value, 16);
if (parsed.ec != std::errc{} || parsed.ptr != text.data() + text.size()) return std::nullopt;
return value;
}
std::string rgbaText(std::uint32_t rgba) {
std::ostringstream output;
output << '#' << std::uppercase << std::hex << std::setw(8) << std::setfill('0') << rgba;
return output.str();
}
std::vector<std::string> splitTags(std::string_view value) {
std::vector<std::string> result;
while (!value.empty()) {
const auto comma = value.find(',');
std::string tag(value.substr(0, comma));
const auto first = tag.find_first_not_of(" \t");
const auto last = tag.find_last_not_of(" \t");
if (first != std::string::npos) result.push_back(tag.substr(first, last - first + 1));
if (comma == std::string_view::npos) break;
value.remove_prefix(comma + 1);
}
std::sort(result.begin(), result.end());
result.erase(std::unique(result.begin(), result.end()), result.end());
return result;
}
std::string joinTags(const std::vector<std::string>& tags) {
std::string result;
for (const auto& tag : tags) {
if (!result.empty()) result += ", ";
result += tag;
}
return result;
}
template <class Integer, class Setter>
eve::ui::WidgetDesc integerField(std::string label, std::string id, Integer value,
Setter setter) {
return eve::ui::inputText(std::move(label), std::to_string(value), std::move(id),
[setter = std::move(setter)](const std::string& text) mutable {
if (const auto parsed = parseInteger<Integer>(text)) setter(*parsed);
});
}
} // namespace
PixelWorldCatalogPanel::PixelWorldCatalogPanel(MaterialCatalog catalog)
: draft_(std::move(catalog)) {
if (!draft_.reactions().empty()) selectedReaction_ = 0;
if (!draft_.phaseRules().empty()) selectedPhase_ = 0;
}
PixelWorldCatalogPanel::~PixelWorldCatalogPanel() {
if (auto host = eve::ui::UIHost::resolve(host_)) {
host->get().setTree(eve::ui::window("", {}));
host->get().setVisible(false);
}
}
PixelWorldCatalogPanel::PixelWorldCatalogPanel(PixelWorldCatalogPanel&& other)
: draft_(std::move(other.draft_)),
revision_(other.revision_),
selectedMaterial_(other.selectedMaterial_),
selectedReaction_(other.selectedReaction_),
selectedPhase_(other.selectedPhase_),
statusText_(std::move(other.statusText_)),
host_(other.host_) {
other.host_ = {};
if (host_.table) (void)refresh();
}
PixelWorldCatalogPanel& PixelWorldCatalogPanel::operator=(PixelWorldCatalogPanel&& other) {
if (this == &other) return *this;
if (auto host = eve::ui::UIHost::resolve(host_)) {
host->get().setTree(eve::ui::window("", {}));
host->get().setVisible(false);
}
draft_ = std::move(other.draft_);
revision_ = other.revision_;
selectedMaterial_ = other.selectedMaterial_;
selectedReaction_ = other.selectedReaction_;
selectedPhase_ = other.selectedPhase_;
statusText_ = std::move(other.statusText_);
host_ = other.host_;
other.host_ = {};
if (host_.table) (void)refresh();
return *this;
}
eve::Result<PixelWorldCatalogPanel> PixelWorldCatalogPanel::create(std::string_view catalogJson) {
auto decoded = decodeMaterialCatalogJson(catalogJson);
if (!decoded.ok())
return eve::Result<PixelWorldCatalogPanel>::failure(decoded.status());
return eve::Result<PixelWorldCatalogPanel>::success(
PixelWorldCatalogPanel(std::move(decoded).takeValue()));
}
PixelWorldCatalogPanel PixelWorldCatalogPanel::builtIn() {
return PixelWorldCatalogPanel(MaterialCatalog::builtIn());
}
std::vector<MaterialCard> PixelWorldCatalogPanel::materialCards() const {
std::vector<MaterialCard> result;
result.reserve(draft_.definitions().size());
for (std::size_t index = 0; index < draft_.definitions().size(); ++index) {
const auto& definition = draft_.definitions()[index];
result.push_back({definition.id, definition.name, definition.state,
definition.displayRgba, index == selectedMaterial_,
draft_.canReact(definition.id),
draft_.canPhaseTransition(definition.id)});
}
return result;
}
eve::Result<void> PixelWorldCatalogPanel::selectMaterial(std::size_t index) {
if (index >= draft_.definitions().size())
return fail<void>(eve::DiagnosticCode::InvalidArgument,
"material selection is outside the Catalog", "materialIndex");
selectedMaterial_ = index;
return eve::Result<void>::success();
}
eve::Result<void> PixelWorldCatalogPanel::selectReaction(std::size_t index) {
if (index >= draft_.reactions().size())
return fail<void>(eve::DiagnosticCode::InvalidArgument,
"reaction selection is outside the Catalog", "reactionIndex");
selectedReaction_ = index;
return eve::Result<void>::success();
}
eve::Result<void> PixelWorldCatalogPanel::selectPhase(std::size_t index) {
if (index >= draft_.phaseRules().size())
return fail<void>(eve::DiagnosticCode::InvalidArgument,
"phase selection is outside the Catalog", "phaseIndex");
selectedPhase_ = index;
return eve::Result<void>::success();
}
eve::Result<CatalogDraftReceipt> PixelWorldCatalogPanel::commitCandidate(
std::vector<MaterialDefinition> candidateDefinitions,
std::vector<MaterialReactionRule> candidateReactions,
std::vector<MaterialPhaseRule> candidatePhases) {
auto candidate = MaterialCatalog::create(std::move(candidateDefinitions),
std::move(candidateReactions),
std::move(candidatePhases));
if (!candidate.ok())
return eve::Result<CatalogDraftReceipt>::failure(candidate.status());
CatalogDraftReceipt receipt{revision_, revision_ + 1, candidate.value().fingerprint()};
draft_ = std::move(candidate).takeValue();
revision_ = receipt.revisionAfter;
if (selectedMaterial_ >= draft_.definitions().size()) selectedMaterial_ = 0;
if (draft_.reactions().empty()) selectedReaction_.reset();
else if (!selectedReaction_ || *selectedReaction_ >= draft_.reactions().size())
selectedReaction_ = draft_.reactions().size() - 1;
if (draft_.phaseRules().empty()) selectedPhase_.reset();
else if (!selectedPhase_ || *selectedPhase_ >= draft_.phaseRules().size())
selectedPhase_ = draft_.phaseRules().size() - 1;
return eve::Result<CatalogDraftReceipt>::success(receipt);
}
eve::Result<CatalogDraftReceipt> PixelWorldCatalogPanel::replaceMaterial(
MaterialDefinition definition) {
const auto index = static_cast<std::size_t>(definition.id);
if (index >= draft_.definitions().size())
return fail<CatalogDraftReceipt>(eve::DiagnosticCode::InvalidArgument,
"material id is outside the Catalog", "material.id");
auto candidateDefinitions = definitions(draft_);
candidateDefinitions[index] = std::move(definition);
return commitCandidate(std::move(candidateDefinitions), reactions(draft_), phases(draft_));
}
eve::Result<CatalogDraftReceipt> PixelWorldCatalogPanel::replaceReaction(
std::size_t index, MaterialReactionRule rule) {
if (index >= draft_.reactions().size())
return fail<CatalogDraftReceipt>(eve::DiagnosticCode::InvalidArgument,
"reaction index is outside the Catalog", "reactionIndex");
auto candidate = reactions(draft_);
candidate[index] = std::move(rule);
return commitCandidate(definitions(draft_), std::move(candidate), phases(draft_));
}
eve::Result<CatalogDraftReceipt> PixelWorldCatalogPanel::addReaction(
MaterialReactionRule rule) {
const std::string selectedId = rule.id;
auto candidate = reactions(draft_);
candidate.push_back(std::move(rule));
auto committed = commitCandidate(definitions(draft_), std::move(candidate), phases(draft_));
if (committed.ok()) {
for (std::size_t index = 0; index < draft_.reactions().size(); ++index)
if (draft_.reactions()[index].id == selectedId) selectedReaction_ = index;
}
return committed;
}
eve::Result<CatalogDraftReceipt> PixelWorldCatalogPanel::removeReaction(std::size_t index) {
if (index >= draft_.reactions().size())
return fail<CatalogDraftReceipt>(eve::DiagnosticCode::InvalidArgument,
"reaction index is outside the Catalog", "reactionIndex");
auto candidate = reactions(draft_);
candidate.erase(candidate.begin() + static_cast<std::ptrdiff_t>(index));
return commitCandidate(definitions(draft_), std::move(candidate), phases(draft_));
}
eve::Result<CatalogDraftReceipt> PixelWorldCatalogPanel::replacePhase(
std::size_t index, MaterialPhaseRule rule) {
if (index >= draft_.phaseRules().size())
return fail<CatalogDraftReceipt>(eve::DiagnosticCode::InvalidArgument,
"phase index is outside the Catalog", "phaseIndex");
auto candidate = phases(draft_);
candidate[index] = std::move(rule);
return commitCandidate(definitions(draft_), reactions(draft_), std::move(candidate));
}
eve::Result<CatalogDraftReceipt> PixelWorldCatalogPanel::addPhase(MaterialPhaseRule rule) {
const std::string selectedId = rule.id;
auto candidate = phases(draft_);
candidate.push_back(std::move(rule));
auto committed = commitCandidate(definitions(draft_), reactions(draft_), std::move(candidate));
if (committed.ok()) {
for (std::size_t index = 0; index < draft_.phaseRules().size(); ++index)
if (draft_.phaseRules()[index].id == selectedId) selectedPhase_ = index;
}
return committed;
}
eve::Result<CatalogDraftReceipt> PixelWorldCatalogPanel::removePhase(std::size_t index) {
if (index >= draft_.phaseRules().size())
return fail<CatalogDraftReceipt>(eve::DiagnosticCode::InvalidArgument,
"phase index is outside the Catalog", "phaseIndex");
auto candidate = phases(draft_);
candidate.erase(candidate.begin() + static_cast<std::ptrdiff_t>(index));
return commitCandidate(definitions(draft_), reactions(draft_), std::move(candidate));
}
eve::Result<std::string> PixelWorldCatalogPanel::document() const {
return encodeMaterialCatalogJson(draft_);
}
eve::Result<CatalogDraftReceipt> PixelWorldCatalogPanel::loadDocument(
std::string_view catalogJson) {
auto decoded = decodeMaterialCatalogJson(catalogJson);
if (!decoded.ok())
return eve::Result<CatalogDraftReceipt>::failure(decoded.status());
return commitCandidate(definitions(decoded.value()), reactions(decoded.value()),
phases(decoded.value()));
}
eve::Result<PixelCatalogReloadReceipt> PixelWorldCatalogPanel::apply(
PixelWorldControlService& control, std::uint64_t worldId,
std::uint64_t expectedFingerprint) const {
auto encoded = document();
if (!encoded.ok())
return eve::Result<PixelCatalogReloadReceipt>::failure(encoded.status());
return control.reloadMaterialCatalog(worldId, encoded.value(), expectedFingerprint);
}
eve::Result<eve::ui::UIHostHandle> PixelWorldCatalogPanel::open() {
auto host = eve::ui::UIHost::resolve(host_);
if (!host) {
host_ = eve::ui::UIHost::createHost(kCatalogPanelHostName);
host = eve::ui::UIHost::resolve(host_);
}
if (!host) {
return fail<eve::ui::UIHostHandle>(
eve::DiagnosticCode::PreconditionViolation,
"pixelworld catalog panel requires an active UI ECS world", "uiHost");
}
host->get().setVisible(true);
host->get().setLayer(90);
auto meta = host->get().meta();
meta->hasPos = true;
meta->posX = 12.0F;
meta->posY = 12.0F;
meta->hasSize = true;
meta->sizeX = 820.0F;
meta->sizeY = 540.0F;
auto refreshed = refresh();
if (!refreshed) {
return eve::Result<eve::ui::UIHostHandle>::failure(refreshed.status());
}
return eve::Result<eve::ui::UIHostHandle>::success(host_);
}
void PixelWorldCatalogPanel::close() {
if (auto host = eve::ui::UIHost::resolve(host_)) host->get().setVisible(false);
}
bool PixelWorldCatalogPanel::isOpen() const {
auto host = eve::ui::UIHost::resolve(host_);
return host && host->get().meta()->visible;
}
eve::Result<void> PixelWorldCatalogPanel::refresh() {
auto host = eve::ui::UIHost::resolve(host_);
if (!host)
return fail<void>(eve::DiagnosticCode::PreconditionViolation,
"pixelworld catalog panel is not mounted", "uiHost");
host->get().setTree(buildWidgetTree());
return eve::Result<void>::success();
}
void PixelWorldCatalogPanel::observe(eve::Result<CatalogDraftReceipt> result) {
if (result.ok()) statusText_ = "Catalog draft validated at revision " +
std::to_string(result.value().revisionAfter);
else statusText_ = result.status().describe();
if (isOpen()) (void)refresh();
}
void PixelWorldCatalogPanel::observe(eve::Result<void> result) {
if (result.ok()) statusText_ = "Selection updated";
else statusText_ = result.status().describe();
if (isOpen()) (void)refresh();
}
eve::ui::WidgetDesc PixelWorldCatalogPanel::buildWidgetTree() {
using namespace eve::ui;
std::vector<WidgetDesc> materialRows;
for (std::size_t index = 0; index < draft_.definitions().size(); ++index) {
const auto& material = draft_.definitions()[index];
const float red = float((material.displayRgba >> 24U) & 0xFFU) / 255.0f;
const float green = float((material.displayRgba >> 16U) & 0xFFU) / 255.0f;
const float blue = float((material.displayRgba >> 8U) & 0xFFU) / 255.0f;
WidgetDesc swatch = image("material-swatch/" + std::to_string(index), 18.0f, 18.0f)
.withTint(red, green, blue, 1.0f)
.withCornerRadius(3.0f);
WidgetDesc choose = button(material.name + " · " + stateName(material.state),
"material/" + std::to_string(index), [this, index] {
observe(selectMaterial(index));
});
choose.checked = index == selectedMaterial_;
materialRows.push_back(row({std::move(swatch), std::move(choose)},
"material-row/" + std::to_string(index)));
}
const MaterialDefinition selected = draft_.definitions()[selectedMaterial_];
auto mutateMaterial = [this, id = selected.id](auto mutation) {
MaterialDefinition value = draft_.definition(id);
mutation(value);
observe(replaceMaterial(std::move(value)));
};
std::vector<std::string> stateOptions{"Empty", "Solid", "Powder", "Liquid", "Gas", "Energy"};
const float red = float((selected.displayRgba >> 24U) & 0xFFU) / 255.0f;
const float green = float((selected.displayRgba >> 16U) & 0xFFU) / 255.0f;
const float blue = float((selected.displayRgba >> 8U) & 0xFFU) / 255.0f;
const float alpha = float(selected.displayRgba & 0xFFU) / 255.0f;
std::vector<WidgetDesc> materialInspector{
sectionHeader("Material · " + selected.name, "material-heading"),
image("material-preview", 96.0f, 56.0f).withTint(red, green, blue, alpha).withCornerRadius(6.0f),
inputText("Name", selected.name, "material-name", [mutateMaterial](const std::string& value) mutable {
mutateMaterial([&](auto& material) { material.name = value; });
}),
combo("State", stateOptions, static_cast<int>(selected.state), "material-state",
[mutateMaterial](float value) mutable {
mutateMaterial([&](auto& material) {
material.state = static_cast<MaterialState>(std::clamp(int(value), 0, 5));
});
}),
inputText("RGBA", rgbaText(selected.displayRgba), "material-rgba",
[mutateMaterial](const std::string& value) mutable {
if (const auto parsed = parseRgba(value))
mutateMaterial([&](auto& material) { material.displayRgba = *parsed; });
}),
checkbox("Flammable", selected.flammable, "material-flammable",
[mutateMaterial](bool value) mutable {
mutateMaterial([&](auto& material) { material.flammable = value; });
}),
inputText("Tags", joinTags(selected.tags), "material-tags",
[mutateMaterial](const std::string& value) mutable {
mutateMaterial([&](auto& material) { material.tags = splitTags(value); });
}),
integerField("Density", "material-density", selected.density,
[mutateMaterial](std::uint16_t value) mutable {
mutateMaterial([&](auto& material) { material.density = value; });
}),
integerField("Viscosity", "material-viscosity", selected.viscosity,
[mutateMaterial](std::uint8_t value) mutable {
mutateMaterial([&](auto& material) { material.viscosity = value; });
}),
integerField("Thermal conductivity", "material-conductivity", selected.thermalConductivity,
[mutateMaterial](std::uint8_t value) mutable {
mutateMaterial([&](auto& material) { material.thermalConductivity = value; });
}),
integerField("Heat capacity", "material-capacity", selected.heatCapacity,
[mutateMaterial](std::uint16_t value) mutable {
mutateMaterial([&](auto& material) { material.heatCapacity = value; });
}),
integerField("Ignition temperature", "material-ignition", selected.ignitionTemperature,
[mutateMaterial](std::int16_t value) mutable {
mutateMaterial([&](auto& material) { material.ignitionTemperature = value; });
}),
integerField("Melting temperature", "material-melting", selected.meltingTemperature,
[mutateMaterial](std::int16_t value) mutable {
mutateMaterial([&](auto& material) { material.meltingTemperature = value; });
}),
integerField("Boiling temperature", "material-boiling", selected.boilingTemperature,
[mutateMaterial](std::int16_t value) mutable {
mutateMaterial([&](auto& material) { material.boilingTemperature = value; });
}),
integerField("Default temperature", "material-default-temperature", selected.defaultTemperature,
[mutateMaterial](std::int16_t value) mutable {
mutateMaterial([&](auto& material) { material.defaultTemperature = value; });
}),
integerField("Default lifetime", "material-lifetime", selected.defaultLifetime,
[mutateMaterial](std::uint16_t value) mutable {
mutateMaterial([&](auto& material) { material.defaultLifetime = value; });
}),
integerField("Blast resistance", "material-blast", selected.blastResistance,
[mutateMaterial](std::uint16_t value) mutable {
mutateMaterial([&](auto& material) { material.blastResistance = value; });
})};
std::vector<std::string> materialNames;
for (const auto& material : draft_.definitions()) materialNames.push_back(material.name);
std::vector<std::string> reactionNames;
for (const auto& reaction : draft_.reactions()) reactionNames.push_back(reaction.id);
std::vector<WidgetDesc> reactionEditor{sectionHeader("Binary reactions", "reaction-heading")};
if (!reactionNames.empty()) {
const auto reactionIndex = selectedReaction_.value_or(0);
const MaterialReactionRule selectedRule = draft_.reactions()[reactionIndex];
auto mutateReaction = [this, reactionIndex](auto mutation) {
MaterialReactionRule value = draft_.reactions()[reactionIndex];
mutation(value);
observe(replaceReaction(reactionIndex, std::move(value)));
};
reactionEditor.push_back(combo("Rule", reactionNames, int(reactionIndex), "reaction-select",
[this](float value) {
observe(selectReaction(std::size_t(std::max(0, int(value)))));
}));
reactionEditor.push_back(inputText("ID", selectedRule.id, "reaction-id",
[mutateReaction](const std::string& value) mutable {
mutateReaction([&](auto& rule) { rule.id = value; });
}));
auto materialCombo = [&](std::string label, std::string id, MaterialId value, auto assign) {
return combo(std::move(label), materialNames, int(value), std::move(id),
[mutateReaction, assign](float index) mutable {
mutateReaction([&](auto& rule) {
assign(rule, static_cast<MaterialId>(std::max(0, int(index))));
});
});
};
reactionEditor.push_back(materialCombo("First", "reaction-first", selectedRule.first,
[](auto& rule, auto value) { rule.first = value; }));
reactionEditor.push_back(materialCombo("Second", "reaction-second", selectedRule.second,
[](auto& rule, auto value) { rule.second = value; }));
reactionEditor.push_back(materialCombo("First result", "reaction-first-result",
selectedRule.firstResult,
[](auto& rule, auto value) { rule.firstResult = value; }));
reactionEditor.push_back(materialCombo("Second result", "reaction-second-result",
selectedRule.secondResult,
[](auto& rule, auto value) { rule.secondResult = value; }));
reactionEditor.push_back(integerField("Minimum temperature", "reaction-min-temperature",
selectedRule.minimumTemperature,
[mutateReaction](std::int16_t value) mutable {
mutateReaction([&](auto& rule) {
rule.minimumTemperature = value;
});
}));
reactionEditor.push_back(integerField("Heat delta", "reaction-heat", selectedRule.heatDelta,
[mutateReaction](std::int16_t value) mutable {
mutateReaction([&](auto& rule) { rule.heatDelta = value; });
}));
reactionEditor.push_back(integerField("Priority", "reaction-priority", selectedRule.priority,
[mutateReaction](std::int32_t value) mutable {
mutateReaction([&](auto& rule) { rule.priority = value; });
}));
reactionEditor.push_back(button("Delete reaction", "reaction-delete", [this, reactionIndex] {
observe(removeReaction(reactionIndex));
}));
}
reactionEditor.push_back(button("Add reaction", "reaction-add", [this] {
MaterialReactionRule rule;
rule.id = "reaction-" + std::to_string(revision_ + 1);
observe(addReaction(std::move(rule)));
}));
std::vector<std::string> phaseNames;
for (const auto& phase : draft_.phaseRules()) phaseNames.push_back(phase.id);
std::vector<WidgetDesc> phaseEditor{sectionHeader("Phase transitions", "phase-heading")};
if (!phaseNames.empty()) {
const auto phaseIndex = selectedPhase_.value_or(0);
const MaterialPhaseRule selectedRule = draft_.phaseRules()[phaseIndex];
auto mutatePhase = [this, phaseIndex](auto mutation) {
MaterialPhaseRule value = draft_.phaseRules()[phaseIndex];
mutation(value);
observe(replacePhase(phaseIndex, std::move(value)));
};
phaseEditor.push_back(combo("Rule", phaseNames, int(phaseIndex), "phase-select",
[this](float value) {
observe(selectPhase(std::size_t(std::max(0, int(value)))));
}));
phaseEditor.push_back(inputText("ID", selectedRule.id, "phase-id",
[mutatePhase](const std::string& value) mutable {
mutatePhase([&](auto& rule) { rule.id = value; });
}));
phaseEditor.push_back(combo("Source", materialNames, int(selectedRule.source), "phase-source",
[mutatePhase](float value) mutable {
mutatePhase([&](auto& rule) {
rule.source = static_cast<MaterialId>(std::max(0, int(value)));
});
}));
phaseEditor.push_back(combo("Result", materialNames, int(selectedRule.result), "phase-result",
[mutatePhase](float value) mutable {
mutatePhase([&](auto& rule) {
rule.result = static_cast<MaterialId>(std::max(0, int(value)));
});
}));
phaseEditor.push_back(combo("Direction", {"At or below", "At or above"},
int(selectedRule.direction), "phase-direction",
[mutatePhase](float value) mutable {
mutatePhase([&](auto& rule) {
rule.direction = value < 0.5f ? TemperatureDirection::AtOrBelow
: TemperatureDirection::AtOrAbove;
});
}));
phaseEditor.push_back(integerField("Threshold", "phase-threshold", selectedRule.threshold,
[mutatePhase](std::int16_t value) mutable {
mutatePhase([&](auto& rule) { rule.threshold = value; });
}));
phaseEditor.push_back(integerField("Temperature delta", "phase-temperature-delta",
selectedRule.temperatureDelta,
[mutatePhase](std::int16_t value) mutable {
mutatePhase([&](auto& rule) {
rule.temperatureDelta = value;
});
}));
phaseEditor.push_back(integerField("Priority", "phase-priority", selectedRule.priority,
[mutatePhase](std::int32_t value) mutable {
mutatePhase([&](auto& rule) { rule.priority = value; });
}));
phaseEditor.push_back(button("Delete transition", "phase-delete", [this, phaseIndex] {
observe(removePhase(phaseIndex));
}));
}
phaseEditor.push_back(button("Add transition", "phase-add", [this] {
MaterialPhaseRule rule;
rule.id = "phase-" + std::to_string(revision_ + 1);
observe(addPhase(std::move(rule)));
}));
WidgetDesc browser = sidebar({searchField("Filter materials", {}, "material-filter"),
scrollList("materials", std::move(materialRows), 0.0f, 28.0f)},
"catalog-browser", 260.0f);
WidgetDesc inspector = child(
"catalog-inspector",
{collapsingHeader("Material properties", std::move(materialInspector), "material-card", true),
collapsingHeader("Reactions", std::move(reactionEditor), "reaction-section", false),
collapsingHeader("Phase transitions", std::move(phaseEditor), "phase-section", false)});
WidgetDesc content = splitPane(FlexDirection::Row, std::move(browser), std::move(inspector),
0.28f, "catalog-split");
return window("PixelWorld Material & Reaction Catalog",
{toolbar({badge("Validated", "catalog-valid"), spacer(),
text("Fingerprint " + std::to_string(draft_.fingerprint()),
"catalog-fingerprint")},
"catalog-toolbar"),
std::move(content), statusBar({text(statusText_, "catalog-status")},
"catalog-statusbar")},
"pixelworld-catalog-editor");
}
} // namespace eve::pixelworld_editor
@@ -0,0 +1,141 @@
#pragma once
#include "common/Result.h"
#include "pixelworld/PixelMaterial.h"
#include "pixelworld/PixelWorldControl.h"
#include "ui/Widget.h"
#include <cstddef>
#include <cstdint>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
namespace eve::pixelworld_editor {
/** @brief One renderer-neutral material card projected by the Catalog panel. */
struct MaterialCard {
eve::pixelworld::MaterialId id = eve::pixelworld::MaterialId::Air;
std::string name;
eve::pixelworld::MaterialState state = eve::pixelworld::MaterialState::Empty;
std::uint32_t displayRgba = 0;
bool selected = false;
bool reacts = false;
bool transitions = false;
};
/** @brief Draft mutation receipt used for UI reconciliation and optimistic publication. */
struct CatalogDraftReceipt {
std::uint64_t revisionBefore = 0;
std::uint64_t revisionAfter = 0;
std::uint64_t fingerprint = 0;
};
/**
* @brief Dedicated visual material/reaction Catalog editor with transactional draft admission.
*
* The panel owns an immutable validated draft and never owns or retains a PixelWorld pointer.
* Every edit reconstructs a complete candidate Catalog and publishes it only after validation.
* `apply()` borrows the control service synchronously and reaches the runtime authority through
* its generation-aware world registry. All methods are owner-thread-only and invoke no unknown
* callbacks except callbacks explicitly embedded in a returned WidgetDesc tree. The panel must
* outlive any mounted tree because those callbacks borrow `this`.
*/
class PixelWorldCatalogPanel {
public:
~PixelWorldCatalogPanel();
PixelWorldCatalogPanel(const PixelWorldCatalogPanel&) = delete;
PixelWorldCatalogPanel& operator=(const PixelWorldCatalogPanel&) = delete;
PixelWorldCatalogPanel(PixelWorldCatalogPanel&& other);
PixelWorldCatalogPanel& operator=(PixelWorldCatalogPanel&& other);
/** @brief Decode and validate a versioned Catalog document into a fresh panel draft. */
[[nodiscard]] static eve::Result<PixelWorldCatalogPanel> create(std::string_view catalogJson);
/** @brief Create a panel over the canonical built-in Catalog. */
static PixelWorldCatalogPanel builtIn();
/** @brief Current validated immutable draft. */
const eve::pixelworld::MaterialCatalog& draft() const noexcept { return draft_; }
/** @brief Monotonic local draft revision, advanced only by accepted mutations. */
std::uint64_t revision() const noexcept { return revision_; }
/** @brief Selected material index, always valid for the current nonempty draft. */
std::size_t selectedMaterialIndex() const noexcept { return selectedMaterial_; }
/** @brief Selected reaction index when at least one binary rule exists. */
std::optional<std::size_t> selectedReactionIndex() const noexcept { return selectedReaction_; }
/** @brief Selected phase-rule index when at least one transition exists. */
std::optional<std::size_t> selectedPhaseIndex() const noexcept { return selectedPhase_; }
/** @brief Project deterministic cards for the material browser. */
std::vector<MaterialCard> materialCards() const;
/** @brief Select a material by canonical index without mutating the Catalog. */
[[nodiscard]] eve::Result<void> selectMaterial(std::size_t index);
/** @brief Select a binary reaction rule by canonical index. */
[[nodiscard]] eve::Result<void> selectReaction(std::size_t index);
/** @brief Select a phase-transition rule by canonical index. */
[[nodiscard]] eve::Result<void> selectPhase(std::size_t index);
/** @brief Replace one material definition after validating the entire reconstructed Catalog. */
[[nodiscard]] eve::Result<CatalogDraftReceipt> replaceMaterial(
eve::pixelworld::MaterialDefinition definition);
/** @brief Replace one binary rule after validating the entire reconstructed Catalog. */
[[nodiscard]] eve::Result<CatalogDraftReceipt> replaceReaction(
std::size_t index, eve::pixelworld::MaterialReactionRule rule);
/** @brief Add a binary rule and select its canonical post-validation position. */
[[nodiscard]] eve::Result<CatalogDraftReceipt> addReaction(
eve::pixelworld::MaterialReactionRule rule);
/** @brief Remove one binary rule transactionally. */
[[nodiscard]] eve::Result<CatalogDraftReceipt> removeReaction(std::size_t index);
/** @brief Replace one phase rule after validating the entire reconstructed Catalog. */
[[nodiscard]] eve::Result<CatalogDraftReceipt> replacePhase(
std::size_t index, eve::pixelworld::MaterialPhaseRule rule);
/** @brief Add a phase rule and select its canonical post-validation position. */
[[nodiscard]] eve::Result<CatalogDraftReceipt> addPhase(
eve::pixelworld::MaterialPhaseRule rule);
/** @brief Remove one phase rule transactionally. */
[[nodiscard]] eve::Result<CatalogDraftReceipt> removePhase(std::size_t index);
/** @brief Encode the current validated draft using the canonical versioned JSON codec. */
[[nodiscard]] eve::Result<std::string> document() const;
/** @brief Replace the draft from a validated document; failure leaves all panel state intact. */
[[nodiscard]] eve::Result<CatalogDraftReceipt> loadDocument(std::string_view catalogJson);
/** @brief Apply the draft through the canonical paused-world authority path. */
[[nodiscard]] eve::Result<eve::pixelworld::PixelCatalogReloadReceipt> apply(
eve::pixelworld::PixelWorldControlService& control, std::uint64_t worldId,
std::uint64_t expectedFingerprint) const;
/** @brief Mount or reveal the panel in the UI ECS world and publish its current tree. */
[[nodiscard]] eve::Result<eve::ui::UIHostHandle> open();
/** @brief Hide the mounted panel without discarding its validated draft. */
void close();
/** @brief True while the panel has a live, visible UI host. */
bool isOpen() const;
/** @brief Reconcile the mounted host with the latest draft and selection state. */
[[nodiscard]] eve::Result<void> refresh();
/** @brief Mounted UI host handle, or an empty handle before open(). */
eve::ui::UIHostHandle host() const noexcept { return host_; }
/** @brief Build a dedicated material browser, property inspector and rule editor visual tree. */
eve::ui::WidgetDesc buildWidgetTree();
/** @brief Last callback diagnostic rendered in the panel status bar. */
const std::string& statusText() const noexcept { return statusText_; }
private:
explicit PixelWorldCatalogPanel(eve::pixelworld::MaterialCatalog catalog);
[[nodiscard]] eve::Result<CatalogDraftReceipt> commitCandidate(
std::vector<eve::pixelworld::MaterialDefinition> definitions,
std::vector<eve::pixelworld::MaterialReactionRule> reactions,
std::vector<eve::pixelworld::MaterialPhaseRule> phases);
void observe(eve::Result<CatalogDraftReceipt> result);
void observe(eve::Result<void> result);
eve::pixelworld::MaterialCatalog draft_;
std::uint64_t revision_ = 1;
std::size_t selectedMaterial_ = 0;
std::optional<std::size_t> selectedReaction_;
std::optional<std::size_t> selectedPhase_;
std::string statusText_ = "Catalog draft is valid";
eve::ui::UIHostHandle host_{};
};
} // namespace eve::pixelworld_editor
@@ -0,0 +1,42 @@
#include "pixelworld_editor/PixelWorldEditorModule.h"
#include <simplesquirrel/simplesquirrel.hpp>
#include <stdexcept>
namespace eve::pixelworld_editor {
Module_IMPL(PixelWorldEditorModule, new PixelWorldEditorModule());
eve::Result<void> PixelWorldEditorModule::openCatalog() {
if (!panel_) panel_ = std::make_unique<PixelWorldCatalogPanel>(PixelWorldCatalogPanel::builtIn());
auto opened = panel_->open();
if (!opened) return eve::Result<void>::failure(opened.status());
return eve::Result<void>::success();
}
void PixelWorldEditorModule::closeCatalog() {
if (panel_) panel_->close();
}
bool PixelWorldEditorModule::isCatalogOpen() const {
return panel_ && panel_->isOpen();
}
void PixelWorldEditorModule::expose(ssq::Table& table) {
auto module = table.addClass(name, PixelWorldEditorModule::create, false);
expose(module);
}
void PixelWorldEditorModule::expose(ssq::Class& cls) {
cls.addFunc("openCatalog", [](PixelWorldEditorModule* self) {
auto result = self->openCatalog();
if (!result) throw std::runtime_error(result.status().describe());
});
cls.addFunc("closeCatalog", [](PixelWorldEditorModule* self) { self->closeCatalog(); });
cls.addFunc("isCatalogOpen", [](PixelWorldEditorModule* self) {
return self->isCatalogOpen();
});
}
} // namespace eve::pixelworld_editor
@@ -0,0 +1,29 @@
#pragma once
#include "common/Module.h"
#include "pixelworld_editor/PixelWorldCatalogPanel.h"
#include <memory>
namespace eve::pixelworld_editor {
/** @brief Script-facing owner for the dedicated PixelWorld Catalog panel. */
class PixelWorldEditorModule final : public eve::Module {
public:
Module_REG(PixelWorldEditorModule);
/** @brief Destroy the panel before the module and its script VM are released. */
~PixelWorldEditorModule() override = default;
/** @brief Open the built-in Catalog draft panel, creating it on first use. */
[[nodiscard]] eve::Result<void> openCatalog();
/** @brief Hide the Catalog panel while preserving its draft. */
void closeCatalog();
/** @brief Report whether the Catalog panel is currently visible. */
bool isCatalogOpen() const;
private:
std::unique_ptr<PixelWorldCatalogPanel> panel_;
};
} // namespace eve::pixelworld_editor
@@ -0,0 +1,248 @@
#include "pixelworld_graphics/PixelWorldGraphics.h"
#include "common/Exception.h"
#include "graphics/Graphics.h"
#include "pixelworld/PixelWorld.h"
#include "pixelworld/PixelWorldControl.h"
#include <simplesquirrel/simplesquirrel.hpp>
#include <algorithm>
#include <array>
#include <cstddef>
#include <functional>
#include <utility>
#include <vector>
namespace eve::pixelworld_graphics {
namespace {
using Rgba = std::array<std::uint8_t, 4>;
Rgba color(const pixelworld::PixelWorld& world, pixelworld::MaterialId material) noexcept {
const std::uint32_t rgba = world.materialDisplayRgba(material);
return {std::uint8_t(rgba >> 24U), std::uint8_t(rgba >> 16U),
std::uint8_t(rgba >> 8U), std::uint8_t(rgba)};
}
} // namespace
struct PixelWorldAtlasRenderer::Impl {
int originX = 0;
int originY = 0;
int width = 0;
int height = 0;
std::vector<std::uint8_t> rgba;
graphics::Texture* texture = nullptr;
std::uint64_t renderedRevision = 0;
std::uint64_t totalUploadedChunks = 0;
std::uint64_t uploadCount = 0;
bool initialized = false;
};
PixelWorldAtlasRenderer::PixelWorldAtlasRenderer(int originX, int originY, int width, int height)
: impl_(std::make_unique<Impl>()) {
if (width <= 0 || height <= 0 || width > 8192 || height > 8192)
throw eve::Exception("PixelWorld atlas size must be within 1..8192");
impl_->originX = originX;
impl_->originY = originY;
impl_->width = width;
impl_->height = height;
impl_->rgba.resize(std::size_t(width) * std::size_t(height) * 4U, 0);
}
PixelWorldAtlasRenderer::~PixelWorldAtlasRenderer() = default;
PixelWorldAtlasRenderer::PixelWorldAtlasRenderer(PixelWorldAtlasRenderer&&) noexcept = default;
PixelWorldAtlasRenderer& PixelWorldAtlasRenderer::operator=(PixelWorldAtlasRenderer&&) noexcept = default;
int PixelWorldAtlasRenderer::sync(pixelworld::PixelWorld* world, graphics::Graphics* graphics) {
if (!world || !graphics) throw eve::Exception("PixelWorldAtlasRenderer::sync requires world and graphics");
const std::uint64_t worldRevision = world->revision();
const bool rolledBack = impl_->initialized && worldRevision < impl_->renderedRevision;
if (rolledBack) {
std::fill(impl_->rgba.begin(), impl_->rgba.end(), std::uint8_t(0));
impl_->renderedRevision = 0;
}
auto chunks = world->snapshotChangedChunks(impl_->initialized && !rolledBack
? impl_->renderedRevision
: 0);
if (impl_->initialized && !rolledBack && chunks.empty()) {
impl_->renderedRevision = worldRevision;
return 0;
}
struct DirtyRect {
int x;
int y;
int width;
int height;
};
std::vector<DirtyRect> dirtyRects;
dirtyRects.reserve(chunks.size());
for (const auto& chunk : chunks) {
const int chunkOriginX = chunk.x * pixelworld::kPixelChunkSize;
const int chunkOriginY = chunk.y * pixelworld::kPixelChunkSize;
const int minX = std::max(chunkOriginX, impl_->originX);
const int minY = std::max(chunkOriginY, impl_->originY);
const int maxX = std::min(chunkOriginX + pixelworld::kPixelChunkSize,
impl_->originX + impl_->width);
const int maxY = std::min(chunkOriginY + pixelworld::kPixelChunkSize,
impl_->originY + impl_->height);
if (minX >= maxX || minY >= maxY) continue;
for (int worldY = minY; worldY < maxY; ++worldY) {
for (int worldX = minX; worldX < maxX; ++worldX) {
const int chunkX = worldX - chunkOriginX;
const int chunkY = worldY - chunkOriginY;
const Rgba pixel = chunk.removed
? Rgba{0, 0, 0, 0}
: color(*world, chunk.cells[std::size_t(chunkY) *
pixelworld::kPixelChunkSize +
std::size_t(chunkX)]
.material);
const std::size_t atlasOffset =
(std::size_t(worldY - impl_->originY) * std::size_t(impl_->width) +
std::size_t(worldX - impl_->originX)) * 4U;
std::copy(pixel.begin(), pixel.end(), impl_->rgba.begin() + std::ptrdiff_t(atlasOffset));
}
}
dirtyRects.push_back({minX - impl_->originX, minY - impl_->originY,
maxX - minX, maxY - minY});
}
const bool creatingTexture = !impl_->texture;
if (creatingTexture) {
impl_->texture = graphics->newTexture(impl_->width, impl_->height, impl_->rgba.data(), false, false);
graphics->setTextureSampler(impl_->texture, "nearest", "none", 1.0F, 0.0F);
} else if (rolledBack) {
graphics->updateTextureRegion(impl_->texture, 0, 0, impl_->width, impl_->height,
impl_->rgba).expect("PixelWorld atlas reset upload failed");
} else if (!dirtyRects.empty()) {
const std::size_t atlasStride = std::size_t(impl_->width) * 4U;
std::vector<graphics::TextureRegionUpload> uploads;
uploads.reserve(dirtyRects.size());
for (const DirtyRect rect : dirtyRects) {
const std::size_t offset =
std::size_t(rect.y) * atlasStride + std::size_t(rect.x) * 4U;
uploads.push_back({rect.x, rect.y, rect.width, rect.height,
std::span<const std::uint8_t>(impl_->rgba).subspan(offset),
atlasStride});
}
graphics->updateTextureRegions(impl_->texture, uploads)
.expect("PixelWorld dirty chunk upload failed");
}
impl_->initialized = true;
impl_->renderedRevision = worldRevision;
impl_->totalUploadedChunks += dirtyRects.size();
if (creatingTexture || rolledBack || !dirtyRects.empty()) ++impl_->uploadCount;
return int(dirtyRects.size());
}
int PixelWorldAtlasRenderer::drawDiagnostics(pixelworld::PixelWorld* world,
graphics::Graphics* graphics, float scale) {
if (!world || !graphics || scale <= 0.0F)
throw eve::Exception("PixelWorldAtlasRenderer::drawDiagnostics requires world, graphics and positive scale");
const int minChunkX = impl_->originX >= 0
? impl_->originX / pixelworld::kPixelChunkSize
: -((-impl_->originX + pixelworld::kPixelChunkSize - 1) /
pixelworld::kPixelChunkSize);
const int minChunkY = impl_->originY >= 0
? impl_->originY / pixelworld::kPixelChunkSize
: -((-impl_->originY + pixelworld::kPixelChunkSize - 1) /
pixelworld::kPixelChunkSize);
const int lastX = impl_->originX + impl_->width - 1;
const int lastY = impl_->originY + impl_->height - 1;
const int maxChunkX = lastX >= 0 ? lastX / pixelworld::kPixelChunkSize
: -((-lastX + pixelworld::kPixelChunkSize - 1) /
pixelworld::kPixelChunkSize);
const int maxChunkY = lastY >= 0 ? lastY / pixelworld::kPixelChunkSize
: -((-lastY + pixelworld::kPixelChunkSize - 1) /
pixelworld::kPixelChunkSize);
const auto diagnostics = world->chunkDiagnostics({minChunkX, minChunkY, maxChunkX, maxChunkY})
.expect("PixelWorld diagnostic overlay region is bounded");
for (const auto& chunk : diagnostics) {
const float x = float(chunk.x * pixelworld::kPixelChunkSize - impl_->originX) * scale;
const float y = float(chunk.y * pixelworld::kPixelChunkSize - impl_->originY) * scale;
const float size = float(pixelworld::kPixelChunkSize) * scale;
const float heat = std::clamp((float(chunk.maximumTemperature) - 20.0F) / 1000.0F,
0.0F, 1.0F);
const float red = heat;
const float green = chunk.active ? 0.95F : 0.25F;
const float blue = chunk.active ? 0.25F : 0.95F;
graphics->drawSolidRectRGBA(x, y, size, 1.0F, red, green, blue, 0.9F);
graphics->drawSolidRectRGBA(x, y + size - 1.0F, size, 1.0F, red, green, blue, 0.9F);
graphics->drawSolidRectRGBA(x, y, 1.0F, size, red, green, blue, 0.9F);
graphics->drawSolidRectRGBA(x + size - 1.0F, y, 1.0F, size, red, green, blue, 0.9F);
}
const auto samples = pixelworld::pixelWorldControlService()
.performanceSamples(world->worldLink().world, 60)
.expect("live PixelWorld has performance samples");
constexpr float graphWidth = 124.0F;
constexpr float graphHeight = 42.0F;
const float graphX = float(impl_->width) * scale - graphWidth - 8.0F;
const float graphY = 8.0F;
graphics->drawSolidRectRGBA(graphX, graphY, graphWidth, graphHeight,
0.015F, 0.02F, 0.035F, 0.82F);
std::uint64_t maximum = 1;
for (const auto& sample : samples) maximum = std::max(maximum, sample.elapsedMicroseconds);
const float barWidth = 2.0F;
const std::size_t visible = std::min<std::size_t>(samples.size(), 60);
for (std::size_t index = 0; index < visible; ++index) {
const auto& sample = samples[samples.size() - visible + index];
const float height = std::max(1.0F, float(sample.elapsedMicroseconds) / float(maximum) *
(graphHeight - 6.0F));
graphics->drawSolidRectRGBA(graphX + 2.0F + float(index) * barWidth,
graphY + graphHeight - 2.0F - height,
1.0F, height, 0.25F, 0.9F, 1.0F, 0.95F);
}
return int(diagnostics.size());
}
graphics::Texture* PixelWorldAtlasRenderer::texture() const noexcept { return impl_->texture; }
int PixelWorldAtlasRenderer::originX() const noexcept { return impl_->originX; }
int PixelWorldAtlasRenderer::originY() const noexcept { return impl_->originY; }
int PixelWorldAtlasRenderer::width() const noexcept { return impl_->width; }
int PixelWorldAtlasRenderer::height() const noexcept { return impl_->height; }
std::uint64_t PixelWorldAtlasRenderer::renderedRevision() const noexcept { return impl_->renderedRevision; }
std::uint64_t PixelWorldAtlasRenderer::totalUploadedChunks() const noexcept { return impl_->totalUploadedChunks; }
std::uint64_t PixelWorldAtlasRenderer::uploadCount() const noexcept { return impl_->uploadCount; }
Module_IMPL(PixelWorldGraphics, new PixelWorldGraphics());
PixelWorldAtlasRenderer* PixelWorldGraphics::newRenderer(int originX, int originY, int width, int height) {
return new PixelWorldAtlasRenderer(originX, originY, width, height);
}
void PixelWorldGraphics::expose(ssq::Table& table) {
auto module = table.addClass(name, PixelWorldGraphics::create, false);
module.addFunc("newRenderer", &PixelWorldGraphics::newRenderer);
auto renderer = table.addClass<PixelWorldAtlasRenderer>(
"PixelWorldAtlasRenderer",
std::function<PixelWorldAtlasRenderer*()>([]() -> PixelWorldAtlasRenderer* { return nullptr; }),
true);
renderer.addFunc("sync", &PixelWorldAtlasRenderer::sync);
renderer.addFunc("drawDiagnostics", &PixelWorldAtlasRenderer::drawDiagnostics);
renderer.addFunc("getTexture", [](PixelWorldAtlasRenderer* self) { return self->texture(); });
renderer.addFunc("getOriginX", [](PixelWorldAtlasRenderer* self) { return self->originX(); });
renderer.addFunc("getOriginY", [](PixelWorldAtlasRenderer* self) { return self->originY(); });
renderer.addFunc("getWidth", [](PixelWorldAtlasRenderer* self) { return self->width(); });
renderer.addFunc("getHeight", [](PixelWorldAtlasRenderer* self) { return self->height(); });
renderer.addFunc("getRenderedRevision", [](PixelWorldAtlasRenderer* self) {
return std::int64_t(self->renderedRevision());
});
renderer.addFunc("getTotalUploadedChunks", [](PixelWorldAtlasRenderer* self) {
return std::int64_t(self->totalUploadedChunks());
});
renderer.addFunc("getUploadCount", [](PixelWorldAtlasRenderer* self) {
return std::int64_t(self->uploadCount());
});
}
void PixelWorldGraphics::expose(ssq::Class& cls) {
cls.addFunc("newRenderer", &PixelWorldGraphics::newRenderer);
}
} // namespace eve::pixelworld_graphics
@@ -0,0 +1,92 @@
#pragma once
#include "common/Module.h"
#include <cstdint>
#include <memory>
namespace eve::graphics {
class Graphics;
class Texture;
} // namespace eve::graphics
namespace eve::pixelworld {
class PixelWorld;
} // namespace eve::pixelworld
namespace eve::pixelworld_graphics {
/**
* @brief Render-thread-affine RGBA atlas projection of a rectangular PixelWorld region.
* @ownership The renderer owns only its CPU atlas; Graphics owns the Texture handle.
* @lifetime A renderer using a texture must be destroyed before its Graphics backend.
*
* The renderer never owns or retains the PixelWorld/Graphics arguments supplied to
* sync(). Its Texture is a borrowed backend-owned handle and must not outlive Graphics.
* The CPU atlas is authoritative only for presentation and is rebuilt after world
* revision rollback (clear/restore); gameplay continues to read PixelWorld itself.
*/
class PixelWorldAtlasRenderer {
public:
/** @brief Create a transparent atlas for `[origin, origin + size)`. Throws on invalid size. */
PixelWorldAtlasRenderer(int originX, int originY, int width, int height);
~PixelWorldAtlasRenderer();
PixelWorldAtlasRenderer(PixelWorldAtlasRenderer&&) noexcept;
PixelWorldAtlasRenderer& operator=(PixelWorldAtlasRenderer&&) noexcept;
PixelWorldAtlasRenderer(const PixelWorldAtlasRenderer&) = delete;
PixelWorldAtlasRenderer& operator=(const PixelWorldAtlasRenderer&) = delete;
/**
* @brief Patch changed CPU chunks and upload the atlas when its world revision advances.
* @return Number of changed chunks patched; zero means no GPU upload was issued.
* @remarks Must run on the render thread. The arguments are borrowed for this call only.
* @ownership `world` and `graphics` remain owned by their callers and are never retained.
* @lifetime Both arguments need only remain valid for the duration of this call.
*/
int sync(pixelworld::PixelWorld* world, graphics::Graphics* graphics);
/**
* @brief Draw Chunk activity/temperature outlines and a bounded step-time graph.
* @param world Borrowed authoritative world for this call only.
* @param graphics Borrowed render-thread graphics service for this call only.
* @param scale Screen pixels per simulated cell; must be positive.
* @return Number of allocated Chunk diagnostics drawn.
*/
int drawDiagnostics(pixelworld::PixelWorld* world, graphics::Graphics* graphics,
float scale);
/**
* @brief Borrow the stable backend-owned atlas texture, or null before first sync.
* @ownership Graphics owns the returned pointer; callers must not delete it.
* @lifetime Valid until the originating Graphics releases textures or is destroyed.
*/
graphics::Texture* texture() const noexcept;
int originX() const noexcept;
int originY() const noexcept;
int width() const noexcept;
int height() const noexcept;
std::uint64_t renderedRevision() const noexcept;
std::uint64_t totalUploadedChunks() const noexcept;
std::uint64_t uploadCount() const noexcept;
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
/** @brief Optional rendering satellite for the backend-neutral PixelWorld module. */
class PixelWorldGraphics : public Module {
public:
Module_REG(PixelWorldGraphics);
/**
* @brief Create a caller-owned atlas renderer for one world-space rectangle.
* @return Newly allocated non-null renderer owned by the caller.
* @ownership The caller must delete the returned renderer.
* @lifetime If synchronized, the renderer must not be used after its Graphics backend.
*/
[[nodiscard("retain and delete the returned PixelWorldAtlasRenderer")]]
PixelWorldAtlasRenderer* newRenderer(int originX, int originY, int width, int height);
};
} // namespace eve::pixelworld_graphics
@@ -0,0 +1,627 @@
#include "pixelworld_physics/PixelWorldPhysics.h"
#include "common/Exception.h"
#include "physics/Body.h"
#include "physics/World.h"
#include <algorithm>
#include <cmath>
#include <limits>
#include <map>
#include <set>
#include <string>
namespace eve::pixelworld_physics {
namespace {
template <class T>
eve::Result<T> failure(eve::DiagnosticCode code, std::string message, std::string path) {
return eve::Result<T>::failure(eve::Diagnostic::error(
code, std::move(message), std::move(path), {}, "pixelworld_physics"));
}
std::vector<FragmentCollisionRect> decompose(const eve::pixelworld::PixelFragment& fragment) {
const int width = fragment.width, height = fragment.height;
std::vector<std::uint8_t> consumed(std::size_t(width) * std::size_t(height));
std::vector<FragmentCollisionRect> result;
const auto occupied = [&](int x, int y) {
return fragment.cells[std::size_t(y) * std::size_t(width) + std::size_t(x)].material !=
eve::pixelworld::MaterialId::Air;
};
for (int y = 0; y < height; ++y)
for (int x = 0; x < width; ++x) {
const std::size_t start = std::size_t(y) * std::size_t(width) + std::size_t(x);
if (consumed[start] || !occupied(x, y)) continue;
int rectWidth = 1;
while (x + rectWidth < width && !consumed[start + std::size_t(rectWidth)] &&
occupied(x + rectWidth, y))
++rectWidth;
int rectHeight = 1;
for (; y + rectHeight < height; ++rectHeight) {
bool complete = true;
for (int ox = 0; ox < rectWidth; ++ox) {
const std::size_t index = std::size_t(y + rectHeight) * std::size_t(width) +
std::size_t(x + ox);
if (consumed[index] || !occupied(x + ox, y + rectHeight)) {
complete = false;
break;
}
}
if (!complete) break;
}
for (int oy = 0; oy < rectHeight; ++oy)
for (int ox = 0; ox < rectWidth; ++ox)
consumed[std::size_t(y + oy) * std::size_t(width) + std::size_t(x + ox)] = 1;
result.push_back({x, y, rectWidth, rectHeight});
}
return result;
}
eve::pixelworld::PixelFragment rotated(const eve::pixelworld::PixelFragment& source, int turns) {
turns = ((turns % 4) + 4) % 4;
eve::pixelworld::PixelFragment result = source;
if (turns == 0) return result;
result.width = (turns % 2 == 0) ? source.width : source.height;
result.height = (turns % 2 == 0) ? source.height : source.width;
result.cells.assign(std::size_t(result.width) * std::size_t(result.height), {});
for (int y = 0; y < source.height; ++y)
for (int x = 0; x < source.width; ++x) {
int rx = 0, ry = 0;
if (turns == 1) {
rx = source.height - 1 - y;
ry = x;
} else if (turns == 2) {
rx = source.width - 1 - x;
ry = source.height - 1 - y;
} else {
rx = y;
ry = source.width - 1 - x;
}
result.cells[std::size_t(ry) * std::size_t(result.width) + std::size_t(rx)] =
source.cells[std::size_t(y) * std::size_t(source.width) + std::size_t(x)];
}
return result;
}
struct GridPoint {
int x = 0;
int y = 0;
auto operator<=>(const GridPoint&) const = default;
};
struct GridEdge {
GridPoint from;
GridPoint to;
};
int direction(const GridEdge& edge) {
if (edge.to.x > edge.from.x) return 0;
if (edge.to.y > edge.from.y) return 1;
if (edge.to.x < edge.from.x) return 2;
return 3;
}
int turnRank(int previous, int next) {
const int turn = (next - previous + 4) % 4;
if (turn == 1) return 0;
if (turn == 0) return 1;
if (turn == 3) return 2;
return 3;
}
bool collinear(GridPoint a, GridPoint b, GridPoint c) {
return (b.x - a.x) * (c.y - b.y) == (b.y - a.y) * (c.x - b.x);
}
bool validCellBounds(float minimum, float maximum) {
return std::isfinite(minimum) && std::isfinite(maximum) &&
double(minimum) >= double(std::numeric_limits<int>::min()) + 2.0 &&
double(maximum) <= double(std::numeric_limits<int>::max()) - 2.0;
}
} // namespace
eve::Result<std::vector<TerrainCollisionContour>> extractTerrainContours(
const eve::pixelworld::PixelWorld& pixelWorld, int chunkX, int chunkY,
std::uint32_t maximumVertices) {
if (maximumVertices < 2)
return failure<std::vector<TerrainCollisionContour>>(
eve::DiagnosticCode::InvalidArgument, "maximumVertices must be at least two",
"maximumVertices");
constexpr int size = eve::pixelworld::kPixelChunkSize;
const std::int64_t wideOriginX = std::int64_t(chunkX) * size;
const std::int64_t wideOriginY = std::int64_t(chunkY) * size;
if (wideOriginX < std::numeric_limits<int>::min() ||
wideOriginX + size > std::numeric_limits<int>::max() ||
wideOriginY < std::numeric_limits<int>::min() ||
wideOriginY + size > std::numeric_limits<int>::max())
return failure<std::vector<TerrainCollisionContour>>(
eve::DiagnosticCode::InvalidArgument, "Chunk coordinates exceed world coordinate range",
"chunk");
const int originX = int(wideOriginX), originY = int(wideOriginY);
const auto solid = [&](int localX, int localY) {
return pixelWorld.isSolidMaterial(
pixelWorld.getCell(originX + localX, originY + localY).material);
};
std::vector<GridEdge> edges;
edges.reserve(size * 4);
for (int y = 0; y < size; ++y)
for (int x = 0; x < size; ++x) {
if (!solid(x, y)) continue;
if (!solid(x, y - 1)) edges.push_back({{x, y}, {x + 1, y}});
if (!solid(x + 1, y)) edges.push_back({{x + 1, y}, {x + 1, y + 1}});
if (!solid(x, y + 1)) edges.push_back({{x + 1, y + 1}, {x, y + 1}});
if (!solid(x - 1, y)) edges.push_back({{x, y + 1}, {x, y}});
}
std::map<GridPoint, std::vector<std::size_t>> outgoing;
for (std::size_t index = 0; index < edges.size(); ++index)
outgoing[edges[index].from].push_back(index);
std::vector<std::uint8_t> used(edges.size());
std::vector<TerrainCollisionContour> contours;
std::uint64_t vertexCount = 0;
for (std::size_t first = 0; first < edges.size(); ++first) {
if (used[first]) continue;
std::vector<GridPoint> points{edges[first].from};
std::size_t current = first;
bool loop = false;
while (true) {
used[current] = 1;
const GridPoint end = edges[current].to;
if (end == points.front()) {
loop = true;
break;
}
points.push_back(end);
const auto found = outgoing.find(end);
if (found == outgoing.end()) break;
std::size_t next = edges.size();
int bestRank = 5;
for (const std::size_t candidate : found->second) {
if (used[candidate]) continue;
const int rank = turnRank(direction(edges[current]), direction(edges[candidate]));
if (rank < bestRank || (rank == bestRank && candidate < next)) {
bestRank = rank;
next = candidate;
}
}
if (next == edges.size()) break;
current = next;
}
bool changed = true;
while (changed && points.size() > (loop ? 3U : 2U)) {
changed = false;
for (std::size_t index = 0; index < points.size(); ++index) {
if (!loop && (index == 0 || index + 1 == points.size())) continue;
const std::size_t before = (index + points.size() - 1) % points.size();
const std::size_t after = (index + 1) % points.size();
if (collinear(points[before], points[index], points[after])) {
points.erase(points.begin() + std::ptrdiff_t(index));
changed = true;
break;
}
}
}
if (points.size() < (loop ? 3U : 2U)) continue;
vertexCount += points.size();
if (vertexCount > maximumVertices)
return failure<std::vector<TerrainCollisionContour>>(
eve::DiagnosticCode::PreconditionViolation,
"terrain contour exceeds maximumVertices", "maximumVertices");
TerrainCollisionContour contour;
contour.loop = loop;
contour.vertices.reserve(points.size() * 2);
for (const GridPoint point : points) {
contour.vertices.push_back(float(point.x));
contour.vertices.push_back(float(point.y));
}
contours.push_back(std::move(contour));
}
return eve::Result<std::vector<TerrainCollisionContour>>::success(std::move(contours));
}
eve::Result<PixelTerrainContact> probeTerrainCircle(
const eve::pixelworld::PixelWorld& pixelWorld, float centerX, float centerY, float radius,
std::uint32_t maximumCells) {
if (!std::isfinite(centerX) || !std::isfinite(centerY) || !std::isfinite(radius) ||
radius <= 0.f || maximumCells == 0)
return failure<PixelTerrainContact>(eve::DiagnosticCode::InvalidArgument,
"circle probe parameters are invalid", "probe");
if (!validCellBounds(centerX - radius, centerX + radius) ||
!validCellBounds(centerY - radius, centerY + radius))
return failure<PixelTerrainContact>(eve::DiagnosticCode::InvalidArgument,
"circle probe exceeds world coordinate range", "probe");
const int minX = int(std::floor(centerX - radius));
const int maxX = int(std::floor(centerX + radius));
const int minY = int(std::floor(centerY - radius));
const int maxY = int(std::floor(centerY + radius));
const std::uint64_t width = std::uint64_t(std::int64_t(maxX) - minX + 1);
const std::uint64_t height = std::uint64_t(std::int64_t(maxY) - minY + 1);
if (width > maximumCells || height > maximumCells || width * height > maximumCells)
return failure<PixelTerrainContact>(eve::DiagnosticCode::PreconditionViolation,
"circle probe exceeds maximumCells", "maximumCells");
PixelTerrainContact best;
for (int y = minY; y <= maxY; ++y)
for (int x = minX; x <= maxX; ++x) {
const auto material = pixelWorld.getCell(x, y).material;
if (!pixelWorld.isSolidMaterial(material)) continue;
const float closestX = std::clamp(centerX, float(x), float(x + 1));
const float closestY = std::clamp(centerY, float(y), float(y + 1));
const float dx = centerX - closestX, dy = centerY - closestY;
const float distanceSquared = dx * dx + dy * dy;
if (distanceSquared >= radius * radius) continue;
const float distance = std::sqrt(distanceSquared);
const float depth = radius - distance;
if (best.hit && depth < best.depth) continue;
if (best.hit && depth == best.depth && std::pair(y, x) >= std::pair(best.cellY, best.cellX))
continue;
best.hit = true;
best.material = material;
best.cellX = x;
best.cellY = y;
best.pointX = closestX;
best.pointY = closestY;
best.depth = depth;
if (distance > 0.f) {
best.normalX = dx / distance;
best.normalY = dy / distance;
} else {
const float left = centerX - float(x), right = float(x + 1) - centerX;
const float top = centerY - float(y), bottom = float(y + 1) - centerY;
const float nearest = std::min({left, right, top, bottom});
if (nearest == left) best.normalX = -1.f;
else if (nearest == right) best.normalX = 1.f;
else if (nearest == top) best.normalY = -1.f;
else best.normalY = 1.f;
}
}
return eve::Result<PixelTerrainContact>::success(best);
}
eve::Result<PixelTerrainContact> sweepTerrainCircle(
const eve::pixelworld::PixelWorld& pixelWorld, float startX, float startY, float endX,
float endY, float radius, std::uint32_t maximumCells) {
if (!std::isfinite(startX) || !std::isfinite(startY) || !std::isfinite(endX) ||
!std::isfinite(endY) || !std::isfinite(radius) || radius <= 0.f || maximumCells == 0)
return failure<PixelTerrainContact>(eve::DiagnosticCode::InvalidArgument,
"circle sweep parameters are invalid", "sweep");
if (!validCellBounds(std::min(startX, endX) - radius,
std::max(startX, endX) + radius) ||
!validCellBounds(std::min(startY, endY) - radius,
std::max(startY, endY) + radius))
return failure<PixelTerrainContact>(eve::DiagnosticCode::InvalidArgument,
"circle sweep exceeds world coordinate range", "sweep");
auto initial = probeTerrainCircle(pixelWorld, startX, startY, radius, maximumCells);
if (!initial.ok()) return initial;
if (initial.value().hit) return initial;
const int minX = int(std::floor(std::min(startX, endX) - radius));
const int maxX = int(std::floor(std::max(startX, endX) + radius));
const int minY = int(std::floor(std::min(startY, endY) - radius));
const int maxY = int(std::floor(std::max(startY, endY) + radius));
const std::uint64_t width = std::uint64_t(std::int64_t(maxX) - minX + 1);
const std::uint64_t height = std::uint64_t(std::int64_t(maxY) - minY + 1);
if (width > maximumCells || height > maximumCells || width * height > maximumCells)
return failure<PixelTerrainContact>(eve::DiagnosticCode::PreconditionViolation,
"circle sweep exceeds maximumCells", "maximumCells");
const float deltaX = endX - startX, deltaY = endY - startY;
PixelTerrainContact best;
best.fraction = 1.f;
for (int y = minY; y <= maxY; ++y)
for (int x = minX; x <= maxX; ++x) {
const auto material = pixelWorld.getCell(x, y).material;
if (!pixelWorld.isSolidMaterial(material)) continue;
float cellFraction = 2.f, cellNormalX = 0.f, cellNormalY = 0.f;
const auto consider = [&](float fraction, float nx, float ny) {
if (fraction >= 0.f && fraction <= 1.f && fraction < cellFraction) {
cellFraction = fraction;
cellNormalX = nx;
cellNormalY = ny;
}
};
if (deltaX > 0.f) {
const float fraction = (float(x) - radius - startX) / deltaX;
const float centerY = startY + deltaY * fraction;
if (centerY >= float(y) && centerY <= float(y + 1))
consider(fraction, -1.f, 0.f);
} else if (deltaX < 0.f) {
const float fraction = (float(x + 1) + radius - startX) / deltaX;
const float centerY = startY + deltaY * fraction;
if (centerY >= float(y) && centerY <= float(y + 1))
consider(fraction, 1.f, 0.f);
}
if (deltaY > 0.f) {
const float fraction = (float(y) - radius - startY) / deltaY;
const float centerX = startX + deltaX * fraction;
if (centerX >= float(x) && centerX <= float(x + 1))
consider(fraction, 0.f, -1.f);
} else if (deltaY < 0.f) {
const float fraction = (float(y + 1) + radius - startY) / deltaY;
const float centerX = startX + deltaX * fraction;
if (centerX >= float(x) && centerX <= float(x + 1))
consider(fraction, 0.f, 1.f);
}
const float quadraticA = deltaX * deltaX + deltaY * deltaY;
if (quadraticA > 0.f) {
struct Corner {
float x;
float y;
int quadrantX;
int quadrantY;
};
const Corner corners[] = {
{float(x), float(y), -1, -1}, {float(x + 1), float(y), 1, -1},
{float(x + 1), float(y + 1), 1, 1}, {float(x), float(y + 1), -1, 1}};
for (const Corner corner : corners) {
const float offsetX = startX - corner.x, offsetY = startY - corner.y;
const float quadraticB = 2.f * (offsetX * deltaX + offsetY * deltaY);
const float quadraticC = offsetX * offsetX + offsetY * offsetY - radius * radius;
const float discriminant = quadraticB * quadraticB - 4.f * quadraticA * quadraticC;
if (discriminant < 0.f) continue;
const float fraction =
(-quadraticB - std::sqrt(discriminant)) / (2.f * quadraticA);
if (fraction < 0.f || fraction > 1.f) continue;
const float hitX = startX + deltaX * fraction;
const float hitY = startY + deltaY * fraction;
if ((corner.quadrantX < 0 && hitX > corner.x) ||
(corner.quadrantX > 0 && hitX < corner.x) ||
(corner.quadrantY < 0 && hitY > corner.y) ||
(corner.quadrantY > 0 && hitY < corner.y))
continue;
consider(fraction, (hitX - corner.x) / radius,
(hitY - corner.y) / radius);
}
}
if (cellFraction > 1.f) continue;
if (best.hit && cellFraction > best.fraction) continue;
if (best.hit && cellFraction == best.fraction &&
std::pair(y, x) >= std::pair(best.cellY, best.cellX))
continue;
best.hit = true;
best.material = material;
best.cellX = x;
best.cellY = y;
best.fraction = cellFraction;
best.pointX = startX + deltaX * cellFraction;
best.pointY = startY + deltaY * cellFraction;
best.normalX = cellNormalX;
best.normalY = cellNormalY;
}
if (!best.hit) best.fraction = 1.f;
return eve::Result<PixelTerrainContact>::success(best);
}
PixelFragmentBody::PixelFragmentBody(eve::pixelworld::PixelFragment fragment,
eve::physics::PhysicsLink link,
std::vector<FragmentCollisionRect> rectangles)
: fragment_(std::move(fragment)), link_(link), rectangles_(std::move(rectangles)) {}
eve::Result<std::unique_ptr<PixelFragmentBody>> PixelFragmentBody::create(
eve::physics::World& world, eve::pixelworld::PixelFragment fragment, FragmentBodyConfig config) {
if (fragment.width <= 0 || fragment.height <= 0 || fragment.id == 0 ||
std::uint64_t(fragment.width) * std::uint64_t(fragment.height) != fragment.cells.size())
return failure<std::unique_ptr<PixelFragmentBody>>(
eve::DiagnosticCode::InvalidArgument, "fragment bitmap metadata is invalid", "fragment");
if (!std::isfinite(config.density) || config.density <= 0.f ||
!std::isfinite(config.friction) || config.friction < 0.f ||
!std::isfinite(config.restitution) || config.restitution < 0.f || config.maximumFixtures == 0)
return failure<std::unique_ptr<PixelFragmentBody>>(
eve::DiagnosticCode::InvalidArgument, "fragment body config is invalid", "config");
auto rectangles = decompose(fragment);
if (rectangles.empty() || rectangles.size() > config.maximumFixtures)
return failure<std::unique_ptr<PixelFragmentBody>>(
eve::DiagnosticCode::PreconditionViolation,
"fragment collision decomposition is empty or exceeds maximumFixtures", "maximumFixtures");
eve::physics::Body* body = nullptr;
try {
const float centerX = float(fragment.originX) + float(fragment.width) * 0.5f;
const float centerY = float(fragment.originY) + float(fragment.height) * 0.5f;
body = world.newBody("dynamic", centerX, centerY);
for (const FragmentCollisionRect& rect : rectangles) {
const float offsetX = float(rect.x) + float(rect.width) * 0.5f - float(fragment.width) * 0.5f;
const float offsetY = float(rect.y) + float(rect.height) * 0.5f - float(fragment.height) * 0.5f;
body->newRectangleFixtureAt(float(rect.width), float(rect.height), offsetX, offsetY,
config.density, config.friction, config.restitution);
}
auto link = eve::physics::PhysicsLink::fromBody(*body);
if (!link.ok()) {
body->destroy();
return eve::Result<std::unique_ptr<PixelFragmentBody>>::failure(link.status());
}
return eve::Result<std::unique_ptr<PixelFragmentBody>>::success(
std::unique_ptr<PixelFragmentBody>(new PixelFragmentBody(
std::move(fragment), std::move(link).takeValue(), std::move(rectangles))));
} catch (const std::exception& error) {
if (body && body->isValid()) body->destroy();
return failure<std::unique_ptr<PixelFragmentBody>>(
eve::DiagnosticCode::Failed, std::string("failed to create fragment fixtures: ") + error.what(),
"physics");
}
}
const std::vector<FragmentCollisionRect>& PixelFragmentBody::collisionRects() const noexcept {
return rectangles_;
}
std::uint64_t PixelFragmentBody::fragmentId() const noexcept { return fragment_.id; }
eve::physics::PhysicsLink PixelFragmentBody::physicsLink() const noexcept { return link_; }
bool PixelFragmentBody::isRasterized() const noexcept { return rasterized_; }
eve::Result<FragmentSettleReceipt> PixelFragmentBody::settleIfSleeping(
eve::physics::World& physicsWorld, eve::pixelworld::PixelWorld& pixelWorld) {
if (rasterized_ || physicsReleased_)
return failure<FragmentSettleReceipt>(eve::DiagnosticCode::PreconditionViolation,
"fragment body is no longer active", "state");
auto resolved = link_.resolve(physicsWorld);
if (!resolved.ok()) return eve::Result<FragmentSettleReceipt>::failure(resolved.status());
eve::physics::Body* body = resolved.value();
if (body->isAwake())
return eve::Result<FragmentSettleReceipt>::success(
{FragmentSettleDisposition::StillAwake, fragment_.id, 0, 0});
constexpr double halfPi = 1.57079632679489661923;
const int turns = int(std::llround(double(body->getAngle()) / halfPi));
auto candidate = rotated(fragment_, turns);
const int originX = int(std::lround(double(body->getX()) - double(candidate.width) * 0.5));
const int originY = int(std::lround(double(body->getY()) - double(candidate.height) * 0.5));
auto rasterized = pixelWorld.rasterizeFragment(candidate, originX, originY);
if (!rasterized.ok()) return eve::Result<FragmentSettleReceipt>::failure(rasterized.status());
body->destroy();
physicsReleased_ = true;
rasterized_ = true;
return eve::Result<FragmentSettleReceipt>::success(
{FragmentSettleDisposition::Rasterized, fragment_.id,
rasterized.value().cellsPlaced, ((turns % 4) + 4) % 4});
}
eve::Result<void> PixelFragmentBody::releasePhysics(eve::physics::World& physicsWorld) {
if (physicsReleased_) return eve::Result<void>::success();
auto resolved = link_.resolve(physicsWorld);
if (!resolved.ok()) return eve::Result<void>::failure(resolved.status());
resolved.value()->destroy();
physicsReleased_ = true;
return eve::Result<void>::success();
}
struct PixelTerrainCollisionCache::Impl {
using Coord = std::pair<int, int>;
std::map<Coord, eve::physics::PhysicsLink> bodies;
eve::pixelworld::PixelWorldLink pixelWorld;
eve::physics::PhysicsWorldHandle physicsWorld = eve::physics::PhysicsWorldHandle::invalid();
std::uint64_t revision = 0;
};
PixelTerrainCollisionCache::PixelTerrainCollisionCache() : impl_(std::make_unique<Impl>()) {}
PixelTerrainCollisionCache::~PixelTerrainCollisionCache() = default;
PixelTerrainCollisionCache::PixelTerrainCollisionCache(PixelTerrainCollisionCache&&) noexcept = default;
PixelTerrainCollisionCache& PixelTerrainCollisionCache::operator=(PixelTerrainCollisionCache&&) noexcept = default;
eve::Result<TerrainCollisionSyncReceipt> PixelTerrainCollisionCache::sync(
eve::physics::World& physicsWorld, const eve::pixelworld::PixelWorld& pixelWorld,
std::uint32_t maximumFixturesPerChunk) {
if (maximumFixturesPerChunk == 0)
return failure<TerrainCollisionSyncReceipt>(eve::DiagnosticCode::InvalidArgument,
"maximumFixturesPerChunk must be positive",
"maximumFixturesPerChunk");
const bool samePixelWorld = impl_->pixelWorld == pixelWorld.worldLink();
const bool samePhysicsWorld = impl_->physicsWorld == physicsWorld.runtimeHandle();
const std::uint64_t since = (samePixelWorld && samePhysicsWorld) ? impl_->revision : 0;
const auto changed = pixelWorld.snapshotChangedChunks(since);
std::set<Impl::Coord> rebuildCoords;
for (const auto& chunk : changed) {
const Impl::Coord coord{chunk.x, chunk.y};
rebuildCoords.insert(coord);
constexpr Impl::Coord neighbors[] = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
for (const auto [dx, dy] : neighbors) {
const Impl::Coord neighbor{chunk.x + dx, chunk.y + dy};
if (impl_->bodies.contains(neighbor)) rebuildCoords.insert(neighbor);
}
}
struct Candidate {
Impl::Coord coord;
eve::physics::PhysicsLink link;
std::uint32_t fixtures = 0;
};
std::vector<Candidate> staged;
staged.reserve(rebuildCoords.size());
const auto destroyStaged = [&] {
for (const Candidate& candidate : staged) {
auto resolved = candidate.link.resolve(physicsWorld);
if (resolved.ok()) resolved.value()->destroy();
}
};
for (const auto& coord : rebuildCoords) {
auto extracted = extractTerrainContours(pixelWorld, coord.first, coord.second);
if (!extracted.ok()) {
destroyStaged();
return eve::Result<TerrainCollisionSyncReceipt>::failure(extracted.status());
}
auto contours = std::move(extracted).takeValue();
if (contours.size() > maximumFixturesPerChunk) {
destroyStaged();
return failure<TerrainCollisionSyncReceipt>(
eve::DiagnosticCode::PreconditionViolation,
"terrain Chunk contours exceed maximumFixturesPerChunk", "chunk");
}
if (contours.empty()) {
staged.push_back({coord, {}, 0});
continue;
}
eve::physics::Body* body = nullptr;
try {
body = physicsWorld.newBody(
"static", float(coord.first) * float(eve::pixelworld::kPixelChunkSize),
float(coord.second) * float(eve::pixelworld::kPixelChunkSize));
for (const TerrainCollisionContour& contour : contours)
body->newChainFixture(contour.vertices, contour.loop, 0.5f, 0.f);
auto link = eve::physics::PhysicsLink::fromBody(*body);
if (!link.ok()) {
body->destroy();
destroyStaged();
return eve::Result<TerrainCollisionSyncReceipt>::failure(link.status());
}
staged.push_back({coord, std::move(link).takeValue(),
std::uint32_t(contours.size())});
} catch (const std::exception& error) {
if (body && body->isValid()) body->destroy();
destroyStaged();
return failure<TerrainCollisionSyncReceipt>(
eve::DiagnosticCode::Failed,
std::string("failed to stage terrain collision body: ") + error.what(), "physics");
}
}
TerrainCollisionSyncReceipt receipt;
receipt.sourceRevision = pixelWorld.revision();
receipt.chunksRebuilt = std::uint32_t(staged.size());
if (!samePhysicsWorld) {
impl_->bodies.clear();
} else if (!samePixelWorld) {
for (const auto& [coord, link] : impl_->bodies) {
(void)coord;
auto resolved = link.resolve(physicsWorld);
if (resolved.ok()) resolved.value()->destroy();
++receipt.bodiesRemoved;
}
impl_->bodies.clear();
}
for (const Candidate& candidate : staged) {
const auto old = impl_->bodies.find(candidate.coord);
if (old != impl_->bodies.end()) {
auto resolved = old->second.resolve(physicsWorld);
if (resolved.ok()) resolved.value()->destroy();
impl_->bodies.erase(old);
++receipt.bodiesRemoved;
}
if (candidate.fixtures != 0) {
impl_->bodies.emplace(candidate.coord, candidate.link);
receipt.fixturesCreated += candidate.fixtures;
}
}
impl_->pixelWorld = pixelWorld.worldLink();
impl_->physicsWorld = physicsWorld.runtimeHandle();
impl_->revision = pixelWorld.revision();
return eve::Result<TerrainCollisionSyncReceipt>::success(receipt);
}
eve::Result<void> PixelTerrainCollisionCache::clearPhysics(eve::physics::World& physicsWorld) {
if (impl_->physicsWorld.isValid() && impl_->physicsWorld != physicsWorld.runtimeHandle())
return failure<void>(eve::DiagnosticCode::StaleHandle,
"terrain collision cache belongs to another physics world", "physicsWorld");
for (const auto& [coord, link] : impl_->bodies) {
(void)coord;
auto resolved = link.resolve(physicsWorld);
if (resolved.ok()) resolved.value()->destroy();
}
impl_->bodies.clear();
impl_->pixelWorld = {};
impl_->physicsWorld = eve::physics::PhysicsWorldHandle::invalid();
impl_->revision = 0;
return eve::Result<void>::success();
}
std::uint64_t PixelTerrainCollisionCache::sourceRevision() const noexcept { return impl_->revision; }
std::size_t PixelTerrainCollisionCache::bodyCount() const noexcept { return impl_->bodies.size(); }
} // namespace eve::pixelworld_physics
@@ -0,0 +1,201 @@
#pragma once
#include "common/Result.h"
#include "physics/PhysicsLink.h"
#include "pixelworld/PixelWorld.h"
#include <cstdint>
#include <memory>
#include <vector>
namespace eve::physics {
class World;
}
namespace eve::pixelworld_physics {
/** @brief One deterministic axis-aligned fixture produced from a fragment bitmap. */
struct FragmentCollisionRect {
int x = 0;
int y = 0;
int width = 0;
int height = 0;
};
/** @brief Policy for dynamic fragment fixture creation and terrain settlement. */
struct FragmentBodyConfig {
float density = 1.0f;
float friction = 0.4f;
float restitution = 0.05f;
std::uint32_t maximumFixtures = 256;
};
/** @brief Observable outcome of polling a fragment body for settlement. */
enum class FragmentSettleDisposition : std::uint8_t { StillAwake, Rasterized };
/** @brief Receipt for one checked fragment-body settlement poll. */
struct FragmentSettleReceipt {
FragmentSettleDisposition disposition = FragmentSettleDisposition::StillAwake;
std::uint64_t fragmentId = 0;
std::uint32_t cellsRasterized = 0;
int quarterTurns = 0;
};
/** @brief Counters for one transactional dirty-Chunk terrain collision sync. */
struct TerrainCollisionSyncReceipt {
std::uint64_t sourceRevision = 0;
std::uint32_t chunksRebuilt = 0;
std::uint32_t fixturesCreated = 0;
std::uint32_t bodiesRemoved = 0;
};
/** @brief One simplified local-space boundary contour generated for a terrain Chunk. */
struct TerrainCollisionContour {
/** @brief Packed local pixel-space vertices x0,y0,x1,y1,... without a repeated loop endpoint. */
std::vector<float> vertices;
/** @brief Whether the last vertex connects back to the first. */
bool loop = false;
};
/** @brief Deterministic contact returned by an authoritative terrain probe or sweep. */
struct PixelTerrainContact {
bool hit = false;
eve::pixelworld::MaterialId material = eve::pixelworld::MaterialId::Air;
int cellX = 0;
int cellY = 0;
float pointX = 0.f;
float pointY = 0.f;
float normalX = 0.f;
float normalY = 0.f;
/** @brief Probe penetration depth, or zero for a non-overlapping sweep hit. */
float depth = 0.f;
/** @brief Sweep time in [0,1], or zero for a probe. */
float fraction = 0.f;
};
/**
* @brief Extract simplified binary Marching-Squares-equivalent boundary chains for one Chunk.
* @remarks Solid occupancy is sampled through PixelWorld, including the one-cell neighbor halo,
* so shared Chunk borders do not emit internal collision edges. Output is deterministic and owning.
*/
[[nodiscard]] eve::Result<std::vector<TerrainCollisionContour>> extractTerrainContours(
const eve::pixelworld::PixelWorld& pixelWorld, int chunkX, int chunkY,
std::uint32_t maximumVertices = 4096);
/**
* @brief Probe a circular character contact directly against authoritative solid material cells.
* @param maximumCells Positive upper bound on candidate cells inspected.
*/
[[nodiscard]] eve::Result<PixelTerrainContact> probeTerrainCircle(
const eve::pixelworld::PixelWorld& pixelWorld, float centerX, float centerY, float radius,
std::uint32_t maximumCells = 4096);
/**
* @brief Sweep a circular character shape continuously through authoritative solid material cells.
* @remarks Uses an exact segment-vs-expanded-cell slab test and deterministic fraction/cell tie-breaks.
*/
[[nodiscard]] eve::Result<PixelTerrainContact> sweepTerrainCircle(
const eve::pixelworld::PixelWorld& pixelWorld, float startX, float startY, float endX,
float endY, float radius, std::uint32_t maximumCells = 16384);
/**
* @brief Owning detached bitmap plus a non-owning, stale-safe dynamic body link.
*
* PixelWorld remains the only owner of live terrain. This adapter owns the detached
* bitmap while Physics World owns the body and fixtures. It stores no World/Body raw
* pointer across calls; every operation resolves `PhysicsLink` against a caller-borrowed
* world on the owning simulation thread. No method invokes scripts or unknown callbacks.
*/
class PixelFragmentBody {
public:
~PixelFragmentBody() = default;
PixelFragmentBody(PixelFragmentBody&&) noexcept = default;
PixelFragmentBody& operator=(PixelFragmentBody&&) noexcept = default;
PixelFragmentBody(const PixelFragmentBody&) = delete;
PixelFragmentBody& operator=(const PixelFragmentBody&) = delete;
/**
* @brief Create one dynamic Box2D body using deterministic greedy rectangle decomposition.
* @param world Borrowed Physics world; it owns the created body and fixtures.
* @param fragment Owning detached bitmap transferred into the adapter on success.
* @param config Finite fixture properties and a positive fixture budget.
* @return Owning adapter; failure destroys any partially created body.
*/
[[nodiscard]] static eve::Result<std::unique_ptr<PixelFragmentBody>> create(
eve::physics::World& world, eve::pixelworld::PixelFragment fragment,
FragmentBodyConfig config = {});
/** @brief Borrow deterministic collision rectangles for diagnostics until adapter mutation/destruction. */
[[nodiscard]] const std::vector<FragmentCollisionRect>& collisionRects() const noexcept;
/** @brief Detached bitmap id retained by this adapter. */
[[nodiscard]] std::uint64_t fragmentId() const noexcept;
/** @brief Process-local physics relationship; resolve it before every use. */
[[nodiscard]] eve::physics::PhysicsLink physicsLink() const noexcept;
/** @brief Whether the bitmap has already returned to authoritative terrain. */
[[nodiscard]] bool isRasterized() const noexcept;
/**
* @brief If the body sleeps, snap its bitmap to the nearest quarter turn and rasterize it.
* @remarks Physics body destruction occurs only after PixelWorld accepts the entire bitmap.
* A stale physics/world link or terrain conflict returns failure without changing ownership.
*/
[[nodiscard]] eve::Result<FragmentSettleReceipt> settleIfSleeping(
eve::physics::World& physicsWorld, eve::pixelworld::PixelWorld& pixelWorld);
/**
* @brief Explicitly destroy a still-live physics body without rasterizing its bitmap.
* @return Success if already released; stale/foreign world links are reported.
*/
[[nodiscard]] eve::Result<void> releasePhysics(eve::physics::World& physicsWorld);
private:
PixelFragmentBody(eve::pixelworld::PixelFragment fragment, eve::physics::PhysicsLink link,
std::vector<FragmentCollisionRect> rectangles);
eve::pixelworld::PixelFragment fragment_;
eve::physics::PhysicsLink link_;
std::vector<FragmentCollisionRect> rectangles_;
bool rasterized_ = false;
bool physicsReleased_ = false;
};
/**
* @brief Incremental static collision projection of authoritative PixelWorld chunks.
*
* The cache owns only process-local PhysicsLinks and revision metadata. Physics World
* owns all bodies/fixtures and PixelWorld owns all material cells. Sync is owner-thread
* affine, invokes no callbacks, and stages every replacement body before committing.
*/
class PixelTerrainCollisionCache {
public:
PixelTerrainCollisionCache();
~PixelTerrainCollisionCache();
PixelTerrainCollisionCache(PixelTerrainCollisionCache&&) noexcept;
PixelTerrainCollisionCache& operator=(PixelTerrainCollisionCache&&) noexcept;
PixelTerrainCollisionCache(const PixelTerrainCollisionCache&) = delete;
PixelTerrainCollisionCache& operator=(const PixelTerrainCollisionCache&) = delete;
/**
* @brief Rebuild only chunks newer than the cached source revision.
* @param physicsWorld Borrowed owner of static bodies.
* @param pixelWorld Borrowed authoritative material world.
* @param maximumFixturesPerChunk Positive deterministic complexity budget.
* @return Applied counters; failure destroys staged candidates and preserves prior cache entries.
*/
[[nodiscard]] eve::Result<TerrainCollisionSyncReceipt> sync(
eve::physics::World& physicsWorld, const eve::pixelworld::PixelWorld& pixelWorld,
std::uint32_t maximumFixturesPerChunk = 256);
/** @brief Explicitly destroy every resolvable projected body and reset revision tracking. */
[[nodiscard]] eve::Result<void> clearPhysics(eve::physics::World& physicsWorld);
/** @brief Last authoritative revision fully represented by this cache. */
[[nodiscard]] std::uint64_t sourceRevision() const noexcept;
/** @brief Number of chunks currently owning a non-empty static collision body. */
[[nodiscard]] std::size_t bodyCount() const noexcept;
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};
} // namespace eve::pixelworld_physics
@@ -0,0 +1,387 @@
#include "pixelworld_replay/PixelWorldReplay.h"
#include <algorithm>
#include <charconv>
#include <cstddef>
#include <limits>
#include <set>
#include <type_traits>
namespace eve::pixelworld_replay {
namespace {
constexpr std::string_view kReplayType = "pixelworld.replay-log";
constexpr std::uint64_t kReplaySchemaVersion = 2;
constexpr std::size_t kMaximumReplayEntries = 1'000'000;
constexpr std::size_t kMaximumReplayCheckpoints = 100'000;
constexpr std::size_t kMaximumReplayChunkDigests = 1'000'000;
template <class T>
eve::Result<T> fail(eve::DiagnosticCode code, std::string message, std::string path = {}) {
return eve::Result<T>::failure(eve::Diagnostic::error(code, std::move(message), std::move(path), {},
"pixelworld.replay"));
}
eve::LogicalId replaySchema() {
const auto parsed = eve::LogicalId::parse("pixelworld:replay-log");
if (!parsed) std::terminate();
return *parsed;
}
eve::Result<const eve::Value::Object*> object(const eve::Value& value, std::string path,
std::initializer_list<std::string_view> fields) {
const auto* result = value.getIf<eve::Value::Object>();
if (!result || result->size() != fields.size())
return fail<const eve::Value::Object*>(eve::DiagnosticCode::ParseError,
"replay object has unknown or missing fields", std::move(path));
for (const auto field : fields)
if (!result->contains(std::string(field)))
return fail<const eve::Value::Object*>(eve::DiagnosticCode::ParseError,
"replay object has unknown or missing fields", std::move(path));
return eve::Result<const eve::Value::Object*>::success(result);
}
eve::Result<std::uint64_t> decimal(const eve::Value& value, std::string path) {
const auto* text = value.getIf<std::string>();
if (!text) return fail<std::uint64_t>(eve::DiagnosticCode::ParseError, "expected decimal string", path);
std::uint64_t result = 0;
const auto [end, error] = std::from_chars(text->data(), text->data() + text->size(), result);
if (error != std::errc{} || end != text->data() + text->size())
return fail<std::uint64_t>(eve::DiagnosticCode::ParseError, "invalid decimal string", std::move(path));
return eve::Result<std::uint64_t>::success(result);
}
eve::Result<int> integer(const eve::Value& value, std::string path) {
const auto* number = value.getIf<std::int64_t>();
if (!number || *number < std::numeric_limits<int>::min() || *number > std::numeric_limits<int>::max())
return fail<int>(eve::DiagnosticCode::ParseError, "expected in-range integer", std::move(path));
return eve::Result<int>::success(static_cast<int>(*number));
}
eve::Value commandValue(const eve::pixelworld::PixelEditCommand& command) {
return eve::Value(eve::Value::Object{
{"centerX", eve::Value(command.centerX)},
{"centerY", eve::Value(command.centerY)},
{"kind", eve::Value(static_cast<int>(command.kind))},
{"material", eve::Value(static_cast<int>(command.material))},
{"radius", eve::Value(command.radius)},
{"sequence", eve::Value(std::to_string(command.sequence))},
{"strength", eve::Value(command.strength)},
{"temperatureDelta", eve::Value(int(command.temperatureDelta))},
});
}
eve::Result<eve::pixelworld::PixelEditCommand> parseCommand(const eve::Value& value, const std::string& path) {
auto record = object(value, path, {"centerX", "centerY", "kind", "material", "radius", "sequence",
"strength", "temperatureDelta"});
if (!record) return eve::Result<eve::pixelworld::PixelEditCommand>::failure(record.status());
eve::pixelworld::PixelEditCommand command;
auto centerX = integer(record.value()->at("centerX"), path + ".centerX");
auto centerY = integer(record.value()->at("centerY"), path + ".centerY");
auto kind = integer(record.value()->at("kind"), path + ".kind");
auto material = integer(record.value()->at("material"), path + ".material");
auto radius = integer(record.value()->at("radius"), path + ".radius");
auto sequence = decimal(record.value()->at("sequence"), path + ".sequence");
auto strength = integer(record.value()->at("strength"), path + ".strength");
auto temperature = integer(record.value()->at("temperatureDelta"), path + ".temperatureDelta");
if (!centerX || !centerY || !kind || !material || !radius || !sequence || !strength || !temperature)
return fail<eve::pixelworld::PixelEditCommand>(eve::DiagnosticCode::ParseError,
"invalid replay command", path);
if (kind.value() < 0 || kind.value() > static_cast<int>(eve::pixelworld::PixelEditKind::Explosion) ||
material.value() < 0 || material.value() > std::numeric_limits<std::uint16_t>::max() ||
temperature.value() < std::numeric_limits<std::int16_t>::min() ||
temperature.value() > std::numeric_limits<std::int16_t>::max())
return fail<eve::pixelworld::PixelEditCommand>(eve::DiagnosticCode::ParseError,
"replay command enum or scalar is out of range", path);
command.sequence = sequence.value();
command.kind = static_cast<eve::pixelworld::PixelEditKind>(kind.value());
command.centerX = centerX.value();
command.centerY = centerY.value();
command.radius = radius.value();
command.material = static_cast<eve::pixelworld::MaterialId>(material.value());
command.strength = strength.value();
command.temperatureDelta = static_cast<std::int16_t>(temperature.value());
return eve::Result<eve::pixelworld::PixelEditCommand>::success(command);
}
std::uint64_t hashBytes(std::span<const std::byte> bytes) noexcept {
std::uint64_t hash = 1469598103934665603ULL;
for (const std::byte value : bytes) {
hash ^= std::uint64_t(std::to_integer<std::uint8_t>(value));
hash *= 1099511628211ULL;
}
return hash;
}
template <class T>
void hashValue(std::uint64_t& hash, T value) noexcept {
static_assert(std::is_integral_v<T>);
using Unsigned = std::make_unsigned_t<T>;
static_assert(sizeof(Unsigned) <= sizeof(std::uint64_t));
std::uint64_t encoded = static_cast<std::uint64_t>(static_cast<Unsigned>(value));
for (std::size_t index = 0; index < sizeof(T); ++index) {
hash ^= encoded & 0xffULL;
hash *= 1099511628211ULL;
encoded >>= 8;
}
}
std::uint64_t hashChunk(const eve::pixelworld::PixelChunkSnapshot& chunk) noexcept {
std::uint64_t hash = 1469598103934665603ULL;
hashValue(hash, std::int32_t(chunk.x));
hashValue(hash, std::int32_t(chunk.y));
for (const auto cell : chunk.cells) {
hashValue(hash, std::uint16_t(cell.material));
hashValue(hash, cell.temperature);
hashValue(hash, cell.lifetime);
hashValue(hash, cell.thermalRemainder);
}
return hash;
}
eve::Result<PixelReplayCheckpoint> checkpointFor(const eve::pixelworld::PixelWorld& world) {
auto snapshot = world.saveSnapshot();
if (!snapshot.ok()) return eve::Result<PixelReplayCheckpoint>::failure(snapshot.status());
PixelReplayCheckpoint checkpoint;
checkpoint.tick = eve::SimulationTick(world.tickValue());
checkpoint.revision = world.revision();
checkpoint.worldDigest = hashBytes(snapshot.value());
for (const auto& chunk : world.snapshotChangedChunks(0))
if (!chunk.removed)
checkpoint.chunks.push_back({chunk.x, chunk.y, hashChunk(chunk)});
return eve::Result<PixelReplayCheckpoint>::success(std::move(checkpoint));
}
eve::Value replayPayload(const std::vector<PixelReplayEntry>& entries,
const std::vector<PixelReplayCheckpoint>& checkpoints) {
eve::Value::Array encodedEntries;
encodedEntries.reserve(entries.size());
for (const auto& entry : entries)
encodedEntries.emplace_back(eve::Value::Object{
{"command", commandValue(entry.command)},
{"tick", eve::Value(std::to_string(entry.tick.value()))},
});
eve::Value::Array encodedCheckpoints;
encodedCheckpoints.reserve(checkpoints.size());
for (const auto& checkpoint : checkpoints) {
eve::Value::Array chunks;
chunks.reserve(checkpoint.chunks.size());
for (const auto& chunk : checkpoint.chunks)
chunks.emplace_back(eve::Value::Object{
{"digest", eve::Value(std::to_string(chunk.digest))},
{"x", eve::Value(chunk.x)},
{"y", eve::Value(chunk.y)},
});
encodedCheckpoints.emplace_back(eve::Value::Object{
{"chunks", eve::Value(std::move(chunks))},
{"revision", eve::Value(std::to_string(checkpoint.revision))},
{"tick", eve::Value(std::to_string(checkpoint.tick.value()))},
{"worldDigest", eve::Value(std::to_string(checkpoint.worldDigest))},
});
}
return eve::Value(eve::Value::Object{
{"checkpoints", eve::Value(std::move(encodedCheckpoints))},
{"entries", eve::Value(std::move(encodedEntries))},
});
}
PixelReplayDivergence compare(const PixelReplayCheckpoint& expected,
const PixelReplayCheckpoint& actual) {
PixelReplayDivergence divergence;
divergence.tick = expected.tick;
divergence.expectedRevision = expected.revision;
divergence.actualRevision = actual.revision;
const std::size_t count = std::max(expected.chunks.size(), actual.chunks.size());
for (std::size_t index = 0; index < count; ++index) {
const bool expectedPresent = index < expected.chunks.size();
const bool actualPresent = index < actual.chunks.size();
if (expectedPresent && actualPresent && expected.chunks[index] == actual.chunks[index]) continue;
if (expectedPresent) divergence.expectedChunk = expected.chunks[index];
if (actualPresent) divergence.actualChunk = actual.chunks[index];
break;
}
return divergence;
}
} // namespace
eve::Result<void> PixelReplayLog::append(PixelReplayEntry entry) {
if (entry.tick.value() == 0)
return eve::Result<void>::failure(eve::Diagnostic::error(
eve::DiagnosticCode::InvalidArgument, "replay command Tick must be positive",
"tick", {}, "pixelworld.replay"));
if (!entries_.empty() && entry.tick.value() < entries_.back().tick.value())
return eve::Result<void>::failure(eve::Diagnostic::error(
eve::DiagnosticCode::PreconditionViolation, "replay command Ticks must be nondecreasing",
"tick", {}, "pixelworld.replay"));
const std::uint64_t expectedSequence = entries_.empty() ? 1 : entries_.back().command.sequence + 1;
if (entry.command.sequence != expectedSequence)
return eve::Result<void>::failure(eve::Diagnostic::error(
eve::DiagnosticCode::PreconditionViolation, "replay edit sequence must be contiguous",
"command.sequence", {}, "pixelworld.replay"));
entries_.push_back(std::move(entry));
return eve::Result<void>::success();
}
eve::Result<void> PixelReplayLog::captureCheckpoint(const eve::pixelworld::PixelWorld& world) {
if (!checkpoints_.empty() && world.tickValue() <= checkpoints_.back().tick.value())
return eve::Result<void>::failure(eve::Diagnostic::error(
eve::DiagnosticCode::PreconditionViolation, "checkpoint Tick must increase",
"tick", {}, "pixelworld.replay"));
auto checkpoint = checkpointFor(world);
if (!checkpoint.ok()) return eve::Result<void>::failure(checkpoint.status());
checkpoints_.push_back(std::move(checkpoint).value());
return eve::Result<void>::success();
}
eve::Result<PixelReplayReceipt> PixelReplayLog::replay(
eve::pixelworld::PixelWorld& target, eve::SimulationTick throughTick) const {
if (throughTick.value() == 0 || target.tickValue() != 0 || target.chunkCount() != 0 ||
target.lastEditSequence() != 0)
return eve::Result<PixelReplayReceipt>::failure(eve::Diagnostic::error(
eve::DiagnosticCode::PreconditionViolation,
"replay requires a positive target Tick and an empty target world",
"target", {}, "pixelworld.replay"));
if ((!entries_.empty() && entries_.back().tick.value() > throughTick.value()) ||
(!checkpoints_.empty() && checkpoints_.back().tick.value() > throughTick.value()))
return eve::Result<PixelReplayReceipt>::failure(eve::Diagnostic::error(
eve::DiagnosticCode::InvalidArgument, "throughTick does not cover the complete log",
"throughTick", {}, "pixelworld.replay"));
PixelReplayReceipt receipt;
std::size_t entryIndex = 0;
std::size_t checkpointIndex = 0;
for (std::uint64_t tick = 1; tick <= throughTick.value(); ++tick) {
while (entryIndex < entries_.size() && entries_[entryIndex].tick.value() == tick) {
auto applied = target.applyEdit(entries_[entryIndex].command);
if (!applied.ok()) return eve::Result<PixelReplayReceipt>::failure(applied.status());
++entryIndex;
++receipt.commandsApplied;
}
auto advanced = target.advance(eve::SimulationTick(tick));
if (!advanced.ok()) return eve::Result<PixelReplayReceipt>::failure(advanced.status());
receipt.reachedTick = eve::SimulationTick(tick);
if (checkpointIndex < checkpoints_.size() && checkpoints_[checkpointIndex].tick.value() == tick) {
auto actual = checkpointFor(target);
if (!actual.ok()) return eve::Result<PixelReplayReceipt>::failure(actual.status());
const auto& expected = checkpoints_[checkpointIndex];
if (actual.value().worldDigest != expected.worldDigest) {
receipt.match = PixelReplayMatch::Diverged;
receipt.firstDivergence = compare(expected, actual.value());
return eve::Result<PixelReplayReceipt>::success(std::move(receipt));
}
++checkpointIndex;
}
}
return eve::Result<PixelReplayReceipt>::success(std::move(receipt));
}
eve::Result<eve::SnapshotEnvelope> PixelReplayLog::snapshot(
eve::PersistentId instanceId, const eve::SnapshotHashProvider& hashProvider) const {
const std::uint64_t entryTick = entries_.empty() ? 0 : entries_.back().tick.value();
const std::uint64_t checkpointTick = checkpoints_.empty() ? 0 : checkpoints_.back().tick.value();
return eve::makeSnapshotEnvelope(
std::string(kReplayType), replaySchema(), eve::SchemaVersion(kReplaySchemaVersion), instanceId,
eve::Revision(entries_.size() + checkpoints_.size()),
eve::SimulationTick(std::max(entryTick, checkpointTick)), replayPayload(entries_, checkpoints_),
hashProvider);
}
eve::Result<void> PixelReplayLog::restoreSnapshot(
const eve::SnapshotEnvelope& snapshot, const eve::SnapshotHashProvider& hashProvider) {
auto verified = eve::verifySnapshotEnvelope(snapshot, hashProvider);
if (!verified) return verified;
if (snapshot.type != kReplayType || snapshot.schema != replaySchema())
return fail<void>(eve::DiagnosticCode::InvalidArgument, "replay snapshot type or schema does not match",
"schema");
if (snapshot.schemaVersion.value() < 1 || snapshot.schemaVersion.value() > kReplaySchemaVersion)
return fail<void>(eve::DiagnosticCode::UnknownVersion, "unsupported replay snapshot schema version",
"schemaVersion");
auto root = object(snapshot.payload, "payload", {"checkpoints", "entries"});
if (!root) return eve::Result<void>::failure(root.status());
const auto* encodedEntries = root.value()->at("entries").getIf<eve::Value::Array>();
const auto* encodedCheckpoints = root.value()->at("checkpoints").getIf<eve::Value::Array>();
if (!encodedEntries || !encodedCheckpoints || encodedEntries->size() > kMaximumReplayEntries ||
encodedCheckpoints->size() > kMaximumReplayCheckpoints)
return fail<void>(eve::DiagnosticCode::ParseError, "replay arrays are invalid or exceed decode budget",
"payload");
PixelReplayLog candidate;
for (std::size_t index = 0; index < encodedEntries->size(); ++index) {
const std::string path = "payload.entries[" + std::to_string(index) + "]";
auto record = object((*encodedEntries)[index], path, {"command", "tick"});
if (!record) return eve::Result<void>::failure(record.status());
auto tick = decimal(record.value()->at("tick"), path + ".tick");
auto command = parseCommand(record.value()->at("command"), path + ".command");
if (!tick || !command)
return fail<void>(eve::DiagnosticCode::ParseError, "invalid replay entry", path);
auto appended = candidate.append({eve::SimulationTick(tick.value()), std::move(command).value()});
if (!appended) return appended;
}
std::size_t totalChunks = 0;
std::uint64_t previousCheckpointTick = 0;
for (std::size_t index = 0; index < encodedCheckpoints->size(); ++index) {
const std::string path = "payload.checkpoints[" + std::to_string(index) + "]";
const bool versionOne = snapshot.schemaVersion.value() == 1;
auto record = versionOne
? object((*encodedCheckpoints)[index], path, {"revision", "tick", "worldDigest"})
: object((*encodedCheckpoints)[index], path,
{"chunks", "revision", "tick", "worldDigest"});
if (!record) return eve::Result<void>::failure(record.status());
auto tick = decimal(record.value()->at("tick"), path + ".tick");
auto revision = decimal(record.value()->at("revision"), path + ".revision");
auto worldDigest = decimal(record.value()->at("worldDigest"), path + ".worldDigest");
if (!tick || !revision || !worldDigest || tick.value() == 0 || tick.value() <= previousCheckpointTick)
return fail<void>(eve::DiagnosticCode::ParseError, "invalid replay checkpoint", path);
PixelReplayCheckpoint checkpoint;
checkpoint.tick = eve::SimulationTick(tick.value());
checkpoint.revision = revision.value();
checkpoint.worldDigest = worldDigest.value();
if (!versionOne) {
const auto* chunks = record.value()->at("chunks").getIf<eve::Value::Array>();
if (!chunks || chunks->size() > kMaximumReplayChunkDigests - totalChunks)
return fail<void>(eve::DiagnosticCode::ParseError,
"replay Chunk digests exceed decode budget", path + ".chunks");
totalChunks += chunks->size();
for (std::size_t chunkIndex = 0; chunkIndex < chunks->size(); ++chunkIndex) {
const std::string chunkPath = path + ".chunks[" + std::to_string(chunkIndex) + "]";
auto chunk = object((*chunks)[chunkIndex], chunkPath, {"digest", "x", "y"});
if (!chunk) return eve::Result<void>::failure(chunk.status());
auto x = integer(chunk.value()->at("x"), chunkPath + ".x");
auto y = integer(chunk.value()->at("y"), chunkPath + ".y");
auto digest = decimal(chunk.value()->at("digest"), chunkPath + ".digest");
if (!x || !y || !digest)
return fail<void>(eve::DiagnosticCode::ParseError, "invalid replay Chunk digest", chunkPath);
PixelChunkDigest decoded{x.value(), y.value(), digest.value()};
if (!checkpoint.chunks.empty()) {
const auto& prior = checkpoint.chunks.back();
if (decoded.y < prior.y || (decoded.y == prior.y && decoded.x <= prior.x))
return fail<void>(eve::DiagnosticCode::ParseError,
"replay Chunk digests must be unique canonical order", chunkPath);
}
checkpoint.chunks.push_back(decoded);
}
}
previousCheckpointTick = tick.value();
candidate.checkpoints_.push_back(std::move(checkpoint));
}
const std::uint64_t entryTick = candidate.entries_.empty() ? 0 : candidate.entries_.back().tick.value();
const std::uint64_t checkpointTick = candidate.checkpoints_.empty()
? 0
: candidate.checkpoints_.back().tick.value();
if (snapshot.revision.value() != candidate.entries_.size() + candidate.checkpoints_.size() ||
snapshot.tick.value() != std::max(entryTick, checkpointTick))
return fail<void>(eve::DiagnosticCode::Conflict,
"replay payload count or Tick disagrees with envelope metadata", "payload");
entries_ = std::move(candidate.entries_);
checkpoints_ = std::move(candidate.checkpoints_);
return eve::Result<void>::success();
}
} // namespace eve::pixelworld_replay
@@ -0,0 +1,113 @@
#pragma once
#include "common/Result.h"
#include "common/Snapshot.h"
#include "common/Time.h"
#include "pixelworld/PixelWorld.h"
#include <cstdint>
#include <optional>
#include <span>
#include <vector>
namespace eve::pixelworld_replay {
/** @brief One deterministic edit admitted immediately before its simulation Tick. */
struct PixelReplayEntry {
eve::SimulationTick tick = eve::SimulationTick::zero();
eve::pixelworld::PixelEditCommand command;
};
/** @brief Canonical content digest for one authoritative PixelWorld Chunk. */
struct PixelChunkDigest {
int x = 0;
int y = 0;
std::uint64_t digest = 0;
friend bool operator==(const PixelChunkDigest&, const PixelChunkDigest&) = default;
};
/** @brief Periodic replay checkpoint containing world and per-Chunk diagnostics. */
struct PixelReplayCheckpoint {
eve::SimulationTick tick = eve::SimulationTick::zero();
std::uint64_t revision = 0;
std::uint64_t worldDigest = 0;
std::vector<PixelChunkDigest> chunks;
};
/** @brief Replay verification outcome; divergence is data, not an operational failure. */
enum class PixelReplayMatch : std::uint8_t { Matched, Diverged };
/** @brief First canonical mismatch found while replaying a log. */
struct PixelReplayDivergence {
eve::SimulationTick tick = eve::SimulationTick::zero();
std::uint64_t expectedRevision = 0;
std::uint64_t actualRevision = 0;
std::optional<PixelChunkDigest> expectedChunk;
std::optional<PixelChunkDigest> actualChunk;
};
/** @brief Receipt from deterministic replay through an exact target Tick. */
struct PixelReplayReceipt {
PixelReplayMatch match = PixelReplayMatch::Matched;
eve::SimulationTick reachedTick = eve::SimulationTick::zero();
std::uint64_t commandsApplied = 0;
std::optional<PixelReplayDivergence> firstDivergence;
};
/**
* @brief Owning command log and periodic checkpoint projection for PixelWorld.
*
* The log is not another mutable world authority. It owns immutable replay inputs
* and digests only. APIs are simulation-thread affine, invoke no callbacks, and
* use bit-exact integer hashes over canonical PixelWorld bytes.
*/
class PixelReplayLog {
public:
/** @brief Append the next sequenced command; Tick order must be nondecreasing. */
[[nodiscard]] eve::Result<void> append(PixelReplayEntry entry);
/**
* @brief Capture a checkpoint from the synchronously borrowed world.
* @remarks The world is not retained or mutated. Checkpoint Tick must increase.
*/
[[nodiscard]] eve::Result<void> captureCheckpoint(const eve::pixelworld::PixelWorld& world);
/**
* @brief Replay into an empty target through `throughTick` and verify checkpoints.
* @return A matched receipt or the first Tick/Chunk divergence. Malformed log or
* non-empty target is a structured failure and leaves admission state unchanged.
* @remarks The target is mutated after validation succeeds; operational failure
* during replay may leave it at the last fully committed Tick.
*/
[[nodiscard]] eve::Result<PixelReplayReceipt> replay(
eve::pixelworld::PixelWorld& target, eve::SimulationTick throughTick) const;
/**
* @brief Seal this log in the shared versioned SnapshotEnvelope.
* @param instanceId Stable identity of the owning replay/session.
* @param hashProvider Injected content digest provider; no weak default is selected.
* @return Schema `pixelworld:replay-log` version 2 with canonical owning payload.
*/
[[nodiscard]] eve::Result<eve::SnapshotEnvelope> snapshot(
eve::PersistentId instanceId, const eve::SnapshotHashProvider& hashProvider) const;
/**
* @brief Verify, migrate and transactionally replace this log from an envelope.
* @param snapshot Externally owned envelope borrowed only for this call.
* @param hashProvider Digest provider used to verify the envelope before parsing.
* @remarks Versions 1 and 2 are accepted. Version 1 checkpoints migrate without
* per-Chunk diagnostics. Any failure leaves entries and checkpoints unchanged.
*/
[[nodiscard]] eve::Result<void> restoreSnapshot(
const eve::SnapshotEnvelope& snapshot, const eve::SnapshotHashProvider& hashProvider);
/** @brief Immutable owning-log views, valid until this log is mutated or destroyed. */
[[nodiscard]] std::span<const PixelReplayEntry> entries() const noexcept { return entries_; }
[[nodiscard]] std::span<const PixelReplayCheckpoint> checkpoints() const noexcept { return checkpoints_; }
private:
std::vector<PixelReplayEntry> entries_;
std::vector<PixelReplayCheckpoint> checkpoints_;
};
} // namespace eve::pixelworld_replay
@@ -0,0 +1,311 @@
#include "pixelworld_streaming/PixelWorldStreaming.h"
#include "asset/EvpackCompression.h"
#include <algorithm>
#include <bit>
#include <charconv>
#include <limits>
#include <string>
#include <type_traits>
namespace eve::pixelworld_streaming {
namespace {
constexpr std::string_view kArchiveType = "pixelworld.chunk-batch";
constexpr std::uint64_t kArchiveVersion = 3;
constexpr std::size_t kMaximumArchiveChunks = 65'536;
constexpr std::uint64_t kMaximumDecodedBytes = 512ULL * 1024ULL * 1024ULL;
constexpr std::size_t kLegacyDecodedChunkBytes =
std::size_t(eve::pixelworld::kPixelChunkSize * eve::pixelworld::kPixelChunkSize) * 5;
constexpr std::size_t kDecodedChunkBytes =
std::size_t(eve::pixelworld::kPixelChunkSize * eve::pixelworld::kPixelChunkSize) * 7;
template <class T>
eve::Result<T> fail(eve::DiagnosticCode code, std::string message, std::string path = {}) {
return eve::Result<T>::failure(eve::Diagnostic::error(
code, std::move(message), std::move(path), {}, "pixelworld.chunk-archive"));
}
eve::LogicalId archiveSchema() {
const auto parsed = eve::LogicalId::parse("pixelworld:chunk-batch");
if (!parsed) std::terminate();
return *parsed;
}
eve::asset::EvpackCodec assetCodec(PixelChunkArchiveCodec codec) {
return codec == PixelChunkArchiveCodec::Zstd ? eve::asset::EvpackCodec::Zstd
: eve::asset::EvpackCodec::None;
}
std::string hex(std::span<const std::uint8_t> bytes) {
static constexpr char digits[] = "0123456789abcdef";
std::string result(bytes.size() * 2, '0');
for (std::size_t index = 0; index < bytes.size(); ++index) {
result[index * 2] = digits[bytes[index] >> 4];
result[index * 2 + 1] = digits[bytes[index] & 0xf];
}
return result;
}
eve::Result<std::vector<std::uint8_t>> unhex(const eve::Value& value, std::size_t maximumBytes,
std::string path) {
const auto* text = value.getIf<std::string>();
if (!text || text->size() % 2 != 0 || text->size() / 2 > maximumBytes)
return fail<std::vector<std::uint8_t>>(eve::DiagnosticCode::ParseError,
"invalid or oversized hexadecimal payload", std::move(path));
const auto nibble = [](char value) -> int {
if (value >= '0' && value <= '9') return value - '0';
if (value >= 'a' && value <= 'f') return value - 'a' + 10;
return -1;
};
std::vector<std::uint8_t> result(text->size() / 2);
for (std::size_t index = 0; index < result.size(); ++index) {
const int high = nibble((*text)[index * 2]);
const int low = nibble((*text)[index * 2 + 1]);
if (high < 0 || low < 0)
return fail<std::vector<std::uint8_t>>(eve::DiagnosticCode::ParseError,
"invalid hexadecimal digit", std::move(path));
result[index] = std::uint8_t((high << 4) | low);
}
return eve::Result<std::vector<std::uint8_t>>::success(std::move(result));
}
template <class T>
void put(std::vector<std::uint8_t>& bytes, T value) {
using Unsigned = std::make_unsigned_t<T>;
std::uint64_t encoded = static_cast<std::uint64_t>(static_cast<Unsigned>(value));
for (std::size_t index = 0; index < sizeof(T); ++index) {
bytes.push_back(std::uint8_t(encoded & 0xff));
encoded >>= 8;
}
}
template <class T>
T take(std::span<const std::uint8_t> bytes, std::size_t& cursor) {
using Unsigned = std::make_unsigned_t<T>;
Unsigned encoded = 0;
for (std::size_t index = 0; index < sizeof(T); ++index)
encoded |= Unsigned(bytes[cursor++]) << (index * 8);
if constexpr (std::is_signed_v<T>) return std::bit_cast<T>(encoded);
return encoded;
}
std::vector<std::uint8_t> encodeCells(const eve::pixelworld::PixelChunkSnapshot& chunk) {
std::vector<std::uint8_t> result;
result.reserve(kDecodedChunkBytes);
for (const auto cell : chunk.cells) {
put(result, std::uint16_t(cell.material));
put(result, cell.temperature);
put(result, cell.lifetime);
put(result, cell.thermalRemainder);
}
return result;
}
eve::Result<const eve::Value::Object*> object(const eve::Value& value, std::string path,
std::initializer_list<std::string_view> fields) {
const auto* result = value.getIf<eve::Value::Object>();
if (!result || result->size() != fields.size())
return fail<const eve::Value::Object*>(eve::DiagnosticCode::ParseError,
"archive object has unknown or missing fields", std::move(path));
for (const auto field : fields)
if (!result->contains(std::string(field)))
return fail<const eve::Value::Object*>(eve::DiagnosticCode::ParseError,
"archive object has unknown or missing fields", std::move(path));
return eve::Result<const eve::Value::Object*>::success(result);
}
eve::Result<std::uint64_t> decimal(const eve::Value& value, std::string path) {
const auto* text = value.getIf<std::string>();
if (!text) return fail<std::uint64_t>(eve::DiagnosticCode::ParseError, "expected decimal string", path);
std::uint64_t result = 0;
const auto [end, error] = std::from_chars(text->data(), text->data() + text->size(), result);
if (error != std::errc{} || end != text->data() + text->size())
return fail<std::uint64_t>(eve::DiagnosticCode::ParseError, "invalid decimal string", std::move(path));
return eve::Result<std::uint64_t>::success(result);
}
eve::Result<int> integer(const eve::Value& value, std::string path) {
const auto* number = value.getIf<std::int64_t>();
if (!number || *number < std::numeric_limits<int>::min() || *number > std::numeric_limits<int>::max())
return fail<int>(eve::DiagnosticCode::ParseError, "expected in-range integer", std::move(path));
return eve::Result<int>::success(int(*number));
}
eve::Result<eve::ContentId> digest(const std::vector<std::uint8_t>& decoded,
const eve::SnapshotHashProvider& hashProvider) {
if (!hashProvider)
return fail<eve::ContentId>(eve::DiagnosticCode::Unsupported, "archive hash provider is required");
return hashProvider(hex(decoded));
}
} // namespace
eve::Result<eve::SnapshotEnvelope> archiveChunkBatch(
const eve::pixelworld::PixelChunkBatch& batch, eve::PersistentId instanceId,
PixelChunkArchiveCodec codec, const eve::SnapshotHashProvider& hashProvider) {
if (codec != PixelChunkArchiveCodec::None && codec != PixelChunkArchiveCodec::Zstd)
return fail<eve::SnapshotEnvelope>(eve::DiagnosticCode::Unsupported, "unknown Chunk archive codec");
if (batch.chunks.size() > kMaximumArchiveChunks)
return fail<eve::SnapshotEnvelope>(eve::DiagnosticCode::InvalidArgument,
"Chunk archive exceeds record budget", "chunks");
eve::Value::Array records;
std::optional<std::pair<int, int>> previous;
for (const auto& chunk : batch.chunks) {
const std::pair<int, int> key{chunk.y, chunk.x};
if (previous && !(previous.value() < key))
return fail<eve::SnapshotEnvelope>(eve::DiagnosticCode::InvalidArgument,
"Chunks must be unique canonical y/x order", "chunks");
previous = key;
if (chunk.revision == 0 || chunk.revision > batch.sourceRevision ||
(chunk.removed && !chunk.cells.empty()) ||
(!chunk.removed && chunk.cells.size() !=
std::size_t(eve::pixelworld::kPixelChunkSize * eve::pixelworld::kPixelChunkSize)))
return fail<eve::SnapshotEnvelope>(eve::DiagnosticCode::InvalidArgument,
"invalid Chunk revision or cell shape", "chunks");
const auto decoded = chunk.removed ? std::vector<std::uint8_t>{} : encodeCells(chunk);
auto contentHash = digest(decoded, hashProvider);
if (!contentHash) return eve::Result<eve::SnapshotEnvelope>::failure(contentHash.status());
const auto physical = chunk.removed ? PixelChunkArchiveCodec::None : codec;
auto stored = eve::asset::compressEvpackChunk(assetCodec(physical), decoded);
if (!stored) return eve::Result<eve::SnapshotEnvelope>::failure(stored.status());
records.emplace_back(eve::Value::Object{
{"codec", eve::Value(int(physical))},
{"contentHash", eve::Value(contentHash.value().format())},
{"decodedSize", eve::Value(std::to_string(decoded.size()))},
{"removed", eve::Value(chunk.removed)},
{"revision", eve::Value(std::to_string(chunk.revision))},
{"stored", eve::Value(hex(stored.value()))},
{"x", eve::Value(chunk.x)},
{"y", eve::Value(chunk.y)},
});
}
eve::Value payload(eve::Value::Object{
{"catalogFingerprint", eve::Value(std::to_string(batch.catalogFingerprint))},
{"chunks", eve::Value(std::move(records))},
{"fullResync", eve::Value(batch.fullResync)},
{"sourceLastEditSequence", eve::Value(std::to_string(batch.sourceLastEditSequence))},
{"sourceSeed", eve::Value(std::to_string(batch.sourceSeed))},
});
return eve::makeSnapshotEnvelope(std::string(kArchiveType), archiveSchema(),
eve::SchemaVersion(kArchiveVersion), instanceId,
eve::Revision(batch.sourceRevision), batch.sourceTick,
std::move(payload), hashProvider);
}
eve::Result<eve::pixelworld::PixelChunkBatch> decodeChunkBatchArchive(
const eve::SnapshotEnvelope& snapshot, const eve::SnapshotHashProvider& hashProvider) {
auto verified = eve::verifySnapshotEnvelope(snapshot, hashProvider);
if (!verified) return eve::Result<eve::pixelworld::PixelChunkBatch>::failure(verified.status());
if (snapshot.type != kArchiveType || snapshot.schema != archiveSchema())
return fail<eve::pixelworld::PixelChunkBatch>(eve::DiagnosticCode::InvalidArgument,
"Chunk archive type or schema does not match", "schema");
if (snapshot.schemaVersion.value() < 1 || snapshot.schemaVersion.value() > kArchiveVersion)
return fail<eve::pixelworld::PixelChunkBatch>(eve::DiagnosticCode::UnknownVersion,
"unsupported Chunk archive version", "schemaVersion");
const bool versionOne = snapshot.schemaVersion.value() == 1;
const bool versionTwo = snapshot.schemaVersion.value() == 2;
auto root = versionOne
? object(snapshot.payload, "payload", {"catalogFingerprint", "chunks", "sourceSeed"})
: versionTwo
? object(snapshot.payload, "payload", {"catalogFingerprint", "chunks",
"sourceLastEditSequence", "sourceSeed"})
: object(snapshot.payload, "payload", {"catalogFingerprint", "chunks",
"fullResync", "sourceLastEditSequence",
"sourceSeed"});
if (!root) return eve::Result<eve::pixelworld::PixelChunkBatch>::failure(root.status());
auto catalog = decimal(root.value()->at("catalogFingerprint"), "payload.catalogFingerprint");
auto seed = decimal(root.value()->at("sourceSeed"), "payload.sourceSeed");
auto sequence = versionOne
? eve::Result<std::uint64_t>::success(0)
: decimal(root.value()->at("sourceLastEditSequence"),
"payload.sourceLastEditSequence");
const auto* records = root.value()->at("chunks").getIf<eve::Value::Array>();
if (!catalog || !seed || !sequence || !records || records->size() > kMaximumArchiveChunks)
return fail<eve::pixelworld::PixelChunkBatch>(eve::DiagnosticCode::ParseError,
"invalid Chunk archive payload", "payload");
eve::pixelworld::PixelChunkBatch batch;
batch.catalogFingerprint = catalog.value();
batch.sourceSeed = seed.value();
batch.sourceRevision = snapshot.revision.value();
batch.sourceTick = snapshot.tick;
batch.sourceLastEditSequence = sequence.value();
if (!versionOne && !versionTwo) {
const auto* fullResync = root.value()->at("fullResync").getIf<bool>();
if (!fullResync)
return fail<eve::pixelworld::PixelChunkBatch>(eve::DiagnosticCode::ParseError,
"invalid fullResync flag", "payload.fullResync");
batch.fullResync = *fullResync;
}
std::optional<std::pair<int, int>> previous;
std::uint64_t decodedTotal = 0;
for (std::size_t index = 0; index < records->size(); ++index) {
const std::string path = "payload.chunks[" + std::to_string(index) + "]";
auto record = object((*records)[index], path,
{"codec", "contentHash", "decodedSize", "removed", "revision",
"stored", "x", "y"});
if (!record) return eve::Result<eve::pixelworld::PixelChunkBatch>::failure(record.status());
auto x = integer(record.value()->at("x"), path + ".x");
auto y = integer(record.value()->at("y"), path + ".y");
auto revision = decimal(record.value()->at("revision"), path + ".revision");
auto decodedSize = decimal(record.value()->at("decodedSize"), path + ".decodedSize");
const auto* removed = record.value()->at("removed").getIf<bool>();
auto codecValue = integer(record.value()->at("codec"), path + ".codec");
const auto* hashText = record.value()->at("contentHash").getIf<std::string>();
if (!x || !y || !revision || !decodedSize || !removed || !codecValue || !hashText ||
revision.value() == 0 || revision.value() > batch.sourceRevision ||
codecValue.value() < 0 || codecValue.value() > int(PixelChunkArchiveCodec::Zstd))
return fail<eve::pixelworld::PixelChunkBatch>(eve::DiagnosticCode::ParseError,
"invalid Chunk archive record", path);
const std::pair<int, int> key{y.value(), x.value()};
if (previous && !(previous.value() < key))
return fail<eve::pixelworld::PixelChunkBatch>(eve::DiagnosticCode::ParseError,
"Chunk records are not canonical", path);
previous = key;
const bool currentCells = decodedSize.value() == kDecodedChunkBytes;
const bool legacyCells = decodedSize.value() == kLegacyDecodedChunkBytes;
const std::uint64_t expectedSize = *removed ? 0 : decodedSize.value();
if ((!*removed && !currentCells && !legacyCells) || (*removed && decodedSize.value() != 0) ||
decodedTotal > kMaximumDecodedBytes - expectedSize)
return fail<eve::pixelworld::PixelChunkBatch>(eve::DiagnosticCode::ParseError,
"Chunk decoded size exceeds budget", path);
decodedTotal += expectedSize;
auto stored = unhex(record.value()->at("stored"), kDecodedChunkBytes + 1024, path + ".stored");
if (!stored) return eve::Result<eve::pixelworld::PixelChunkBatch>::failure(stored.status());
const auto codec = static_cast<PixelChunkArchiveCodec>(codecValue.value());
if (*removed && (codec != PixelChunkArchiveCodec::None || !stored.value().empty()))
return fail<eve::pixelworld::PixelChunkBatch>(eve::DiagnosticCode::ParseError,
"removed Chunk must have empty uncompressed payload", path);
auto decoded = eve::asset::decompressEvpackChunk(assetCodec(codec), stored.value(),
expectedSize, kMaximumDecodedBytes);
if (!decoded) return eve::Result<eve::pixelworld::PixelChunkBatch>::failure(decoded.status());
const auto expectedHash = eve::ContentId::parse(*hashText);
auto actualHash = digest(decoded.value(), hashProvider);
if (!expectedHash || !actualHash || actualHash.value() != *expectedHash)
return fail<eve::pixelworld::PixelChunkBatch>(eve::DiagnosticCode::HashMismatch,
"decoded Chunk content hash does not match", path);
eve::pixelworld::PixelChunkSnapshot chunk;
chunk.x = x.value();
chunk.y = y.value();
chunk.revision = revision.value();
chunk.removed = *removed;
if (!*removed) {
chunk.cells.resize(eve::pixelworld::kPixelChunkSize * eve::pixelworld::kPixelChunkSize);
std::size_t cursor = 0;
for (auto& cell : chunk.cells) {
cell.material = eve::pixelworld::MaterialId(take<std::uint16_t>(decoded.value(), cursor));
cell.temperature = take<std::int16_t>(decoded.value(), cursor);
cell.lifetime = take<std::uint8_t>(decoded.value(), cursor);
if (currentCells)
cell.thermalRemainder = take<std::uint16_t>(decoded.value(), cursor);
}
}
batch.chunks.push_back(std::move(chunk));
}
return eve::Result<eve::pixelworld::PixelChunkBatch>::success(std::move(batch));
}
} // namespace eve::pixelworld_streaming
@@ -0,0 +1,83 @@
#include "pixelworld_streaming/PixelWorldStreaming.h"
#include <algorithm>
#include <limits>
#include <vector>
namespace eve::pixelworld_streaming {
namespace {
using Coord = std::pair<int, int>;
Coord coord(const eve::pixelworld::PixelChunkSnapshot& chunk) { return {chunk.y, chunk.x}; }
bool contains(eve::pixelworld::PixelChunkRegion region, Coord value) noexcept {
const auto [y, x] = value;
return x >= region.minX && x <= region.maxX && y >= region.minY && y <= region.maxY;
}
} // namespace
eve::Result<PixelChunkStreamUpdate> PixelChunkStreamCursor::capture(
const eve::pixelworld::PixelWorld& source, eve::pixelworld::PixelChunkRegion interest) {
auto all = source.snapshotChunksInRegion(interest, 0);
if (!all) return eve::Result<PixelChunkStreamUpdate>::failure(all.status());
const auto link = source.worldLink();
const bool fullResync = source_.world == 0 || source_ != link;
std::set<Coord> nextKnown;
std::set<Coord> emitted;
std::vector<eve::pixelworld::PixelChunkSnapshot> output;
output.reserve(all.value().size() + known_.size());
PixelChunkStreamUpdate update;
update.fullResync = fullResync;
for (const auto& chunk : all.value()) {
const Coord key = coord(chunk);
if (chunk.removed) {
if (fullResync || known_.contains(key)) {
output.push_back(chunk);
emitted.insert(key);
}
continue;
}
nextKnown.insert(key);
if (fullResync || !known_.contains(key) || chunk.revision > revision_) {
output.push_back(chunk);
emitted.insert(key);
if (!known_.contains(key)) ++update.chunksEntered;
}
}
for (const Coord key : known_) {
if (nextKnown.contains(key) || emitted.contains(key)) continue;
++update.chunksEvicted;
if (fullResync || contains(interest, key)) continue;
const auto [y, x] = key;
output.push_back({x, y, source.revision(), true, {}});
}
std::sort(output.begin(), output.end(), [](const auto& left, const auto& right) {
return coord(left) < coord(right);
});
update.batch.catalogFingerprint = source.materialCatalogFingerprint();
update.batch.sourceSeed = source.seed();
update.batch.sourceRevision = source.revision();
update.batch.sourceTick = eve::SimulationTick(source.tickValue());
update.batch.sourceLastEditSequence = source.lastEditSequence();
update.batch.fullResync = fullResync;
update.batch.chunks = std::move(output);
source_ = link;
revision_ = source.revision();
known_ = std::move(nextKnown);
return eve::Result<PixelChunkStreamUpdate>::success(std::move(update));
}
void PixelChunkStreamCursor::reset() noexcept {
source_ = {};
revision_ = 0;
known_.clear();
}
} // namespace eve::pixelworld_streaming
@@ -0,0 +1,79 @@
#pragma once
#include "common/Result.h"
#include "common/Snapshot.h"
#include "pixelworld/PixelWorld.h"
#include <cstdint>
#include <set>
#include <utility>
namespace eve::pixelworld_streaming {
/** @brief Stable physical codec for one independently stored PixelWorld Chunk. */
enum class PixelChunkArchiveCodec : std::uint8_t { None = 0, Zstd = 1 };
/**
* @brief Encode one authoritative batch as `pixelworld:chunk-batch` SnapshotEnvelope version 3.
* @param batch Canonical owning batch to persist.
* @param instanceId Stable world/save identity.
* @param codec Per-present-Chunk physical compression codec.
* @param hashProvider Injected digest used by the envelope and every decoded Chunk.
* @return Integrity-sealed archive with independently compressed Chunk records.
*/
[[nodiscard]] eve::Result<eve::SnapshotEnvelope> archiveChunkBatch(
const eve::pixelworld::PixelChunkBatch& batch, eve::PersistentId instanceId,
PixelChunkArchiveCodec codec, const eve::SnapshotHashProvider& hashProvider);
/**
* @brief Verify and decode a version 1, 2 or 3 Chunk-batch archive into an owning candidate.
* @remarks Decoding is bounded and has no world side effects. Version 1 migrates with
* `sourceLastEditSequence == 0`; versions 1 and 2 migrate with `fullResync == false`.
* Bad envelope/Chunk hashes fail before any authority write.
*/
[[nodiscard]] eve::Result<eve::pixelworld::PixelChunkBatch> decodeChunkBatchArchive(
const eve::SnapshotEnvelope& snapshot, const eve::SnapshotHashProvider& hashProvider);
/** @brief One interest-stream update suitable for persistence or network transport. */
struct PixelChunkStreamUpdate {
eve::pixelworld::PixelChunkBatch batch;
/** @brief Mirrors `batch.fullResync` for capture diagnostics. */
bool fullResync = false;
std::uint32_t chunksEntered = 0;
std::uint32_t chunksEvicted = 0;
};
/**
* @brief Stateful revision cursor that projects one PixelWorld interest region.
*
* The cursor owns only consumer projection state: source link, last revision and
* known Chunk coordinates. PixelWorld remains the sole material authority. APIs
* are simulation-thread affine, retain no world pointer and invoke no callbacks.
*/
class PixelChunkStreamCursor {
public:
/**
* @brief Capture changed/entered Chunks and eviction tombstones for an interest region.
* @param source World borrowed synchronously and never retained.
* @param interest Inclusive Chunk-coordinate rectangle, bounded by PixelWorld.
* @return Owning canonical correction batch. Source epoch changes force full resync.
* @remarks Failure leaves cursor revision and known coordinates unchanged.
*/
[[nodiscard]] eve::Result<PixelChunkStreamUpdate> capture(
const eve::pixelworld::PixelWorld& source, eve::pixelworld::PixelChunkRegion interest);
/** @brief Forget consumer projection state so the next capture is a full resync. */
void reset() noexcept;
/** @brief Last successfully captured source revision, or zero before first capture. */
[[nodiscard]] std::uint64_t revision() const noexcept { return revision_; }
/** @brief Number of present Chunk coordinates believed to be held by the consumer. */
[[nodiscard]] std::size_t knownChunkCount() const noexcept { return known_.size(); }
private:
eve::pixelworld::PixelWorldLink source_{};
std::uint64_t revision_ = 0;
std::set<std::pair<int, int>> known_;
};
} // namespace eve::pixelworld_streaming
@@ -0,0 +1,257 @@
#include "pixelworld_streaming/PixelWorldTransport.h"
#include <algorithm>
#include <limits>
#include <string>
namespace eve::pixelworld_streaming {
namespace {
template <class T>
eve::Result<T> fail(eve::DiagnosticCode code, std::string message, std::string path) {
return eve::Result<T>::failure(eve::Diagnostic::error(
code, std::move(message), std::move(path), {}, "pixelworld.reliable-transport"));
}
bool valid(PixelChunkTransportConfig config) {
return config.maximumInFlightTransfers > 0 && config.maximumInFlightTransfers <= 1024 &&
config.maximumChunksPerPart > 0 && config.maximumChunksPerPart <= 1024 &&
config.maximumPartsPerTransfer > 0 && config.maximumPartsPerTransfer <= 65'536 &&
config.maximumBufferedTransfers > 0 && config.maximumBufferedTransfers <= 1024;
}
bool sameMetadata(const PixelChunkTransferPart& left, const PixelChunkTransferPart& right) {
return left.streamId == right.streamId && left.transferId == right.transferId &&
left.partCount == right.partCount && left.interest == right.interest &&
left.batch.catalogFingerprint == right.batch.catalogFingerprint &&
left.batch.sourceSeed == right.batch.sourceSeed &&
left.batch.sourceRevision == right.batch.sourceRevision &&
left.batch.sourceTick == right.batch.sourceTick &&
left.batch.sourceLastEditSequence == right.batch.sourceLastEditSequence &&
left.batch.fullResync == right.batch.fullResync;
}
} // namespace
ReliablePixelChunkSender::ReliablePixelChunkSender(std::uint64_t streamId,
PixelChunkTransportConfig config)
: streamId_(streamId), config_(config) {}
eve::Result<ReliablePixelChunkSender> ReliablePixelChunkSender::create(
std::uint64_t streamId, PixelChunkTransportConfig config) {
if (streamId == 0 || !valid(config))
return fail<ReliablePixelChunkSender>(eve::DiagnosticCode::InvalidArgument,
"stream id or transport policy is invalid", "config");
return eve::Result<ReliablePixelChunkSender>::success(
ReliablePixelChunkSender(streamId, config));
}
eve::Result<std::vector<PixelChunkTransferPart>> ReliablePixelChunkSender::capture(
const eve::pixelworld::PixelWorld& source, eve::pixelworld::PixelChunkRegion interest) {
if (inFlight_.size() >= config_.maximumInFlightTransfers)
return fail<std::vector<PixelChunkTransferPart>>(
eve::DiagnosticCode::PreconditionViolation, "reliable send window is full",
"maximumInFlightTransfers");
if (nextTransferId_ == std::numeric_limits<std::uint64_t>::max())
return fail<std::vector<PixelChunkTransferPart>>(
eve::DiagnosticCode::PreconditionViolation, "transfer id space is exhausted",
"transferId");
auto candidateCursor = cursor_;
auto captured = candidateCursor.capture(source, interest);
if (!captured.ok())
return eve::Result<std::vector<PixelChunkTransferPart>>::failure(captured.status());
auto update = std::move(captured).takeValue();
if (captured_ && !update.fullResync && update.batch.chunks.empty() &&
update.batch.sourceRevision == lastEnqueuedRevision_) {
cursor_ = std::move(candidateCursor);
return eve::Result<std::vector<PixelChunkTransferPart>>::success({});
}
const std::size_t chunkCount = update.batch.chunks.size();
const std::size_t partCount = std::max<std::size_t>(
1, (chunkCount + config_.maximumChunksPerPart - 1) / config_.maximumChunksPerPart);
if (partCount > config_.maximumPartsPerTransfer)
return fail<std::vector<PixelChunkTransferPart>>(
eve::DiagnosticCode::PreconditionViolation, "captured update exceeds part budget",
"maximumPartsPerTransfer");
std::vector<PixelChunkTransferPart> parts;
parts.reserve(partCount);
for (std::size_t index = 0; index < partCount; ++index) {
PixelChunkTransferPart part;
part.streamId = streamId_;
part.transferId = nextTransferId_;
part.partIndex = std::uint32_t(index);
part.partCount = std::uint32_t(partCount);
part.interest = interest;
part.batch.catalogFingerprint = update.batch.catalogFingerprint;
part.batch.sourceSeed = update.batch.sourceSeed;
part.batch.sourceRevision = update.batch.sourceRevision;
part.batch.sourceTick = update.batch.sourceTick;
part.batch.sourceLastEditSequence = update.batch.sourceLastEditSequence;
part.batch.fullResync = update.batch.fullResync;
const std::size_t begin = index * config_.maximumChunksPerPart;
const std::size_t end = std::min(chunkCount, begin + config_.maximumChunksPerPart);
if (begin < end)
part.batch.chunks.assign(update.batch.chunks.begin() + std::ptrdiff_t(begin),
update.batch.chunks.begin() + std::ptrdiff_t(end));
parts.push_back(std::move(part));
}
inFlight_.emplace(nextTransferId_, parts);
cursor_ = std::move(candidateCursor);
++nextTransferId_;
captured_ = true;
lastEnqueuedRevision_ = update.batch.sourceRevision;
return eve::Result<std::vector<PixelChunkTransferPart>>::success(std::move(parts));
}
std::vector<PixelChunkTransferPart> ReliablePixelChunkSender::pendingParts() const {
std::vector<PixelChunkTransferPart> result;
for (const auto& [transfer, parts] : inFlight_) {
(void)transfer;
result.insert(result.end(), parts.begin(), parts.end());
}
return result;
}
eve::Result<PixelChunkAckReceipt> ReliablePixelChunkSender::acknowledge(
PixelChunkTransferAck ack) {
if (ack.streamId != streamId_)
return fail<PixelChunkAckReceipt>(eve::DiagnosticCode::Conflict,
"ACK belongs to another stream", "streamId");
if (ack.acknowledgedThrough <= acknowledgedThrough_)
return eve::Result<PixelChunkAckReceipt>::success(
{0, 0, acknowledgedThrough_});
if (ack.acknowledgedThrough >= nextTransferId_)
return fail<PixelChunkAckReceipt>(eve::DiagnosticCode::Conflict,
"ACK advances beyond the sent window",
"acknowledgedThrough");
const auto acknowledged = inFlight_.find(ack.acknowledgedThrough);
if (acknowledged == inFlight_.end() || acknowledged->second.empty() ||
acknowledged->second.front().batch.sourceRevision != ack.appliedRevision)
return fail<PixelChunkAckReceipt>(eve::DiagnosticCode::Conflict,
"ACK revision does not match the transfer",
"appliedRevision");
PixelChunkAckReceipt receipt;
for (auto iterator = inFlight_.begin(); iterator != inFlight_.end() &&
iterator->first <= ack.acknowledgedThrough;) {
for (const auto& part : iterator->second)
receipt.tombstonesReleased += std::uint32_t(std::count_if(
part.batch.chunks.begin(), part.batch.chunks.end(),
[](const auto& chunk) { return chunk.removed; }));
iterator = inFlight_.erase(iterator);
++receipt.transfersReleased;
}
acknowledgedThrough_ = ack.acknowledgedThrough;
receipt.acknowledgedThrough = acknowledgedThrough_;
return eve::Result<PixelChunkAckReceipt>::success(receipt);
}
std::size_t ReliablePixelChunkSender::inFlightTransferCount() const noexcept {
return inFlight_.size();
}
std::size_t ReliablePixelChunkSender::retainedTombstoneCount() const noexcept {
std::size_t count = 0;
for (const auto& [transfer, parts] : inFlight_) {
(void)transfer;
for (const auto& part : parts)
count += std::count_if(part.batch.chunks.begin(), part.batch.chunks.end(),
[](const auto& chunk) { return chunk.removed; });
}
return count;
}
ReliablePixelChunkReceiver::ReliablePixelChunkReceiver(std::uint64_t streamId,
PixelChunkTransportConfig config)
: streamId_(streamId), config_(config) {}
eve::Result<ReliablePixelChunkReceiver> ReliablePixelChunkReceiver::create(
std::uint64_t streamId, PixelChunkTransportConfig config) {
if (streamId == 0 || !valid(config))
return fail<ReliablePixelChunkReceiver>(eve::DiagnosticCode::InvalidArgument,
"stream id or transport policy is invalid", "config");
return eve::Result<ReliablePixelChunkReceiver>::success(
ReliablePixelChunkReceiver(streamId, config));
}
eve::Result<PixelChunkReceiveReceipt> ReliablePixelChunkReceiver::receive(
PixelChunkTransferPart part, eve::pixelworld::PixelWorld& replica) {
if (part.streamId != streamId_ || part.transferId == 0 || part.partCount == 0 ||
part.partCount > config_.maximumPartsPerTransfer || part.partIndex >= part.partCount ||
part.batch.chunks.size() > config_.maximumChunksPerPart)
return fail<PixelChunkReceiveReceipt>(eve::DiagnosticCode::InvalidArgument,
"transfer part metadata exceeds policy", "part");
PixelChunkReceiveReceipt receipt;
if (part.transferId < expectedTransferId_) {
receipt.duplicate = true;
receipt.ack = {streamId_, expectedTransferId_ - 1, replica.revision()};
receipt.partsBuffered = std::uint32_t(bufferedPartCount());
return eve::Result<PixelChunkReceiveReceipt>::success(receipt);
}
auto found = buffered_.find(part.transferId);
if (found == buffered_.end()) {
if (buffered_.size() >= config_.maximumBufferedTransfers)
return fail<PixelChunkReceiveReceipt>(
eve::DiagnosticCode::PreconditionViolation,
"out-of-order receive window is full", "maximumBufferedTransfers");
Assembly assembly;
assembly.partCount = part.partCount;
assembly.parts.resize(part.partCount);
found = buffered_.emplace(part.transferId, std::move(assembly)).first;
}
Assembly& assembly = found->second;
if (assembly.partCount != part.partCount)
return fail<PixelChunkReceiveReceipt>(eve::DiagnosticCode::Conflict,
"transfer part count changed", "partCount");
auto& slot = assembly.parts[part.partIndex];
if (slot) {
if (*slot != part)
return fail<PixelChunkReceiveReceipt>(eve::DiagnosticCode::Conflict,
"duplicate part payload conflicts", "part");
receipt.duplicate = true;
} else {
slot = std::make_unique<PixelChunkTransferPart>(std::move(part));
}
while (true) {
auto next = buffered_.find(expectedTransferId_);
if (next == buffered_.end()) break;
const bool complete = std::all_of(next->second.parts.begin(), next->second.parts.end(),
[](const auto& value) { return bool(value); });
if (!complete) break;
const PixelChunkTransferPart& first = *next->second.parts.front();
eve::pixelworld::PixelChunkBatch combined = first.batch;
combined.chunks.clear();
for (const auto& value : next->second.parts) {
if (!sameMetadata(first, *value))
return fail<PixelChunkReceiveReceipt>(eve::DiagnosticCode::Conflict,
"transfer parts disagree on authority metadata",
"part.batch");
combined.chunks.insert(combined.chunks.end(), value->batch.chunks.begin(),
value->batch.chunks.end());
}
auto applied = replica.applyChunkBatch(combined, replica.revision());
if (!applied.ok()) {
buffered_.erase(next);
return eve::Result<PixelChunkReceiveReceipt>::failure(applied.status());
}
buffered_.erase(next);
++expectedTransferId_;
++receipt.transfersApplied;
}
receipt.ack = {streamId_, expectedTransferId_ - 1, replica.revision()};
receipt.partsBuffered = std::uint32_t(bufferedPartCount());
return eve::Result<PixelChunkReceiveReceipt>::success(receipt);
}
std::size_t ReliablePixelChunkReceiver::bufferedPartCount() const noexcept {
std::size_t count = 0;
for (const auto& [transfer, assembly] : buffered_) {
(void)transfer;
count += std::count_if(assembly.parts.begin(), assembly.parts.end(),
[](const auto& value) { return bool(value); });
}
return count;
}
} // namespace eve::pixelworld_streaming
@@ -0,0 +1,131 @@
#pragma once
#include "common/Result.h"
#include "pixelworld/PixelWorld.h"
#include "pixelworld_streaming/PixelWorldStreaming.h"
#include <cstdint>
#include <map>
#include <memory>
#include <vector>
namespace eve::pixelworld_streaming {
/** @brief Bounded application-level reliability policy layered over any byte transport. */
struct PixelChunkTransportConfig {
std::uint32_t maximumInFlightTransfers = 8;
std::uint32_t maximumChunksPerPart = 1;
std::uint32_t maximumPartsPerTransfer = 8192;
std::uint32_t maximumBufferedTransfers = 16;
};
/** @brief One independently retransmittable part of an atomic Chunk transfer. */
struct PixelChunkTransferPart {
std::uint64_t streamId = 0;
std::uint64_t transferId = 0;
std::uint32_t partIndex = 0;
std::uint32_t partCount = 0;
eve::pixelworld::PixelChunkRegion interest{};
eve::pixelworld::PixelChunkBatch batch;
friend bool operator==(const PixelChunkTransferPart&, const PixelChunkTransferPart&) = default;
};
/** @brief Cumulative application ACK proving transfers were committed to replica authority. */
struct PixelChunkTransferAck {
std::uint64_t streamId = 0;
std::uint64_t acknowledgedThrough = 0;
std::uint64_t appliedRevision = 0;
};
/** @brief Counters returned after accepting a cumulative ACK. */
struct PixelChunkAckReceipt {
std::uint32_t transfersReleased = 0;
std::uint32_t tombstonesReleased = 0;
std::uint64_t acknowledgedThrough = 0;
};
/** @brief Receiver outcome after one possibly duplicate or out-of-order part. */
struct PixelChunkReceiveReceipt {
PixelChunkTransferAck ack;
std::uint32_t transfersApplied = 0;
std::uint32_t partsBuffered = 0;
bool duplicate = false;
};
/**
* @brief Reliable sender retaining owning transfer parts and tombstones until application ACK.
*
* The sender is transport-neutral: callers may send `pendingParts()` through EVNetwork reliable
* messages, another socket, or deterministic tests. Cursor state advances only after a transfer
* fits the bounded in-flight window. Repeated `pendingParts()` calls are intentional retransmits.
*/
class ReliablePixelChunkSender {
public:
/** @brief Validate policy and create a fresh nonzero stream session. */
[[nodiscard]] static eve::Result<ReliablePixelChunkSender> create(
std::uint64_t streamId, PixelChunkTransportConfig config = {});
/** @brief Capture and enqueue one atomic revision transfer, split into bounded owning parts. */
[[nodiscard]] eve::Result<std::vector<PixelChunkTransferPart>> capture(
const eve::pixelworld::PixelWorld& source,
eve::pixelworld::PixelChunkRegion interest);
/** @brief Return every unacknowledged part in canonical transfer/part order for transmission. */
[[nodiscard]] std::vector<PixelChunkTransferPart> pendingParts() const;
/** @brief Release cumulatively acknowledged transfers and their retained tombstones. */
[[nodiscard]] eve::Result<PixelChunkAckReceipt> acknowledge(PixelChunkTransferAck ack);
/** @brief Number of unacknowledged atomic transfers. */
[[nodiscard]] std::size_t inFlightTransferCount() const noexcept;
/** @brief Number of tombstones retained specifically for possible retransmission. */
[[nodiscard]] std::size_t retainedTombstoneCount() const noexcept;
private:
ReliablePixelChunkSender(std::uint64_t streamId, PixelChunkTransportConfig config);
std::uint64_t streamId_ = 0;
std::uint64_t nextTransferId_ = 1;
std::uint64_t acknowledgedThrough_ = 0;
std::uint64_t lastEnqueuedRevision_ = 0;
bool captured_ = false;
PixelChunkTransportConfig config_{};
PixelChunkStreamCursor cursor_;
std::map<std::uint64_t, std::vector<PixelChunkTransferPart>> inFlight_;
};
/**
* @brief Ordered atomic receiver with duplicate suppression and bounded out-of-order buffering.
*
* Parts never mutate PixelWorld individually. A complete next transfer is canonicalized and
* committed through `applyChunkBatch`; only then does the cumulative ACK advance. Failure leaves
* both replica and expected transfer id unchanged.
*/
class ReliablePixelChunkReceiver {
public:
/** @brief Validate policy and create a receiver for one nonzero stream session. */
[[nodiscard]] static eve::Result<ReliablePixelChunkReceiver> create(
std::uint64_t streamId, PixelChunkTransportConfig config = {});
/** @brief Buffer one part and atomically apply every newly contiguous complete transfer. */
[[nodiscard]] eve::Result<PixelChunkReceiveReceipt> receive(
PixelChunkTransferPart part, eve::pixelworld::PixelWorld& replica);
/** @brief Last cumulatively applied transfer id. */
[[nodiscard]] std::uint64_t acknowledgedThrough() const noexcept { return expectedTransferId_ - 1; }
/** @brief Count parts retained while waiting for gaps or completion. */
[[nodiscard]] std::size_t bufferedPartCount() const noexcept;
private:
struct Assembly {
std::uint32_t partCount = 0;
std::vector<std::unique_ptr<PixelChunkTransferPart>> parts;
};
ReliablePixelChunkReceiver(std::uint64_t streamId, PixelChunkTransportConfig config);
std::uint64_t streamId_ = 0;
std::uint64_t expectedTransferId_ = 1;
PixelChunkTransportConfig config_{};
std::map<std::uint64_t, Assembly> buffered_;
};
} // namespace eve::pixelworld_streaming
@@ -0,0 +1,33 @@
#include "pixelworld_thread/PixelWorldThread.h"
#include "thread/JobSystem.h"
#include <algorithm>
#include <memory>
namespace eve::pixelworld_thread {
JobSystemPixelScheduler::JobSystemPixelScheduler(eve::thread::JobSystem& jobs) noexcept
: jobs_(&jobs) {}
void JobSystemPixelScheduler::parallelFor(
std::size_t workItems, const std::function<void(std::size_t)>& body) {
if (workItems == 0) return;
try {
std::unique_ptr<eve::thread::Job> job(jobs_->parallelFor(
0, int(workItems), [&body](int first, int last) {
for (int index = first; index < last; ++index) body(std::size_t(index));
}, 1));
job->wait();
if (!job->hasFailed()) return;
} catch (...) {
// The serial pass below restores every index-owned result slot.
}
for (std::size_t index = 0; index < workItems; ++index) body(index);
}
std::size_t JobSystemPixelScheduler::workerCount() const noexcept {
return std::size_t(std::max(0, jobs_->getWorkerCount()));
}
} // namespace eve::pixelworld_thread
@@ -0,0 +1,34 @@
#pragma once
#include "pixelworld/PixelWorld.h"
namespace eve::thread {
class JobSystem;
}
namespace eve::pixelworld_thread {
/**
* @brief Synchronous PixelWorld candidate scheduler backed by engine JobSystem.
*
* The adapter borrows JobSystem for its whole lifetime and never stops or owns it.
* `parallelFor` joins every submitted job before returning. Worker failure falls
* back to a deterministic serial overwrite of all index-owned result slots.
*/
class JobSystemPixelScheduler final : public eve::pixelworld::PixelWorkScheduler {
public:
/**
* @brief Borrow a running scheduler.
* @param jobs JobSystem that must outlive this adapter and all calls.
*/
explicit JobSystemPixelScheduler(eve::thread::JobSystem& jobs) noexcept;
void parallelFor(std::size_t workItems,
const std::function<void(std::size_t)>& body) override;
[[nodiscard]] std::size_t workerCount() const noexcept override;
private:
eve::thread::JobSystem* jobs_ = nullptr;
};
} // namespace eve::pixelworld_thread
+38 -2
View File
@@ -13,6 +13,8 @@
#include "graphics/TextureSampler.h"
#include "image/ImageData.h"
#include "image/Image.h"
#include "pixelworld/PixelWorld.h"
#include "pixelworld_graphics/PixelWorldGraphics.h"
#include <cstddef>
#include <cstdint>
@@ -113,6 +115,15 @@ TEST_CASE("graphics.backendParity.textureUpdateAndAlphaBlend") {
REQUIRE(gfx->updateTexture(texture, 1, 1, green));
REQUIRE(texture == stable);
REQUIRE(!gfx->updateTexture(texture, 2, 1, green));
REQUIRE(gfx->updateTextureRegion(texture, 0, 0, 1, 1, red).ok());
CHECK(!gfx->updateTextureRegion(texture, 1, 0, 1, 1, red).ok());
CHECK(!gfx->updateTextureRegion(texture, 0, 0, 1, 1,
std::span<const uint8_t>(red, 3)).ok());
const TextureRegionUpload rejectedBatch[] = {
{0, 0, 1, 1, green, 0},
{1, 0, 1, 1, green, 0},
};
CHECK(!gfx->updateTextureRegions(texture, rejectedBatch).ok());
Canvas *canvas = gfx->newCanvas(64, 64);
REQUIRE(canvas != nullptr);
@@ -131,8 +142,8 @@ TEST_CASE("graphics.backendParity.textureUpdateAndAlphaBlend") {
REQUIRE(background[2] > 247);
const uint8_t *updated = pixel(*image, 16, 16);
REQUIRE(updated[0] < 8);
REQUIRE(updated[1] > 247);
REQUIRE(updated[0] > 247);
REQUIRE(updated[1] < 8);
REQUIRE(updated[2] < 8);
const uint8_t *blended = pixel(*image, 40, 16);
@@ -144,6 +155,31 @@ TEST_CASE("graphics.backendParity.textureUpdateAndAlphaBlend") {
writeParityArtifact(*image, "texture_update_alpha_blend", backend);
}
TEST_CASE("graphics.backendParity.pixelWorldMillionPixelAtlas") {
Graphics *gfx = headlessGraphics();
REQUIRE(gfx != nullptr);
eve::pixelworld::PixelWorld world(4242);
// Radius 724 covers every cell in [0,1024)^2, creating a genuine one-million
// pixel viewport while also proving off-viewport chunks are clipped by the adapter.
REQUIRE(world.paintCircleChecked(512, 512, 724, "stone").ok());
eve::pixelworld_graphics::PixelWorldAtlasRenderer renderer(0, 0, 1024, 1024);
CHECK(renderer.sync(&world, gfx) == 256);
REQUIRE(renderer.texture() != nullptr);
CHECK(renderer.texture()->width == 1024);
CHECK(renderer.texture()->height == 1024);
CHECK(renderer.uploadCount() == 1);
CHECK(renderer.totalUploadedChunks() == 256);
CHECK(renderer.renderedRevision() == world.revision());
world.setMaterial(1, 1, "sand");
world.setMaterial(1022, 1022, "water");
CHECK(renderer.sync(&world, gfx) == 2);
CHECK(renderer.uploadCount() == 2);
CHECK(renderer.totalUploadedChunks() == 258);
CHECK(renderer.renderedRevision() == world.revision());
}
TEST_CASE("graphics.backendParity.draw2dUvRotationAndBlendModes") {
Graphics *gfx = headlessGraphics();
REQUIRE(gfx != nullptr);
+39
View File
@@ -12,6 +12,7 @@
#include "devtools/DevTool.hpp"
#include "devtools/McpServer.hpp"
#include "editor/Editor.h"
#include "pixelworld/PixelWorldModule.h"
#include "ui/EditorHost.h"
#include <Poco/Dynamic/Var.h>
@@ -276,6 +277,9 @@ TEST_CASE("devtools.mcp.initializeToolsStatus") {
dt.attach(vm, false);
dt.exposeScriptApi(vm);
eve::pixelworld::PixelWorldModule pixelworldModule;
eve::pixelworld::PixelWorld pixelworld(9081);
eve::editor::Editor editor;
eve::editor::CommandDescriptor editorCommand;
editorCommand.id = eve::editor::CommandId("mcp.test-command");
@@ -348,6 +352,11 @@ TEST_CASE("devtools.mcp.initializeToolsStatus") {
bool foundAgentStart = false;
bool foundAgentEvidence = false;
bool foundAgentComplete = false;
bool foundPixelWorlds = false;
bool foundPixelSamples = false;
bool foundPixelCatalog = false;
bool foundPixelValidate = false;
bool foundPixelApply = false;
for (size_t i = 0; i < tools->size(); ++i) {
auto t = tools->getObject(static_cast<unsigned>(i));
const std::string name = t->getValue<std::string>("name");
@@ -385,6 +394,11 @@ TEST_CASE("devtools.mcp.initializeToolsStatus") {
if (name == "eve_agent_session_start") foundAgentStart = true;
if (name == "eve_agent_session_evidence") foundAgentEvidence = true;
if (name == "eve_agent_session_complete") foundAgentComplete = true;
if (name == "eve_pixelworld_worlds") foundPixelWorlds = true;
if (name == "eve_pixelworld_samples") foundPixelSamples = true;
if (name == "eve_pixelworld_catalog_builtin") foundPixelCatalog = true;
if (name == "eve_pixelworld_catalog_validate") foundPixelValidate = true;
if (name == "eve_pixelworld_catalog_apply") foundPixelApply = true;
}
CHECK(foundStatus);
CHECK(foundEval);
@@ -420,6 +434,11 @@ TEST_CASE("devtools.mcp.initializeToolsStatus") {
CHECK(foundAgentStart);
CHECK(foundAgentEvidence);
CHECK(foundAgentComplete);
CHECK(foundPixelWorlds);
CHECK(foundPixelSamples);
CHECK(foundPixelCatalog);
CHECK(foundPixelValidate);
CHECK(foundPixelApply);
client.sendRequest(3, "tools/call", "{\"name\":\"eve_status\",\"arguments\":{}}");
auto statusMsg = client.expectResult(3);
@@ -509,6 +528,26 @@ TEST_CASE("devtools.mcp.initializeToolsStatus") {
const std::string sceneObserveAbsent = textOf(
21, "{\"name\":\"eve_editor_execute_observe\",\"arguments\":{\"target\":\"mcp.test\",\"command\":\"mcp.test-command\",\"payload\":{},\"observer\":\"scene-node\",\"host\":\"world\",\"node\":\"player\"}}");
CHECK(sceneObserveAbsent.find("scene module not available") != std::string::npos);
const std::string pixelWorlds = textOf(22, "{\"name\":\"eve_pixelworld_worlds\",\"arguments\":{}}");
CHECK(pixelWorlds.find("\"seed\":9081") != std::string::npos);
const std::string worldId = std::to_string(pixelworld.worldLink().world);
const std::string pixelPaused = textOf(
23, "{\"name\":\"eve_pixelworld_pause\",\"arguments\":{\"world\":" + worldId +
",\"paused\":true}}");
CHECK(pixelPaused.find("\"paused\":true") != std::string::npos);
const std::string pixelStepped = textOf(
24, "{\"name\":\"eve_pixelworld_step\",\"arguments\":{\"world\":" + worldId +
",\"count\":2}}");
CHECK(pixelStepped.find("\"lastTick\":2") != std::string::npos);
const std::string pixelSamples = textOf(
25, "{\"name\":\"eve_pixelworld_samples\",\"arguments\":{\"world\":" + worldId + "}}");
CHECK(pixelSamples.find("\"tick\":2") != std::string::npos);
const std::string pixelCatalog =
textOf(26, "{\"name\":\"eve_pixelworld_catalog_builtin\",\"arguments\":{}}");
CHECK(pixelCatalog.find("eve.pixelworld.material-catalog") != std::string::npos);
const std::string invalidCatalog = textOf(
27, "{\"name\":\"eve_pixelworld_catalog_validate\",\"arguments\":{\"catalog\":{}}}");
CHECK(invalidCatalog.find("\"ok\":false") != std::string::npos);
mcp.stop();
dt.detach();
+646
View File
@@ -0,0 +1,646 @@
#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include "pixelworld/PixelWorld.h"
#include "pixelworld/PixelMaterialCatalogCodec.h"
#include <algorithm>
#include <cstddef>
#include <cstring>
#include <limits>
#include <utility>
#include <vector>
using namespace eve::pixelworld;
namespace {
std::vector<std::byte> withoutThermalRemainders(const std::vector<std::byte>& current) {
REQUIRE(current.size() >= 50);
std::uint32_t chunks = 0;
std::memcpy(&chunks, current.data() + 46, sizeof(chunks));
std::vector<std::byte> legacy(current.begin(), current.begin() + 50);
std::size_t cursor = 50;
for (std::uint32_t chunk = 0; chunk < chunks; ++chunk) {
REQUIRE(current.size() - cursor >= 8);
legacy.insert(legacy.end(), current.begin() + std::ptrdiff_t(cursor),
current.begin() + std::ptrdiff_t(cursor + 8));
cursor += 8;
for (int cell = 0; cell < kPixelChunkSize * kPixelChunkSize; ++cell) {
REQUIRE(current.size() - cursor >= 7);
legacy.insert(legacy.end(), current.begin() + std::ptrdiff_t(cursor),
current.begin() + std::ptrdiff_t(cursor + 5));
cursor += 7;
}
}
REQUIRE_EQ(cursor, current.size());
return legacy;
}
} // namespace
TEST_CASE("pixelworld.sand_falls_and_stops_on_stone") {
PixelWorld world(7);
for (int x = -2; x <= 2; ++x) world.setMaterial(x, 4, "stone");
world.setMaterial(0, 0, "sand");
for (std::uint64_t tick = 1; tick <= 6; ++tick) {
auto result = world.advance(eve::SimulationTick(tick));
REQUIRE(result.ok());
}
CHECK_EQ(world.getMaterial(0, 3), int(MaterialId::Sand));
CHECK_EQ(world.getMaterial(0, 4), int(MaterialId::Stone));
}
TEST_CASE("pixelworld.cross_chunk_motion_is_conservative") {
PixelWorld world(11);
for (int x = 61; x <= 65; ++x) world.setMaterial(x, 65, "stone");
world.setMaterial(63, 63, "sand");
for (std::uint64_t tick = 1; tick <= 4; ++tick)
world.advance(eve::SimulationTick(tick)).expect("cross-chunk step");
CHECK_EQ(world.getMaterial(63, 64), int(MaterialId::Sand));
CHECK_EQ(world.getMaterial(63, 65), int(MaterialId::Stone));
CHECK(world.chunkCount() >= 2);
}
TEST_CASE("pixelworld.fire_converts_water_to_steam") {
PixelWorld world(3);
world.setMaterial(10, 10, "fire");
world.setMaterial(11, 10, "water");
world.setMaterial(10, 11, "stone");
world.setMaterial(11, 11, "stone");
world.setMaterial(12, 10, "stone");
world.setMaterial(12, 11, "stone");
const StepStats stats = world.advance(eve::SimulationTick(1)).expect("reaction step");
CHECK(stats.reactions > 0);
const bool steamPresent = world.getMaterial(11, 10) == int(MaterialId::Steam) ||
world.getMaterial(11, 9) == int(MaterialId::Steam);
CHECK(steamPresent);
}
TEST_CASE("pixelworld.fixed_seed_and_ticks_are_deterministic") {
PixelWorld a(99), b(99);
for (int x = -8; x <= 8; ++x) {
a.setMaterial(x, 20, "stone");
b.setMaterial(x, 20, "stone");
}
a.paintCircle(0, 2, 6, "sand");
b.paintCircle(0, 2, 6, "sand");
a.paintCircle(3, 0, 3, "water");
b.paintCircle(3, 0, 3, "water");
for (std::uint64_t tick = 1; tick <= 30; ++tick) {
a.advance(eve::SimulationTick(tick)).expect("determinism a");
b.advance(eve::SimulationTick(tick)).expect("determinism b");
}
auto aBytes = a.saveSnapshot().expect("snapshot a");
auto bBytes = b.saveSnapshot().expect("snapshot b");
REQUIRE_EQ(aBytes.size(), bBytes.size());
CHECK(std::equal(aBytes.begin(), aBytes.end(), bBytes.begin()));
}
TEST_CASE("pixelworld.snapshot_restore_is_transactional") {
PixelWorld source(123);
source.paintCircle(65, -2, 4, "oil");
source.step();
const auto bytes = source.saveSnapshot().expect("save source");
PixelWorld restored(1);
restored.restoreSnapshot(bytes).expect("restore source");
CHECK_EQ(restored.seed(), source.seed());
CHECK_EQ(restored.tickValue(), source.tickValue());
CHECK_EQ(restored.getMaterial(65, -1), source.getMaterial(65, -1));
std::vector<std::byte> malformed = bytes;
malformed.pop_back();
auto failed = restored.restoreSnapshot(malformed);
CHECK(!failed.ok());
CHECK_EQ(restored.seed(), source.seed());
CHECK_EQ(restored.tickValue(), source.tickValue());
}
TEST_CASE("pixelworld.rejects_non_monotonic_tick") {
PixelWorld world;
world.advance(eve::SimulationTick(2)).expect("first step");
auto rejected = world.advance(eve::SimulationTick(2));
CHECK(!rejected.ok());
CHECK_EQ(rejected.error()->code(), eve::DiagnosticCode::PreconditionViolation);
}
TEST_CASE("pixelworld.changed_chunk_snapshots_are_owned_canonical_and_incremental") {
PixelWorld world(17);
world.setMaterial(70, 2, "sand");
world.setMaterial(-2, 2, "water");
const std::uint64_t baseline = world.revision();
auto initial = world.snapshotChangedChunks(0);
REQUIRE_EQ(initial.size(), std::size_t(2));
CHECK(initial[0].x < initial[1].x);
CHECK_EQ(initial[0].cells.size(), std::size_t(kPixelChunkSize * kPixelChunkSize));
const PixelCell ownedCell = initial[0].cells[2 * kPixelChunkSize + 62];
world.setMaterial(70, 3, "stone");
auto incremental = world.snapshotChangedChunks(baseline);
REQUIRE_EQ(incremental.size(), std::size_t(1));
CHECK_EQ(incremental[0].x, 1);
CHECK_EQ(initial[0].cells[2 * kPixelChunkSize + 62], ownedCell);
}
TEST_CASE("pixelworld.revision_tracks_lifetime_only_changes") {
PixelWorld world(23);
world.setMaterial(0, 0, "fire");
const std::uint64_t before = world.revision();
const StepStats stats = world.advance(eve::SimulationTick(1)).expect("fire lifetime step");
CHECK(stats.cellsChanged > 0);
CHECK(world.revision() > before);
const auto changed = world.snapshotChangedChunks(before);
CHECK(!changed.empty());
CHECK(std::all_of(changed.begin(), changed.end(), [before](const PixelChunkSnapshot& chunk) {
return chunk.revision > before;
}));
}
TEST_CASE("pixelworld.material_catalog_validates_names_and_drives_custom_reactions") {
MaterialCatalog builtIn = MaterialCatalog::builtIn();
std::vector<MaterialDefinition> definitions(builtIn.definitions().begin(), builtIn.definitions().end());
const MaterialId brine = MaterialId(definitions.size());
definitions.push_back(
{brine, "brine", MaterialState::Liquid, 110, 35, 30, 90, 32767, 32767, 120});
std::vector<MaterialReactionRule> reactions{
{"brine.stone.hot-only", brine, MaterialId::Stone, MaterialId::Fire, MaterialId::Lava,
1000, 0, 200},
{"brine.stone.erode", brine, MaterialId::Stone, MaterialId::Water, MaterialId::Sand,
-32768, 5, 100},
};
MaterialCatalog catalog =
MaterialCatalog::create(std::move(definitions), std::move(reactions)).expect("custom catalog");
PixelWorld world(41, catalog);
world.setMaterialChecked(10, 10, "brine").expect("place brine");
world.setMaterial(11, 10, "stone");
world.setMaterial(9, 10, "stone");
world.setMaterial(10, 11, "stone");
world.setMaterial(9, 11, "stone");
world.setMaterial(11, 11, "stone");
const std::uint64_t beforeUnknown = world.revision();
auto unknown = world.setMaterialChecked(0, 0, "typo-material");
CHECK(!unknown.ok());
CHECK_EQ(unknown.error()->code(), eve::DiagnosticCode::NotFound);
CHECK_EQ(world.revision(), beforeUnknown);
const StepStats stats = world.advance(eve::SimulationTick(1)).expect("custom reaction step");
CHECK(stats.reactions > 0);
const int center = world.getMaterial(10, 10);
const bool adjacentSand = world.getMaterial(9, 10) == int(MaterialId::Sand) ||
world.getMaterial(11, 10) == int(MaterialId::Sand) ||
world.getMaterial(10, 11) == int(MaterialId::Sand);
const bool adjacentWater = world.getMaterial(9, 10) == int(MaterialId::Water) ||
world.getMaterial(11, 10) == int(MaterialId::Water) ||
world.getMaterial(10, 11) == int(MaterialId::Water);
const bool productsPresent = (center == int(MaterialId::Water) && adjacentSand) ||
(center == int(MaterialId::Sand) && adjacentWater);
CHECK(productsPresent);
}
TEST_CASE("pixelworld.material_catalog_owns_color_and_canonical_tags") {
MaterialCatalog builtIn = MaterialCatalog::builtIn();
CHECK_EQ(builtIn.definition(MaterialId::Water).displayRgba, std::uint32_t(0x1F61F2DBU));
PixelWorld world(42, builtIn);
CHECK_EQ(world.materialDisplayRgba(MaterialId::Acid), std::uint32_t(0x4DEB3DE0U));
std::vector<MaterialDefinition> definitions(builtIn.definitions().begin(),
builtIn.definitions().end());
definitions[std::size_t(MaterialId::Sand)].tags = {"mobile", "granular"};
MaterialCatalog canonical = MaterialCatalog::create(std::move(definitions)).expect("sorted tags");
const auto& tags = canonical.definition(MaterialId::Sand).tags;
REQUIRE_EQ(tags.size(), std::size_t(2));
CHECK_EQ(tags[0], std::string("granular"));
CHECK_EQ(tags[1], std::string("mobile"));
std::vector<MaterialDefinition> invalid(canonical.definitions().begin(),
canonical.definitions().end());
invalid[std::size_t(MaterialId::Sand)].tags = {"mobile", "mobile"};
CHECK(!MaterialCatalog::create(std::move(invalid)).ok());
}
TEST_CASE("pixelworld.material_catalog_json_roundtrips_and_rejects_unknown_schema") {
const MaterialCatalog builtIn = MaterialCatalog::builtIn();
const std::string json = encodeMaterialCatalogJson(builtIn).expect("encode Catalog JSON");
const MaterialCatalog decoded = decodeMaterialCatalogJson(json).expect("decode Catalog JSON");
CHECK_EQ(decoded.fingerprint(), builtIn.fingerprint());
CHECK_EQ(decoded.definition(MaterialId::Fire).displayRgba,
builtIn.definition(MaterialId::Fire).displayRgba);
CHECK_EQ(decoded.reactions().size(), builtIn.reactions().size());
CHECK_EQ(decoded.phaseRules().size(), builtIn.phaseRules().size());
std::string unknownField = json;
unknownField.insert(unknownField.find('{') + 1, "\"unknown\":1,");
CHECK(!decodeMaterialCatalogJson(unknownField).ok());
std::string unknownVersion = json;
const auto version = unknownVersion.find("\"version\": 1");
REQUIRE(version != std::string::npos);
unknownVersion.replace(version, std::string("\"version\": 1").size(), "\"version\": 2");
CHECK(!decodeMaterialCatalogJson(unknownVersion).ok());
CHECK(!decodeMaterialCatalogJson("{broken").ok());
CHECK(!decodeMaterialCatalogJson(
R"({"schema":"eve.pixelworld.material-catalog","version":1,"materials":{},"reactions":[],"phaseRules":[]})")
.ok());
CHECK(!decodeMaterialCatalogJson(
R"({"schema":"eve.pixelworld.material-catalog","version":1,"materials":[7],"reactions":[],"phaseRules":[]})")
.ok());
std::string overflowingId = json;
const auto firstId = overflowingId.find("\"id\": 0");
REQUIRE(firstId != std::string::npos);
overflowingId.replace(firstId, std::string("\"id\": 0").size(), "\"id\": 70000");
CHECK(!decodeMaterialCatalogJson(overflowingId).ok());
}
TEST_CASE("pixelworld.material_catalog_hot_reload_is_paused_compatible_and_transactional") {
PixelWorld world(43);
world.setMaterial(1, 1, "water");
const auto originalLink = world.worldLink();
const std::uint64_t originalFingerprint = world.materialCatalogFingerprint();
MaterialCatalog builtIn = MaterialCatalog::builtIn();
std::vector<MaterialDefinition> definitions(builtIn.definitions().begin(),
builtIn.definitions().end());
definitions[std::size_t(MaterialId::Water)].displayRgba = 0x12345678U;
definitions[std::size_t(MaterialId::Water)].viscosity = 31;
definitions[std::size_t(MaterialId::Water)].density = 60;
MaterialCatalog replacement = MaterialCatalog::create(
std::move(definitions),
{builtIn.reactions().begin(), builtIn.reactions().end()},
{builtIn.phaseRules().begin(), builtIn.phaseRules().end()})
.expect("compatible Catalog");
const std::string replacementJson =
encodeMaterialCatalogJson(replacement).expect("encode replacement Catalog");
CHECK(!world.reloadMaterialCatalog(replacement, originalFingerprint).ok());
CHECK_EQ(world.materialCatalogFingerprint(), originalFingerprint);
world.setPaused(true);
CHECK(!world.reloadMaterialCatalog(replacement, originalFingerprint + 1).ok());
CHECK_EQ(world.materialCatalogFingerprint(), originalFingerprint);
const auto receipt = world.reloadMaterialCatalog(std::move(replacement), originalFingerprint)
.expect("paused Catalog reload");
CHECK_EQ(receipt.fingerprintBefore, originalFingerprint);
CHECK(receipt.fingerprintAfter != originalFingerprint);
CHECK_EQ(receipt.chunksRebuilt, std::uint32_t(1));
CHECK(receipt.replayHistoryInvalidated);
CHECK(world.worldLink().epoch > originalLink.epoch);
CHECK_EQ(world.materialDisplayRgba(MaterialId::Water), std::uint32_t(0x12345678U));
CHECK_EQ(world.getMaterial(1, 1), int(MaterialId::Water));
world.setMaterial(1, 2, "oil");
world.setMaterial(0, 1, "stone");
world.setMaterial(2, 1, "stone");
world.setMaterial(0, 2, "stone");
world.setMaterial(2, 2, "stone");
world.setPaused(false);
world.advance(eve::SimulationTick(1)).expect("advance with rebuilt displacement table");
CHECK_EQ(world.getMaterial(1, 1), int(MaterialId::Water));
CHECK_EQ(world.getMaterial(1, 2), int(MaterialId::Oil));
world.setPaused(true);
const std::uint64_t revisionBeforeNoOp = world.revision();
const auto noOp = world.reloadMaterialCatalog(
decodeMaterialCatalogJson(replacementJson)
.expect("decode replacement Catalog"),
world.materialCatalogFingerprint())
.expect("same Catalog is a no-op");
CHECK_EQ(noOp.revisionBefore, revisionBeforeNoOp);
CHECK_EQ(noOp.revisionAfter, revisionBeforeNoOp);
CHECK_EQ(noOp.worldEpoch, world.worldLink().epoch);
CHECK(!noOp.replayHistoryInvalidated);
const MaterialCatalog incompatibleBase = MaterialCatalog::builtIn();
std::vector<MaterialDefinition> incompatible(incompatibleBase.definitions().begin(),
incompatibleBase.definitions().end());
incompatible[std::size_t(MaterialId::Water)].name = "renamed-water";
auto bad = MaterialCatalog::create(std::move(incompatible)).expect("valid but incompatible");
const std::uint64_t revision = world.revision();
CHECK(!world.reloadMaterialCatalog(std::move(bad), world.materialCatalogFingerprint()).ok());
CHECK_EQ(world.revision(), revision);
}
TEST_CASE("pixelworld.snapshot_rejects_material_catalog_mismatch_transactionally") {
PixelWorld builtInWorld(5);
builtInWorld.setMaterial(0, 0, "sand");
const auto bytes = builtInWorld.saveSnapshot().expect("built-in snapshot");
MaterialCatalog builtIn = MaterialCatalog::builtIn();
std::vector<MaterialDefinition> definitions(builtIn.definitions().begin(), builtIn.definitions().end());
const MaterialId custom = MaterialId(definitions.size());
definitions.push_back({custom, "custom", MaterialState::Powder, 150, 20, 10, 50});
PixelWorld customWorld(
9, MaterialCatalog::create(std::move(definitions)).expect("catalog with custom material"));
customWorld.setMaterialChecked(3, 3, "custom").expect("place custom material");
const std::uint64_t revisionBefore = customWorld.revision();
auto rejected = customWorld.restoreSnapshot(bytes);
CHECK(!rejected.ok());
CHECK_EQ(customWorld.revision(), revisionBefore);
CHECK_EQ(customWorld.getMaterial(3, 3), int(custom));
}
TEST_CASE("pixelworld.snapshot_v1_migrates_only_with_builtin_catalog") {
PixelWorld source(77);
source.setMaterial(4, 5, "water");
std::vector<std::byte> legacy =
withoutThermalRemainders(source.saveSnapshot().expect("v4 source"));
REQUIRE(legacy.size() > 14);
legacy[4] = std::byte{1};
legacy[5] = std::byte{0};
legacy.erase(legacy.begin() + 38, legacy.begin() + 46);
legacy.erase(legacy.begin() + 6, legacy.begin() + 14);
PixelWorld restored(1);
restored.restoreSnapshot(legacy).expect("v1 migration");
CHECK_EQ(restored.seed(), std::uint64_t(77));
CHECK_EQ(restored.getMaterial(4, 5), int(MaterialId::Water));
}
TEST_CASE("pixelworld.snapshot_v2_migrates_with_zero_edit_sequence") {
PixelWorld source(78);
source.setMaterial(2, 3, "sand");
std::vector<std::byte> version2 =
withoutThermalRemainders(source.saveSnapshot().expect("v4 source"));
REQUIRE(version2.size() > 46);
version2[4] = std::byte{2};
version2[5] = std::byte{0};
version2.erase(version2.begin() + 38, version2.begin() + 46);
PixelWorld restored(1);
restored.restoreSnapshot(version2).expect("v2 migration");
CHECK_EQ(restored.getMaterial(2, 3), int(MaterialId::Sand));
CHECK_EQ(restored.lastEditSequence(), std::uint64_t(0));
}
TEST_CASE("pixelworld.snapshot_v3_migrates_with_zero_thermal_remainder") {
PixelWorld source(79);
source.setCell(2, 3, {MaterialId::Stone, 123, 0, 0});
std::vector<std::byte> version3 =
withoutThermalRemainders(source.saveSnapshot().expect("v4 source"));
version3[4] = std::byte{3};
version3[5] = std::byte{0};
PixelWorld restored(1);
restored.restoreSnapshot(version3).expect("v3 migration");
CHECK_EQ(restored.getCell(2, 3).temperature, std::int16_t(123));
CHECK_EQ(restored.getCell(2, 3).thermalRemainder, std::uint16_t(0));
}
TEST_CASE("pixelworld.fixed_point_heat_diffusion_is_directional_and_revisioned") {
PixelWorld world(88);
world.setCell(0, 0, {MaterialId::Stone, 1000, 0});
world.setCell(1, 0, {MaterialId::Stone, 0, 0});
const std::uint64_t before = world.revision();
const StepStats stats = world.advance(eve::SimulationTick(1)).expect("thermal step");
CHECK(stats.temperatureTransfers > 0);
CHECK(world.getCell(0, 0).temperature < 1000);
CHECK(world.getCell(1, 0).temperature > 0);
CHECK(world.revision() > before);
}
TEST_CASE("pixelworld.thermal_remainders_conserve_energy_and_roundtrip_authority") {
const MaterialCatalog builtIn = MaterialCatalog::builtIn();
std::vector<MaterialDefinition> definitions(builtIn.definitions().begin(),
builtIn.definitions().end());
definitions[std::size_t(MaterialId::Stone)].heatCapacity = 7;
definitions[std::size_t(MaterialId::Stone)].thermalConductivity = 13;
definitions[std::size_t(MaterialId::Wood)].heatCapacity = 11;
definitions[std::size_t(MaterialId::Wood)].thermalConductivity = 13;
const MaterialCatalog catalog =
MaterialCatalog::create(std::move(definitions)).expect("thermal test Catalog");
PixelWorld world(8801, catalog);
world.setCell(0, 0, {MaterialId::Stone, 1000, 0, 3});
world.setCell(1, 0, {MaterialId::Wood, -200, 0, 5});
const auto energy = [&](const PixelWorld& value) {
const PixelCell stone = value.getCell(0, 0);
const PixelCell wood = value.getCell(1, 0);
return std::int64_t(stone.temperature) * 7 + stone.thermalRemainder +
std::int64_t(wood.temperature) * 11 + wood.thermalRemainder;
};
const std::int64_t initialEnergy = energy(world);
std::uint64_t transferred = 0;
for (std::uint64_t tick = 1; tick <= 200; ++tick) {
const auto stats = world.advance(eve::SimulationTick(tick)).expect("conservative heat step");
transferred += stats.thermalEnergyTransferred;
CHECK_EQ(stats.thermalEnergyClamped, std::uint64_t(0));
CHECK_EQ(energy(world), initialEnergy);
}
CHECK(transferred > 0);
CHECK(world.getCell(0, 0).thermalRemainder < 7);
CHECK(world.getCell(1, 0).thermalRemainder < 11);
const auto snapshot = world.saveSnapshot().expect("thermal v4 snapshot");
PixelWorld restored(2, catalog);
restored.restoreSnapshot(snapshot).expect("restore thermal v4 snapshot");
CHECK_EQ(restored.getCell(0, 0), world.getCell(0, 0));
CHECK_EQ(restored.getCell(1, 0), world.getCell(1, 0));
auto malformed = snapshot;
REQUIRE(malformed.size() >= 67);
const std::uint16_t invalidRemainder = std::numeric_limits<std::uint16_t>::max();
std::memcpy(malformed.data() + 63, &invalidRemainder, sizeof(invalidRemainder));
const auto beforeRejectedRestore = restored.saveSnapshot().expect("before bad remainder");
CHECK(!restored.restoreSnapshot(malformed).ok());
const auto afterRejectedRestore = restored.saveSnapshot().expect("after bad remainder");
CHECK(std::equal(beforeRejectedRestore.begin(), beforeRejectedRestore.end(),
afterRejectedRestore.begin()));
}
TEST_CASE("pixelworld.phase_rules_freeze_boil_and_melt_without_hardcoded_branches") {
PixelWorld world(89);
world.setCell(10, 10, {MaterialId::Water, -20, 0});
world.setCell(20, 10, {MaterialId::Water, 130, 0});
world.setCell(30, 10, {MaterialId::Ice, 20, 0});
for (const int center : {10, 20}) {
world.setMaterial(center - 1, 10, "stone");
world.setMaterial(center + 1, 10, "stone");
world.setMaterial(center - 1, 11, "stone");
world.setMaterial(center, 11, "stone");
world.setMaterial(center + 1, 11, "stone");
}
const StepStats stats = world.advance(eve::SimulationTick(1)).expect("phase step");
CHECK(stats.phaseChanges >= 3);
CHECK_EQ(world.getMaterial(10, 10), int(MaterialId::Ice));
CHECK_EQ(world.getMaterial(20, 10), int(MaterialId::Steam));
CHECK_EQ(world.getMaterial(30, 10), int(MaterialId::Water));
}
TEST_CASE("pixelworld.explosion_uses_resistance_heat_and_atomic_sequence_receipt") {
PixelWorld world(90);
world.setMaterial(0, 0, "wood");
world.setMaterial(1, 0, "stone");
world.setMaterial(-1, 0, "gunpowder");
PixelEditCommand command;
command.sequence = 1;
command.kind = PixelEditKind::Explosion;
command.radius = 4;
command.strength = 120;
command.temperatureDelta = 400;
const PixelEditReceipt receipt = world.applyEdit(command).expect("explosion command");
CHECK_EQ(receipt.sequence, std::uint64_t(1));
CHECK(receipt.cellsRemoved >= 2);
CHECK_EQ(world.getMaterial(0, 0), int(MaterialId::Air));
CHECK_EQ(world.getMaterial(-1, 0), int(MaterialId::Air));
CHECK_EQ(world.getMaterial(1, 0), int(MaterialId::Stone));
CHECK(world.getCell(1, 0).temperature > 20);
CHECK_EQ(world.lastEditSequence(), std::uint64_t(1));
CHECK(receipt.revisionAfter > receipt.revisionBefore);
}
TEST_CASE("pixelworld.edit_sequence_gap_rejects_without_partial_mutation_and_roundtrips") {
PixelWorld world(91);
world.setMaterial(5, 5, "wood");
world.explode(5, 5, 2, 100, 100);
const auto before = world.saveSnapshot().expect("before rejected edit");
PixelEditCommand gap;
gap.sequence = 3;
gap.kind = PixelEditKind::HeatCircle;
gap.centerX = 5;
gap.centerY = 5;
gap.radius = 2;
gap.temperatureDelta = 500;
auto rejected = world.applyEdit(gap);
CHECK(!rejected.ok());
const auto after = world.saveSnapshot().expect("after rejected edit");
REQUIRE_EQ(before.size(), after.size());
CHECK(std::equal(before.begin(), before.end(), after.begin()));
PixelWorld restored(1);
restored.restoreSnapshot(before).expect("restore edit sequence");
CHECK_EQ(restored.lastEditSequence(), std::uint64_t(1));
}
TEST_CASE("pixelworld.unsupported_solids_detach_canonically_and_rasterize_atomically") {
PixelWorld world(92);
for (int x = -2; x <= 2; ++x) world.setMaterial(x, 10, "stone");
world.setMaterial(-1, 2, "wood");
world.setMaterial(0, 2, "wood");
world.setMaterial(0, 3, "wood");
world.setMaterial(4, 9, "stone");
world.setMaterial(4, 10, "stone");
auto fragments = world.extractUnsupportedFragments({-4, 0, 6, 10}, 10, 2)
.expect("extract unsupported component");
REQUIRE_EQ(fragments.size(), std::size_t(1));
const PixelFragment& fragment = fragments.front();
CHECK_EQ(fragment.originX, -1);
CHECK_EQ(fragment.originY, 2);
CHECK_EQ(fragment.width, 2);
CHECK_EQ(fragment.height, 2);
CHECK_EQ(fragment.solidCellCount, std::uint32_t(3));
CHECK_EQ(world.getMaterial(-1, 2), int(MaterialId::Air));
CHECK_EQ(world.getMaterial(0, 3), int(MaterialId::Air));
CHECK_EQ(world.getMaterial(4, 9), int(MaterialId::Stone));
world.setMaterial(20, 20, "stone");
const auto beforeConflict = world.saveSnapshot().expect("before fragment conflict");
auto conflict = world.rasterizeFragment(fragment, 20, 20);
CHECK(!conflict.ok());
const auto afterConflict = world.saveSnapshot().expect("after fragment conflict");
CHECK(std::equal(beforeConflict.begin(), beforeConflict.end(), afterConflict.begin()));
const auto receipt = world.rasterizeFragment(fragment, 30, 30).expect("rasterize fragment");
CHECK_EQ(receipt.cellsPlaced, std::uint32_t(3));
CHECK_EQ(world.getMaterial(30, 30), int(MaterialId::Wood));
CHECK_EQ(world.getMaterial(31, 31), int(MaterialId::Wood));
}
TEST_CASE("pixelworld.fragment_link_is_stale_after_restore_or_clear") {
PixelWorld world(93);
world.setMaterial(0, 0, "wood");
const auto checkpoint = world.saveSnapshot().expect("fragment checkpoint");
auto fragment = world.extractUnsupportedFragments({-1, -1, 1, 1}, 10)
.expect("extract before restore").front();
world.restoreSnapshot(checkpoint).expect("restore invalidates links");
CHECK(!world.rasterizeFragment(fragment, 5, 5).ok());
auto second = world.extractUnsupportedFragments({-1, -1, 1, 1}, 10)
.expect("extract before clear").front();
world.clear();
CHECK(!world.rasterizeFragment(second, 5, 5).ok());
}
TEST_CASE("pixelworld.empty_chunks_sleep_with_hysteresis_and_emit_removal_tombstones") {
PixelWorld world(94);
world.setMaterial(3, 4, "stone");
const auto populatedRevision = world.revision();
world.setMaterial(3, 4, "air");
CHECK_EQ(world.chunkCount(), 1);
for (std::uint64_t tick = 1; tick < kPixelSleepHysteresisTicks; ++tick) {
const auto stats = world.advance(eve::SimulationTick(tick)).expect("idle hysteresis tick");
CHECK_EQ(stats.chunksReclaimed, std::uint32_t(0));
CHECK_EQ(world.chunkCount(), 1);
}
const auto reclaimed = world.advance(eve::SimulationTick(kPixelSleepHysteresisTicks))
.expect("chunk reclaim tick");
CHECK_EQ(reclaimed.chunksReclaimed, std::uint32_t(1));
CHECK_EQ(world.chunkCount(), 0);
const auto changed = world.snapshotChangedChunks(populatedRevision);
REQUIRE_EQ(changed.size(), std::size_t(1));
CHECK(changed.front().removed);
CHECK(changed.front().cells.empty());
CHECK(changed.front().revision > populatedRevision);
}
TEST_CASE("pixelworld.authoritative_chunk_batch_replaces_and_removes_transactionally") {
PixelWorld authority(95);
authority.setMaterial(2, 3, "wood");
const std::uint64_t populatedRevision = authority.revision();
PixelChunkBatch populated;
populated.catalogFingerprint = authority.materialCatalogFingerprint();
populated.sourceSeed = authority.seed();
populated.sourceRevision = populatedRevision;
populated.sourceTick = eve::SimulationTick(authority.tickValue());
populated.sourceLastEditSequence = authority.lastEditSequence();
populated.chunks = authority.snapshotChangedChunks(0);
PixelWorld replica(95);
const auto applied = replica.applyChunkBatch(populated, 0).expect("apply populated Chunk");
CHECK_EQ(applied.chunksReplaced, std::uint32_t(1));
CHECK_EQ(replica.getMaterial(2, 3), int(MaterialId::Wood));
CHECK_EQ(replica.revision(), authority.revision());
auto fragment = replica.extractUnsupportedFragments({0, 0, 5, 5}, 20)
.expect("extract replica fragment")
.front();
authority.setMaterial(2, 3, "air");
for (std::uint64_t tick = 1; tick <= kPixelSleepHysteresisTicks; ++tick)
authority.advance(eve::SimulationTick(tick)).expect("reclaim authority Chunk");
PixelChunkBatch removed;
removed.catalogFingerprint = authority.materialCatalogFingerprint();
removed.sourceSeed = authority.seed();
removed.sourceRevision = authority.revision();
removed.sourceTick = eve::SimulationTick(authority.tickValue());
removed.sourceLastEditSequence = authority.lastEditSequence();
removed.chunks = authority.snapshotChangedChunks(populatedRevision);
const auto removedReceipt = replica.applyChunkBatch(removed, replica.revision()).expect("apply tombstone");
CHECK_EQ(removedReceipt.chunksRemoved, std::uint32_t(1));
CHECK_EQ(replica.chunkCount(), 0);
CHECK(!replica.rasterizeFragment(fragment, 10, 10).ok());
}
TEST_CASE("pixelworld.authoritative_chunk_batch_rejects_stale_or_malformed_without_mutation") {
PixelWorld world(96);
world.setMaterial(0, 0, "stone");
const auto before = world.saveSnapshot().expect("before bad correction");
PixelChunkBatch stale;
stale.catalogFingerprint = world.materialCatalogFingerprint();
stale.sourceSeed = world.seed();
stale.sourceRevision = world.revision();
stale.sourceTick = eve::SimulationTick(world.tickValue());
CHECK(!world.applyChunkBatch(stale, world.revision() - 1).ok());
PixelChunkSnapshot malformed;
malformed.revision = world.revision();
malformed.cells.resize(1);
stale.chunks.push_back(std::move(malformed));
CHECK(!world.applyChunkBatch(stale, world.revision()).ok());
const auto after = world.saveSnapshot().expect("after bad correction");
REQUIRE_EQ(before.size(), after.size());
CHECK(std::equal(before.begin(), before.end(), after.begin()));
}
+103
View File
@@ -0,0 +1,103 @@
#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include "pixelworld/PixelWorldControl.h"
#include "pixelworld/PixelMaterialCatalogCodec.h"
#include <utility>
using namespace eve::pixelworld;
TEST_CASE("pixelworld.control_registry_tracks_lifetime_and_move") {
auto& control = pixelWorldControlService();
std::uint64_t id = 0;
{
PixelWorld original(71);
id = original.worldLink().world;
REQUIRE(control.world(id).ok());
PixelWorld moved(std::move(original));
auto summary = control.world(id).expect("moved world remains registered");
CHECK_EQ(summary.seed, std::uint64_t(71));
CHECK_EQ(summary.link.world, id);
}
CHECK(!control.world(id).ok());
}
TEST_CASE("pixelworld.control_pause_and_explicit_step_have_one_authority") {
PixelWorld world(72);
const std::uint64_t id = world.worldLink().world;
auto& control = pixelWorldControlService();
control.setPaused(id, true).expect("pause world");
const StepStats paused = world.step();
CHECK_EQ(paused.tick.value(), std::uint64_t(0));
CHECK_EQ(world.tickValue(), std::uint64_t(0));
const auto receipt = control.step(id, 3).expect("explicit paused steps");
CHECK_EQ(receipt.steps, std::uint32_t(3));
CHECK_EQ(receipt.firstTick, std::uint64_t(1));
CHECK_EQ(receipt.lastTick, std::uint64_t(3));
CHECK_EQ(world.tickValue(), std::uint64_t(3));
CHECK(world.isPaused());
CHECK(!control.step(id, 0).ok());
CHECK(!control.step(id, 1025).ok());
const auto samples = control.performanceSamples(id, 2).expect("bounded samples");
REQUIRE_EQ(samples.size(), std::size_t(2));
CHECK_EQ(samples.front().tick, std::uint64_t(2));
CHECK_EQ(samples.back().tick, std::uint64_t(3));
CHECK(!control.performanceSamples(id, 0).ok());
}
TEST_CASE("pixelworld.control_edit_snapshot_and_diagnostics_are_transactional") {
PixelWorld world(73);
const std::uint64_t id = world.worldLink().world;
auto& control = pixelWorldControlService();
PixelEditCommand paint;
paint.sequence = 1;
paint.kind = PixelEditKind::PaintCircle;
paint.radius = 1;
paint.material = MaterialId::Water;
control.applyEdit(id, paint).expect("control paint");
const auto diagnostics = control.chunkDiagnostics(id, {0, 0, 0, 0})
.expect("bounded diagnostics");
REQUIRE_EQ(diagnostics.size(), std::size_t(1));
CHECK_EQ(diagnostics.front().nonAirCells, std::uint32_t(5));
CHECK_EQ(diagnostics.front().mobileCells, std::uint32_t(5));
CHECK_EQ(diagnostics.front().minimumTemperature, std::int16_t(20));
CHECK(!control.chunkDiagnostics(id, {0, 0, 256, 256}).ok());
const auto snapshot = control.captureSnapshot(id).expect("control snapshot");
world.setMaterial(8, 8, "stone");
REQUIRE_EQ(world.getMaterial(8, 8), int(MaterialId::Stone));
control.restoreSnapshot(id, snapshot).expect("control restore");
CHECK_EQ(world.getMaterial(8, 8), int(MaterialId::Air));
auto malformed = snapshot;
malformed.pop_back();
const std::uint64_t revision = world.revision();
CHECK(!control.restoreSnapshot(id, malformed).ok());
CHECK_EQ(world.revision(), revision);
}
TEST_CASE("pixelworld.control_catalog_reload_reuses_validated_authority_path") {
PixelWorld world(75);
auto& control = pixelWorldControlService();
control.setPaused(world.worldLink().world, true).expect("pause authoring world");
MaterialCatalog builtIn = MaterialCatalog::builtIn();
std::vector<MaterialDefinition> definitions(builtIn.definitions().begin(),
builtIn.definitions().end());
definitions[std::size_t(MaterialId::Stone)].displayRgba = 0x010203FFU;
auto candidate = MaterialCatalog::create(
std::move(definitions),
{builtIn.reactions().begin(), builtIn.reactions().end()},
{builtIn.phaseRules().begin(), builtIn.phaseRules().end()})
.expect("authoring candidate");
const std::string json = encodeMaterialCatalogJson(candidate).expect("authoring JSON");
control.reloadMaterialCatalog(world.worldLink().world, json,
world.materialCatalogFingerprint())
.expect("control Catalog reload");
CHECK_EQ(world.materialDisplayRgba(MaterialId::Stone), std::uint32_t(0x010203FFU));
CHECK(!control.reloadMaterialCatalog(world.worldLink().world, "{}",
world.materialCatalogFingerprint())
.ok());
}
+141
View File
@@ -0,0 +1,141 @@
#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include "pixelworld/PixelMaterialCatalogCodec.h"
#include "pixelworld/PixelWorld.h"
#include "pixelworld_editor/PixelWorldCatalogPanel.h"
#include "ui/UIHost.h"
#include <string>
using namespace eve::pixelworld;
using namespace eve::pixelworld_editor;
namespace {
eve::ui::WidgetDesc* findWidget(eve::ui::WidgetDesc& root, const std::string& id) {
if (root.id == id) return &root;
for (auto& child : root.children)
if (auto* found = findWidget(child, id)) return found;
return nullptr;
}
} // namespace
TEST_CASE("pixelworld_editor_material_draft_is_transactional_and_codec_canonical") {
auto panel = PixelWorldCatalogPanel::builtIn();
const auto initialRevision = panel.revision();
const auto initialFingerprint = panel.draft().fingerprint();
MaterialDefinition stone = panel.draft().definition(MaterialId::Stone);
stone.displayRgba = 0x102030FFU;
const auto receipt = panel.replaceMaterial(stone).expect("replace material draft");
CHECK_EQ(receipt.revisionBefore, initialRevision);
CHECK_EQ(receipt.revisionAfter, initialRevision + 1);
CHECK(receipt.fingerprint != initialFingerprint);
CHECK_EQ(panel.draft().definition(MaterialId::Stone).displayRgba,
std::uint32_t(0x102030FFU));
const auto beforeFailure = panel.document().expect("document before failure");
stone.name = "water";
CHECK(!panel.replaceMaterial(stone).ok());
CHECK_EQ(panel.revision(), initialRevision + 1);
CHECK_EQ(panel.document().expect("document after failure"), beforeFailure);
auto decoded = PixelWorldCatalogPanel::create(beforeFailure).expect("decode panel document");
CHECK_EQ(decoded.draft().fingerprint(), panel.draft().fingerprint());
CHECK(!panel.loadDocument("{broken").ok());
CHECK_EQ(panel.document().expect("document after malformed load"), beforeFailure);
}
TEST_CASE("pixelworld_editor_reaction_and_phase_operations_validate_whole_catalog") {
auto panel = PixelWorldCatalogPanel::builtIn();
MaterialReactionRule reaction;
reaction.id = "editor-test-reaction";
reaction.first = MaterialId::Water;
reaction.second = MaterialId::Lava;
reaction.firstResult = MaterialId::Steam;
reaction.secondResult = MaterialId::Stone;
reaction.heatDelta = 25;
panel.addReaction(reaction).expect("add reaction");
REQUIRE(panel.selectedReactionIndex().has_value());
CHECK_EQ(panel.draft().reactions()[*panel.selectedReactionIndex()].id,
std::string("editor-test-reaction"));
auto duplicate = reaction;
duplicate.first = MaterialId::Oil;
CHECK(!panel.addReaction(std::move(duplicate)).ok());
MaterialPhaseRule phase;
phase.id = "editor-test-phase";
phase.source = MaterialId::Water;
phase.result = MaterialId::Steam;
phase.threshold = 120;
panel.addPhase(phase).expect("add phase");
REQUIRE(panel.selectedPhaseIndex().has_value());
CHECK_EQ(panel.draft().phaseRules()[*panel.selectedPhaseIndex()].id,
std::string("editor-test-phase"));
panel.removePhase(*panel.selectedPhaseIndex()).expect("remove phase");
}
TEST_CASE("pixelworld_editor_widget_tree_exposes_visual_material_and_rule_controls") {
auto panel = PixelWorldCatalogPanel::builtIn();
auto tree = panel.buildWidgetTree();
CHECK_EQ(tree.id, std::string("pixelworld-catalog-editor"));
REQUIRE(findWidget(tree, "catalog-browser") != nullptr);
REQUIRE(findWidget(tree, "material-preview") != nullptr);
REQUIRE(findWidget(tree, "reaction-section") != nullptr);
REQUIRE(findWidget(tree, "phase-section") != nullptr);
auto* stone = findWidget(tree, "material/1");
REQUIRE(stone != nullptr);
REQUIRE(bool(stone->onClick));
stone->onClick();
CHECK_EQ(panel.selectedMaterialIndex(), std::size_t(1));
tree = panel.buildWidgetTree();
auto* rgba = findWidget(tree, "material-rgba");
REQUIRE(rgba != nullptr);
REQUIRE(bool(rgba->onTextChange));
rgba->onTextChange("#112233FF");
CHECK_EQ(panel.draft().definition(MaterialId::Stone).displayRgba,
std::uint32_t(0x112233FFU));
CHECK(panel.statusText().find("validated") != std::string::npos);
}
TEST_CASE("pixelworld_editor_mounts_a_live_panel_and_reconciles_callback_edits") {
auto panel = PixelWorldCatalogPanel::builtIn();
const auto hostHandle = panel.open().expect("open catalog panel");
REQUIRE(hostHandle);
CHECK(panel.isOpen());
auto host = eve::ui::UIHost::resolve(hostHandle);
REQUIRE(host.has_value());
auto stone = host->get().findById("material/1");
REQUIRE(stone.has_value());
const auto handler = stone->get().handlerClick;
REQUIRE(handler > 0);
auto click = host->get().tree()->clickHandlers[handler - 1];
REQUIRE(bool(click));
click();
CHECK_EQ(panel.selectedMaterialIndex(), std::size_t(1));
CHECK(host->get().findById("material-rgba").has_value());
panel.close();
CHECK(!panel.isOpen());
panel.open().expect("reopen catalog panel");
CHECK(panel.isOpen());
}
TEST_CASE("pixelworld_editor_apply_reuses_paused_authority_and_fingerprint_guard") {
PixelWorld world(8601);
auto& control = pixelWorldControlService();
const auto worldId = world.worldLink().world;
control.setPaused(worldId, true).expect("pause world");
auto panel = PixelWorldCatalogPanel::builtIn();
MaterialDefinition stone = panel.draft().definition(MaterialId::Stone);
stone.displayRgba = 0xABCDEF88U;
panel.replaceMaterial(std::move(stone)).expect("edit draft");
const auto expected = world.materialCatalogFingerprint();
panel.apply(control, worldId, expected).expect("apply Catalog panel");
CHECK_EQ(world.materialDisplayRgba(MaterialId::Stone), std::uint32_t(0xABCDEF88U));
CHECK(!panel.apply(control, worldId, expected).ok());
}
+135
View File
@@ -0,0 +1,135 @@
#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include "pixelworld/PixelWorldGeneration.h"
#include "pixelworld/PixelWorldGenerationCodec.h"
#include <algorithm>
#include <limits>
#include <set>
#include <string>
using namespace eve::pixelworld;
TEST_CASE("pixelworld_generation.is_bit_exact_seamless_and_seed_sensitive") {
const MaterialCatalog catalog = MaterialCatalog::builtIn();
PixelWorldGenerationRequest request;
request.seed = 0xC0FFEE;
request.region = {-3, -1, 3, 2};
request.surfaceY = 4;
request.terrainAmplitude = 20;
request.waterLevel = 48;
request.caveThreshold = 4200;
const auto first = generatePixelWorld(request, catalog, 1).expect("first generated world");
const auto second = generatePixelWorld(request, catalog, 1).expect("second generated world");
CHECK_EQ(first.contentHash, second.contentHash);
CHECK_EQ(first.batch, second.batch);
CHECK_EQ(first.chunks, second.chunks);
CHECK(std::any_of(first.chunks.begin(), first.chunks.end(), [](const auto& chunk) {
return chunk.caveCells > 0;
}));
CHECK(std::any_of(first.chunks.begin(), first.chunks.end(), [](const auto& chunk) {
return chunk.solidCells > 0;
}));
PixelWorldGenerationRequest single = request;
single.region = {0, 0, 0, 0};
const auto isolated = generatePixelWorld(single, catalog, 1).expect("isolated Chunk");
const auto wholeChunk = std::find_if(first.batch.chunks.begin(), first.batch.chunks.end(),
[](const auto& chunk) {
return chunk.x == 0 && chunk.y == 0;
});
REQUIRE(wholeChunk != first.batch.chunks.end());
REQUIRE_EQ(isolated.batch.chunks.size(), std::size_t(1));
CHECK_EQ(wholeChunk->cells, isolated.batch.chunks.front().cells);
request.seed += 1;
const auto different = generatePixelWorld(request, catalog, 1).expect("different seed");
CHECK(different.contentHash != first.contentHash);
}
TEST_CASE("pixelworld_generation.stamps_cross_chunk_boundaries_and_apply_transactionally") {
const MaterialCatalog catalog = MaterialCatalog::builtIn();
PixelWorldGenerationRequest request;
request.seed = 77;
request.region = {0, 0, 1, 0};
request.surfaceY = 0;
request.caveThreshold = 0;
PixelMaterialStamp stamp;
stamp.originX = 62;
stamp.originY = 10;
stamp.width = 4;
stamp.height = 2;
stamp.cells.assign(8, {MaterialId::Wood, 20, 0, 0});
request.stamps.push_back(stamp);
const auto generated = generatePixelWorld(request, catalog, 1).expect("stamped generation");
REQUIRE_EQ(generated.batch.chunks.size(), std::size_t(2));
CHECK_EQ(generated.chunks[0].stampedCells, std::uint32_t(4));
CHECK_EQ(generated.chunks[1].stampedCells, std::uint32_t(4));
PixelWorld world(request.seed, catalog);
const auto receipt = world.applyChunkBatch(generated.batch, 0).expect("apply generated batch");
CHECK_EQ(receipt.chunksReplaced, std::uint32_t(2));
CHECK_EQ(world.revision(), std::uint64_t(1));
for (int y = 10; y < 12; ++y)
for (int x = 62; x < 66; ++x)
CHECK_EQ(world.getMaterial(x, y), int(MaterialId::Wood));
const auto before = world.saveSnapshot().expect("before invalid generation");
PixelWorldGenerationRequest invalid = request;
invalid.schemaVersion = 99;
CHECK(!generatePixelWorld(invalid, catalog, 2).ok());
const auto after = world.saveSnapshot().expect("after invalid generation");
REQUIRE_EQ(after.size(), before.size());
CHECK(std::equal(after.begin(), after.end(), before.begin()));
}
TEST_CASE("pixelworld_generation.codec_round_trips_and_rejects_unknown_data") {
PixelWorldGenerationRequest request;
request.seed = std::numeric_limits<std::uint64_t>::max();
request.region = {-2, 3, 4, 5};
request.surfaceY = -17;
request.terrainAmplitude = 91;
request.waterLevel = 73;
request.caveThreshold = 1234;
PixelMaterialStamp stamp;
stamp.originX = -65;
stamp.originY = 7;
stamp.width = 1;
stamp.height = 1;
stamp.cells.push_back({MaterialId::Lava, 777, 9, 4});
request.stamps.push_back(stamp);
const std::string encoded =
encodePixelWorldGenerationRequestJson(request).expect("encode request");
const auto decoded =
decodePixelWorldGenerationRequestJson(encoded).expect("decode request");
CHECK_EQ(decoded.schemaVersion, request.schemaVersion);
CHECK_EQ(decoded.seed, request.seed);
CHECK_EQ(decoded.region.minX, request.region.minX);
CHECK_EQ(decoded.region.minY, request.region.minY);
CHECK_EQ(decoded.region.maxX, request.region.maxX);
CHECK_EQ(decoded.region.maxY, request.region.maxY);
CHECK_EQ(decoded.surfaceY, request.surfaceY);
CHECK_EQ(decoded.terrainAmplitude, request.terrainAmplitude);
CHECK_EQ(decoded.waterLevel, request.waterLevel);
CHECK_EQ(decoded.caveThreshold, request.caveThreshold);
REQUIRE_EQ(decoded.stamps.size(), std::size_t(1));
CHECK_EQ(decoded.stamps.front().cells, request.stamps.front().cells);
CHECK_EQ(encodePixelWorldGenerationRequestJson(decoded).expect("re-encode request"), encoded);
std::string unknownVersion = encoded;
const auto versionAt = unknownVersion.find("\"version\": 1");
REQUIRE(versionAt != std::string::npos);
unknownVersion.replace(versionAt, std::string("\"version\": 1").size(), "\"version\": 2");
CHECK(!decodePixelWorldGenerationRequestJson(unknownVersion).ok());
std::string unknownField = encoded;
const auto closingBrace = unknownField.rfind('}');
REQUIRE(closingBrace != std::string::npos);
unknownField.insert(closingBrace, ",\n \"future\": true\n");
CHECK(!decodePixelWorldGenerationRequestJson(unknownField).ok());
CHECK(!decodePixelWorldGenerationRequestJson("{broken").ok());
}
+107
View File
@@ -0,0 +1,107 @@
#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include "pixelworld/PixelWorld.h"
#include "pixelworld_thread/PixelWorldThread.h"
#include "thread/JobSystem.h"
#include <algorithm>
#include <chrono>
#include <cstdlib>
#include <iostream>
#include <memory>
#include <vector>
using namespace eve::pixelworld;
TEST_CASE("pixelworld.parallel_thermal_candidates_are_bit_exact_with_reference") {
PixelWorld reference(7001);
PixelWorld parallel(7001);
for (PixelWorld* world : {&reference, &parallel}) {
for (int x = -70; x <= 70; ++x) world->setMaterial(x, 80, "stone");
world->paintCircle(-35, 20, 18, "sand");
world->paintCircle(35, 20, 18, "water");
world->paintCircle(0, 40, 12, "lava");
world->paintCircle(0, 58, 16, "ice");
}
std::unique_ptr<eve::thread::JobSystem> jobs(eve::thread::createJobSystem(4));
eve::pixelworld_thread::JobSystemPixelScheduler scheduler(*jobs);
CHECK_EQ(scheduler.workerCount(), std::size_t(4));
for (std::uint64_t tick = 1; tick <= 100; ++tick) {
reference.advance(eve::SimulationTick(tick)).expect("reference tick");
const StepStats stats = parallel.advanceScheduled(eve::SimulationTick(tick), scheduler)
.expect("scheduled tick");
CHECK(stats.parallelTasks > 1);
}
const auto expected = reference.saveSnapshot().expect("reference snapshot");
const auto actual = parallel.saveSnapshot().expect("parallel snapshot");
REQUIRE_EQ(expected.size(), actual.size());
CHECK(std::equal(expected.begin(), expected.end(), actual.begin()));
}
TEST_CASE("pixelworld.benchmark_million_active_pixels_reports_p50_p95") {
if (!std::getenv("EVENGINE_PIXELWORLD_MILLION_BENCHMARK")) return;
PixelWorld world(7002);
for (int y = 0; y < 1024; ++y)
for (int x = 0; x < 1024; ++x) world.setMaterial(x, y, "stone");
std::unique_ptr<eve::thread::JobSystem> jobs(eve::thread::createJobSystem(4));
eve::pixelworld_thread::JobSystemPixelScheduler scheduler(*jobs);
std::vector<double> milliseconds;
std::uint64_t totalReactions = 0;
for (std::uint64_t tick = 1; tick <= 10; ++tick) {
for (int cy = 0; cy < 16; ++cy)
for (int cx = 0; cx < 16; ++cx)
world.setMaterial(cx * kPixelChunkSize, cy * kPixelChunkSize, "stone");
const auto started = std::chrono::steady_clock::now();
const auto stats = world.advanceScheduled(eve::SimulationTick(tick), scheduler)
.expect("million-pixel scheduled tick");
milliseconds.push_back(std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - started).count());
totalReactions += stats.reactions;
CHECK_EQ(stats.chunksVisited, std::uint32_t(256));
}
std::sort(milliseconds.begin(), milliseconds.end());
const double p50 = milliseconds[milliseconds.size() / 2];
const double p95 = milliseconds.back();
std::cout << "PIXELWORLD_BENCHMARK_JSON={\"occupiedPixels\":1048576,\"chunks\":256,"
"\"workload\":\"uniform-stone-force-awake\",\"samples\":10,\"workers\":"
<< scheduler.workerCount()
<< ",\"reactions\":" << totalReactions << ",\"p50Ms\":" << p50
<< ",\"p95Ms\":" << p95 << "}" << std::endl;
CHECK(p50 > 0.0);
CHECK(p95 >= p50);
}
TEST_CASE("pixelworld.benchmark_million_mobile_pixels_reports_p50_p95") {
if (!std::getenv("EVENGINE_PIXELWORLD_ACTIVE_BENCHMARK")) return;
PixelWorld world(7003);
for (int y = 0; y < 1024; ++y)
for (int x = 0; x < 1024; ++x) world.setMaterial(x, y, "sand");
std::unique_ptr<eve::thread::JobSystem> jobs(eve::thread::createJobSystem(4));
eve::pixelworld_thread::JobSystemPixelScheduler scheduler(*jobs);
std::vector<double> milliseconds;
std::uint64_t totalVisited = 0;
std::uint64_t totalMoved = 0;
for (std::uint64_t tick = 1; tick <= 5; ++tick) {
const auto started = std::chrono::steady_clock::now();
const auto stats = world.advanceScheduled(eve::SimulationTick(tick), scheduler)
.expect("million-mobile-pixel scheduled tick");
milliseconds.push_back(std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - started).count());
totalVisited += stats.cellsVisited;
totalMoved += stats.cellsMoved;
CHECK(stats.chunksVisited > 0);
CHECK(stats.parallelTasks > 1);
}
std::sort(milliseconds.begin(), milliseconds.end());
const double p50 = milliseconds[milliseconds.size() / 2];
const double p95 = milliseconds.back();
std::cout << "PIXELWORLD_ACTIVE_BENCHMARK_JSON={\"mobilePixels\":1048576,\"chunks\":256,"
"\"workload\":\"packed-sand-over-empty\",\"samples\":5,\"workers\":"
<< scheduler.workerCount() << ",\"visited\":" << totalVisited
<< ",\"moved\":" << totalMoved << ",\"p50Ms\":" << p50
<< ",\"p95Ms\":" << p95 << "}" << std::endl;
CHECK(p50 > 0.0);
CHECK(p95 >= p50);
}
+183
View File
@@ -0,0 +1,183 @@
#include "physics/Body.h"
#include "physics/World.h"
#include "pixelworld/PixelWorld.h"
#include "pixelworld_physics/PixelWorldPhysics.h"
#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include <cmath>
using namespace eve::pixelworld;
using namespace eve::pixelworld_physics;
namespace {
PixelFragment detachL(PixelWorld& world) {
world.setMaterial(0, 0, "wood");
world.setMaterial(1, 0, "wood");
world.setMaterial(0, 1, "wood");
auto fragments = world.extractUnsupportedFragments({-1, -1, 2, 2}, 10)
.expect("detach L fragment");
REQUIRE_EQ(fragments.size(), std::size_t(1));
return std::move(fragments.front());
}
} // namespace
TEST_CASE("pixelworld_physics.fragment_decomposition_and_sleep_settlement_are_transactional") {
PixelWorld pixels(501);
PixelFragment fragment = detachL(pixels);
eve::physics::World physics(0.f, 100.f, true, 64.f);
auto adapter = PixelFragmentBody::create(physics, std::move(fragment))
.expect("create fragment body");
REQUIRE_EQ(adapter->collisionRects().size(), std::size_t(2));
auto body = adapter->physicsLink().resolve(physics).expect("resolve fragment body");
body->setPosition(20.f, 20.f);
body->setAngle(1.57079632679f);
pixels.setMaterial(19, 19, "stone");
body->setAwake(false);
auto conflict = adapter->settleIfSleeping(physics, pixels);
CHECK(!conflict.ok());
CHECK(body->isValid());
CHECK(!adapter->isRasterized());
body->setPosition(40.f, 40.f);
body->setAwake(false);
const auto settled = adapter->settleIfSleeping(physics, pixels).expect("settle fragment");
CHECK_EQ(int(settled.disposition), int(FragmentSettleDisposition::Rasterized));
CHECK_EQ(settled.cellsRasterized, std::uint32_t(3));
CHECK_EQ(settled.quarterTurns, 1);
CHECK(adapter->isRasterized());
CHECK(!body->isValid());
CHECK_EQ(pixels.getMaterial(39, 39), int(MaterialId::Wood));
CHECK_EQ(pixels.getMaterial(40, 39), int(MaterialId::Wood));
CHECK_EQ(pixels.getMaterial(40, 40), int(MaterialId::Wood));
}
TEST_CASE("pixelworld_physics.both_destruction_orders_and_restore_staleness_are_safe") {
PixelWorld pixels(502);
eve::physics::World firstPhysics(0.f, 0.f, true, 64.f);
auto first = PixelFragmentBody::create(firstPhysics, detachL(pixels))
.expect("first fragment body");
first->releasePhysics(firstPhysics).expect("body first release");
CHECK(firstPhysics.isValid());
pixels.clear();
auto secondFragment = detachL(pixels);
const auto checkpoint = pixels.saveSnapshot().expect("pixel checkpoint");
auto second = PixelFragmentBody::create(firstPhysics, std::move(secondFragment))
.expect("second fragment body");
auto secondBody = second->physicsLink().resolve(firstPhysics).expect("second body");
secondBody->setAwake(false);
pixels.restoreSnapshot(checkpoint).expect("restore invalidates pixel link");
CHECK(!second->settleIfSleeping(firstPhysics, pixels).ok());
CHECK(secondBody->isValid());
auto thirdPhysics = std::make_unique<eve::physics::World>(0.f, 0.f, true, 64.f);
pixels.clear();
auto third = PixelFragmentBody::create(*thirdPhysics, detachL(pixels))
.expect("third fragment body");
thirdPhysics.reset();
eve::physics::World foreignPhysics(0.f, 0.f, true, 64.f);
CHECK(!third->releasePhysics(foreignPhysics).ok());
}
TEST_CASE("pixelworld_physics.terrain_cache_rebuilds_dirty_chunks_and_rolls_back_budget_failure") {
PixelWorld pixels(503);
for (int x = 0; x < 70; ++x) pixels.setMaterial(x, 20, "stone");
pixels.setMaterial(5, 5, "water");
eve::physics::World physics(0.f, 100.f, true, 64.f);
PixelTerrainCollisionCache cache;
const auto initial = cache.sync(physics, pixels).expect("initial terrain collision sync");
CHECK_EQ(initial.chunksRebuilt, std::uint32_t(2));
CHECK_EQ(initial.fixturesCreated, std::uint32_t(4));
CHECK_EQ(cache.bodyCount(), std::size_t(2));
CHECK(physics.rayCast(10.f, 10.f, 10.f, 30.f) >= 0);
CHECK(std::abs(physics.getRayHitY() - 20.f) < 0.01f);
const auto represented = cache.sourceRevision();
pixels.setMaterial(2, 19, "stone");
const auto incremental = cache.sync(physics, pixels).expect("dirty chunk collision sync");
CHECK_EQ(incremental.chunksRebuilt, std::uint32_t(2));
CHECK_EQ(incremental.bodiesRemoved, std::uint32_t(2));
CHECK(cache.sourceRevision() > represented);
const auto beforeFailureRevision = cache.sourceRevision();
const auto beforeFailureBodies = cache.bodyCount();
for (int x = 0; x < 16; x += 2) pixels.setMaterial(x, 4, "stone");
auto rejected = cache.sync(physics, pixels, 1);
CHECK(!rejected.ok());
CHECK_EQ(cache.sourceRevision(), beforeFailureRevision);
CHECK_EQ(cache.bodyCount(), beforeFailureBodies);
cache.clearPhysics(physics).expect("clear terrain collision cache");
CHECK_EQ(cache.bodyCount(), std::size_t(0));
CHECK_EQ(cache.sourceRevision(), std::uint64_t(0));
}
TEST_CASE("pixelworld_physics.terrain_contours_are_simplified_and_suppress_chunk_seams") {
PixelWorld pixels(504);
for (int y = 10; y < 12; ++y)
for (int x = 10; x < 12; ++x) pixels.setMaterial(x, y, "stone");
auto simple = extractTerrainContours(pixels, 0, 0).expect("simple terrain contour");
REQUIRE_EQ(simple.size(), std::size_t(1));
CHECK(simple.front().loop);
CHECK_EQ(simple.front().vertices.size(), std::size_t(8));
pixels.setMaterial(63, 20, "stone");
pixels.setMaterial(64, 20, "stone");
const auto left = extractTerrainContours(pixels, 0, 0).expect("left seam contour");
for (const auto& contour : left) {
const std::size_t count = contour.vertices.size() / 2;
for (std::size_t index = 0; index + 1 < count; ++index) {
const float x1 = contour.vertices[index * 2];
const float y1 = contour.vertices[index * 2 + 1];
const float x2 = contour.vertices[(index + 1) * 2];
const float y2 = contour.vertices[(index + 1) * 2 + 1];
CHECK(!(x1 == 64.f && x2 == 64.f && y1 >= 20.f && y2 >= 20.f));
}
}
CHECK(!extractTerrainContours(pixels, 0, 0, 3).ok());
}
TEST_CASE("pixelworld_physics.character_probe_and_sweep_query_authoritative_material") {
PixelWorld pixels(505);
pixels.setMaterial(10, 10, "stone");
pixels.setMaterial(8, 10, "water");
const auto probe = probeTerrainCircle(pixels, 9.75f, 10.5f, 0.5f)
.expect("character terrain probe");
REQUIRE(probe.hit);
CHECK_EQ(int(probe.material), int(MaterialId::Stone));
CHECK_EQ(probe.cellX, 10);
CHECK(std::abs(probe.depth - 0.25f) < 0.0001f);
CHECK_EQ(probe.normalX, -1.f);
CHECK_EQ(probe.normalY, 0.f);
const auto sweep = sweepTerrainCircle(pixels, 5.5f, 10.5f, 15.5f, 10.5f, 0.25f)
.expect("character terrain sweep");
REQUIRE(sweep.hit);
CHECK_EQ(int(sweep.material), int(MaterialId::Stone));
CHECK_EQ(sweep.cellX, 10);
CHECK(std::abs(sweep.fraction - 0.425f) < 0.0001f);
CHECK_EQ(sweep.normalX, -1.f);
const auto corner = sweepTerrainCircle(pixels, 8.f, 8.f, 10.f, 10.f, 0.5f)
.expect("rounded corner sweep");
REQUIRE(corner.hit);
CHECK(corner.normalX < -0.7f);
CHECK(corner.normalY < -0.7f);
const auto cornerMiss = sweepTerrainCircle(pixels, 8.f, 8.f, 9.6f, 9.6f, 0.5f)
.expect("rounded corner miss");
CHECK(!cornerMiss.hit);
const auto miss = sweepTerrainCircle(pixels, 1.f, 1.f, 2.f, 1.f, 0.25f)
.expect("empty terrain sweep");
CHECK(!miss.hit);
CHECK_EQ(miss.fraction, 1.f);
CHECK(!sweepTerrainCircle(pixels, 0.f, 0.f, 100.f, 100.f, 1.f, 4).ok());
}
+234
View File
@@ -0,0 +1,234 @@
#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include "pixelworld/PixelWorld.h"
#include "pixelworld_replay/PixelWorldReplay.h"
#include <algorithm>
#include <cstdint>
#include <string>
using namespace eve::pixelworld;
using namespace eve::pixelworld_replay;
namespace {
eve::SnapshotHashProvider replayHashProvider() {
return [](std::string_view input) -> eve::Result<eve::ContentId> {
std::uint64_t left = 14695981039346656037ULL;
std::uint64_t right = 1099511628211ULL;
for (const unsigned char byte : input) {
left = (left ^ byte) * 1099511628211ULL;
right = (right ^ (std::uint64_t(byte) + 0x9e3779b97f4a7c15ULL)) * 14029467366897019727ULL;
}
eve::ContentId::Bytes bytes{};
for (int index = 0; index < 8; ++index) {
bytes[std::size_t(index)] = std::uint8_t(left >> (56 - index * 8));
bytes[std::size_t(index + 8)] = std::uint8_t(right >> (56 - index * 8));
}
return eve::Result<eve::ContentId>::success(eve::ContentId(bytes));
};
}
eve::LogicalId replaySchema() {
const auto parsed = eve::LogicalId::parse("pixelworld:replay-log");
REQUIRE(parsed.has_value());
return *parsed;
}
eve::Value versionOnePayload() {
eve::Value::Object command{
{"centerX", eve::Value(3)},
{"centerY", eve::Value(4)},
{"kind", eve::Value(int(PixelEditKind::PaintCircle))},
{"material", eve::Value(int(MaterialId::Sand))},
{"radius", eve::Value(2)},
{"sequence", eve::Value("1")},
{"strength", eve::Value(0)},
{"temperatureDelta", eve::Value(0)},
};
eve::Value::Array entries;
entries.emplace_back(eve::Value::Object{
{"command", eve::Value(std::move(command))},
{"tick", eve::Value("1")},
});
eve::Value::Array checkpoints;
checkpoints.emplace_back(eve::Value::Object{
{"revision", eve::Value("1")},
{"tick", eve::Value("2")},
{"worldDigest", eve::Value("42")},
});
return eve::Value(eve::Value::Object{
{"checkpoints", eve::Value(std::move(checkpoints))},
{"entries", eve::Value(std::move(entries))},
});
}
} // namespace
TEST_CASE("pixelworld_replay.command_log_checkpoints_replay_bit_exact") {
PixelWorld source(8101);
PixelReplayLog log;
for (std::uint64_t tick = 1; tick <= 80; ++tick) {
if (tick == 1 || tick == 20 || tick == 45) {
PixelEditCommand command;
command.sequence = tick == 1 ? 1 : tick == 20 ? 2 : 3;
command.kind = PixelEditKind::PaintCircle;
command.centerX = int(tick) - 30;
command.centerY = 5;
command.radius = 8;
command.material = tick == 45 ? MaterialId::Water : MaterialId::Sand;
log.append({eve::SimulationTick(tick), command}).expect("append replay command");
source.applyEdit(command).expect("apply source command");
}
source.advance(eve::SimulationTick(tick)).expect("advance source");
if (tick % 20 == 0) log.captureCheckpoint(source).expect("capture checkpoint");
}
PixelWorld replayed(8101);
const auto receipt = log.replay(replayed, eve::SimulationTick(80)).expect("replay log");
CHECK_EQ(int(receipt.match), int(PixelReplayMatch::Matched));
CHECK_EQ(receipt.commandsApplied, std::uint64_t(3));
const auto expected = source.saveSnapshot().expect("source snapshot");
const auto actual = replayed.saveSnapshot().expect("replayed snapshot");
REQUIRE_EQ(expected.size(), actual.size());
CHECK(std::equal(expected.begin(), expected.end(), actual.begin()));
}
TEST_CASE("pixelworld_replay_reports_first_tick_and_chunk_divergence") {
PixelWorld source(8102);
PixelReplayLog log;
source.paintCircle(0, 0, 5, "stone"); // Deliberately absent from the command log.
for (std::uint64_t tick = 1; tick <= 10; ++tick) source.advance(eve::SimulationTick(tick)).expect("step");
log.captureCheckpoint(source).expect("checkpoint");
PixelWorld missingInput(8102);
const auto receipt = log.replay(missingInput, eve::SimulationTick(10)).expect("divergent replay");
CHECK_EQ(int(receipt.match), int(PixelReplayMatch::Diverged));
REQUIRE(receipt.firstDivergence.has_value());
CHECK_EQ(receipt.firstDivergence->tick.value(), std::uint64_t(10));
const bool hasChunkDiagnostic = receipt.firstDivergence->expectedChunk.has_value() ||
receipt.firstDivergence->actualChunk.has_value();
CHECK(hasChunkDiagnostic);
}
TEST_CASE("pixelworld_replay_chunk_digest_has_canonical_little_endian_encoding") {
PixelWorld world(8104);
world.setMaterial(-1, 0, "stone");
world.advance(eve::SimulationTick(1)).expect("advance world");
PixelReplayLog log;
log.captureCheckpoint(world).expect("capture checkpoint");
REQUIRE_EQ(log.checkpoints().size(), std::size_t(1));
REQUIRE_EQ(log.checkpoints().front().chunks.size(), std::size_t(1));
CHECK_EQ(log.checkpoints().front().chunks.front().digest, std::uint64_t(17357935190315814178ULL));
}
TEST_CASE("pixelworld_replay_rejects_sequence_tick_and_nonempty_target_without_log_mutation") {
PixelReplayLog log;
PixelEditCommand command;
command.sequence = 2;
auto gap = log.append({eve::SimulationTick(1), command});
CHECK(!gap.ok());
CHECK(log.entries().empty());
command.sequence = 1;
log.append({eve::SimulationTick(2), command}).expect("first entry");
command.sequence = 2;
auto backwards = log.append({eve::SimulationTick(1), command});
CHECK(!backwards.ok());
CHECK_EQ(log.entries().size(), std::size_t(1));
PixelWorld nonempty(8103);
nonempty.setMaterial(0, 0, "stone");
const auto before = nonempty.saveSnapshot().expect("before rejection");
auto rejected = log.replay(nonempty, eve::SimulationTick(2));
CHECK(!rejected.ok());
const auto after = nonempty.saveSnapshot().expect("after rejection");
REQUIRE_EQ(before.size(), after.size());
CHECK(std::equal(before.begin(), before.end(), after.begin()));
}
TEST_CASE("pixelworld_replay_snapshot_v2_round_trip_preserves_bit_exact_replay") {
const auto hash = replayHashProvider();
PixelWorld source(8201);
PixelReplayLog original;
PixelEditCommand command;
command.sequence = 1;
command.kind = PixelEditKind::PaintCircle;
command.centerX = -9;
command.centerY = 2;
command.radius = 4;
command.material = MaterialId::Water;
original.append({eve::SimulationTick(1), command}).expect("append");
source.applyEdit(command).expect("apply");
for (std::uint64_t tick = 1; tick <= 6; ++tick) {
source.advance(eve::SimulationTick(tick)).expect("advance");
if (tick == 3 || tick == 6) original.captureCheckpoint(source).expect("checkpoint");
}
const auto sealed = original.snapshot(eve::PersistentId::nil(), hash).expect("seal replay");
CHECK_EQ(sealed.schemaVersion.value(), std::uint64_t(2));
const auto json = eve::serializeSnapshotEnvelope(sealed).expect("serialize replay");
const auto parsed = eve::parseSnapshotEnvelope(json, hash).expect("parse replay");
PixelReplayLog restored;
restored.restoreSnapshot(parsed, hash).expect("restore replay");
CHECK_EQ(restored.entries().size(), original.entries().size());
CHECK_EQ(restored.checkpoints().size(), original.checkpoints().size());
CHECK_EQ(restored.checkpoints().back().chunks, original.checkpoints().back().chunks);
PixelWorld replayed(8201);
const auto receipt = restored.replay(replayed, eve::SimulationTick(6)).expect("replay restored log");
CHECK_EQ(int(receipt.match), int(PixelReplayMatch::Matched));
}
TEST_CASE("pixelworld_replay_migrates_v1_checkpoint_without_chunk_diagnostics") {
const auto hash = replayHashProvider();
const auto legacy = eve::makeSnapshotEnvelope(
"pixelworld.replay-log", replaySchema(), eve::SchemaVersion(1),
eve::PersistentId::nil(), eve::Revision(2), eve::SimulationTick(2),
versionOnePayload(), hash)
.expect("seal v1 replay");
PixelReplayLog restored;
restored.restoreSnapshot(legacy, hash).expect("restore v1 replay");
REQUIRE_EQ(restored.entries().size(), std::size_t(1));
REQUIRE_EQ(restored.checkpoints().size(), std::size_t(1));
CHECK(restored.checkpoints().front().chunks.empty());
const auto upgraded = restored.snapshot(eve::PersistentId::nil(), hash).expect("upgrade replay");
CHECK_EQ(upgraded.schemaVersion.value(), std::uint64_t(2));
}
TEST_CASE("pixelworld_replay_bad_envelopes_leave_existing_log_unchanged") {
const auto hash = replayHashProvider();
PixelReplayLog existing;
PixelEditCommand command;
command.sequence = 1;
existing.append({eve::SimulationTick(1), command}).expect("append existing");
const auto before = existing.snapshot(eve::PersistentId::nil(), hash).expect("before");
auto badHash = before;
badHash.contentHash = eve::ContentId::nil();
CHECK(!existing.restoreSnapshot(badHash, hash).ok());
CHECK_EQ(existing.entries().size(), std::size_t(1));
auto unknown = eve::makeSnapshotEnvelope(
"pixelworld.replay-log", replaySchema(), eve::SchemaVersion(3),
eve::PersistentId::nil(), before.revision, before.tick, before.payload, hash)
.expect("seal unknown version");
CHECK(!existing.restoreSnapshot(unknown, hash).ok());
CHECK_EQ(existing.entries().size(), std::size_t(1));
auto malformedPayload = before.payload;
malformedPayload.getIf<eve::Value::Object>()->emplace("unknown", eve::Value(true));
auto malformed = eve::makeSnapshotEnvelope(
"pixelworld.replay-log", replaySchema(), eve::SchemaVersion(2),
eve::PersistentId::nil(), before.revision, before.tick,
std::move(malformedPayload), hash)
.expect("seal malformed payload");
CHECK(!existing.restoreSnapshot(malformed, hash).ok());
CHECK_EQ(existing.entries().size(), std::size_t(1));
std::string truncated = eve::serializeSnapshotEnvelope(before).expect("serialize before");
truncated.resize(truncated.size() / 2);
CHECK(!eve::parseSnapshotEnvelope(truncated, hash).ok());
CHECK_EQ(existing.entries().size(), std::size_t(1));
}
+17
View File
@@ -0,0 +1,17 @@
#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include "pixelworld/PixelWorldModule.h"
using namespace eve::pixelworld;
TEST_CASE("pixelworld.module.creates_seeded_world") {
auto* module = PixelWorldModule::create();
REQUIRE(module != nullptr);
CHECK_EQ(module->getName(), std::string("PixelWorldModule"));
PixelWorld* world = module->newWorld(42);
REQUIRE(world != nullptr);
CHECK_EQ(world->seed(), std::uint64_t(42));
CHECK_EQ(world->chunkCount(), 0);
delete world;
}
+307
View File
@@ -0,0 +1,307 @@
#include "zeroerr/assert.h"
#include "zeroerr/unittest.h"
#include "pixelworld/PixelWorld.h"
#include "pixelworld_streaming/PixelWorldStreaming.h"
#include "pixelworld_streaming/PixelWorldTransport.h"
#include <algorithm>
#include <cstdint>
#include <string_view>
using namespace eve::pixelworld;
using namespace eve::pixelworld_streaming;
namespace {
eve::SnapshotHashProvider archiveHashProvider() {
return [](std::string_view input) -> eve::Result<eve::ContentId> {
std::uint64_t left = 14695981039346656037ULL;
std::uint64_t right = 1099511628211ULL;
for (const unsigned char byte : input) {
left = (left ^ byte) * 1099511628211ULL;
right = (right ^ (std::uint64_t(byte) + 0x9e3779b97f4a7c15ULL)) * 14029467366897019727ULL;
}
eve::ContentId::Bytes bytes{};
for (int index = 0; index < 8; ++index) {
bytes[std::size_t(index)] = std::uint8_t(left >> (56 - index * 8));
bytes[std::size_t(index + 8)] = std::uint8_t(right >> (56 - index * 8));
}
return eve::Result<eve::ContentId>::success(eve::ContentId(bytes));
};
}
eve::LogicalId chunkArchiveSchema() {
return *eve::LogicalId::parse("pixelworld:chunk-batch");
}
} // namespace
TEST_CASE("pixelworld_streaming_interest_move_emits_entry_and_eviction_correction") {
PixelWorld authority(8301);
authority.setMaterial(1, 1, "stone");
authority.setMaterial(2 * kPixelChunkSize + 1, 1, "water");
PixelChunkStreamCursor cursor;
auto first = cursor.capture(authority, {0, 0, 0, 0}).expect("capture first interest");
CHECK(first.fullResync);
REQUIRE_EQ(first.batch.chunks.size(), std::size_t(1));
PixelWorld replica(8301);
replica.applyChunkBatch(first.batch, 0).expect("apply first interest");
CHECK_EQ(replica.getMaterial(1, 1), int(MaterialId::Stone));
CHECK_EQ(replica.getMaterial(2 * kPixelChunkSize + 1, 1), int(MaterialId::Air));
auto moved = cursor.capture(authority, {2, 0, 2, 0}).expect("move interest");
CHECK(!moved.fullResync);
CHECK_EQ(moved.chunksEntered, std::uint32_t(1));
CHECK_EQ(moved.chunksEvicted, std::uint32_t(1));
REQUIRE_EQ(moved.batch.chunks.size(), std::size_t(2));
replica.applyChunkBatch(moved.batch, replica.revision()).expect("apply moved interest");
CHECK_EQ(replica.getMaterial(1, 1), int(MaterialId::Air));
CHECK_EQ(replica.getMaterial(2 * kPixelChunkSize + 1, 1), int(MaterialId::Water));
const auto unchanged = cursor.capture(authority, {2, 0, 2, 0}).expect("unchanged interest");
CHECK(unchanged.batch.chunks.empty());
}
TEST_CASE("pixelworld_streaming_disconnect_reconnect_applies_authoritative_chunk_delta") {
PixelWorld authority(8302);
authority.setMaterial(4, 4, "wood");
PixelChunkStreamCursor cursor;
auto initial = cursor.capture(authority, {0, 0, 0, 0}).expect("initial sync");
PixelWorld replica(8302);
replica.applyChunkBatch(initial.batch, 0).expect("apply initial sync");
authority.setMaterial(4, 4, "fire");
authority.setMaterial(5, 4, "oil");
auto reconnect = cursor.capture(authority, {0, 0, 0, 0}).expect("reconnect delta");
REQUIRE_EQ(reconnect.batch.chunks.size(), std::size_t(1));
replica.applyChunkBatch(reconnect.batch, replica.revision()).expect("apply reconnect correction");
CHECK_EQ(replica.getCell(4, 4), authority.getCell(4, 4));
CHECK_EQ(replica.getCell(5, 4), authority.getCell(5, 4));
CHECK_EQ(replica.revision(), authority.revision());
}
TEST_CASE("pixelworld_streaming_epoch_change_forces_full_resync_and_stale_writer_rejects") {
PixelWorld authority(8303);
authority.setMaterial(3, 3, "sand");
const auto saved = authority.saveSnapshot().expect("save authority");
PixelChunkStreamCursor cursor;
auto initial = cursor.capture(authority, {0, 0, 0, 0}).expect("initial");
CHECK(initial.fullResync);
authority.restoreSnapshot(saved).expect("restore changes epoch");
auto rebuilt = cursor.capture(authority, {0, 0, 0, 0}).expect("full rebuild");
CHECK(rebuilt.fullResync);
REQUIRE_EQ(rebuilt.batch.chunks.size(), std::size_t(1));
PixelWorld replica(8303);
replica.setMaterial(10, 10, "stone");
const auto before = replica.saveSnapshot().expect("before stale correction");
CHECK(!replica.applyChunkBatch(rebuilt.batch, 0).ok());
const auto after = replica.saveSnapshot().expect("after stale correction");
REQUIRE_EQ(before.size(), after.size());
CHECK(std::equal(before.begin(), before.end(), after.begin()));
}
TEST_CASE("pixelworld_streaming_full_resync_atomically_clears_projection_and_accepts_rewind") {
PixelWorld authority(8304);
authority.setMaterial(3, 3, "stone");
PixelChunkStreamCursor cursor;
const auto populated = cursor.capture(authority, {0, 0, 0, 0}).expect("populated sync");
REQUIRE(populated.batch.fullResync);
PixelWorld replica(8304);
replica.applyChunkBatch(populated.batch, 0).expect("apply populated projection");
CHECK_EQ(replica.getMaterial(3, 3), int(MaterialId::Stone));
PixelWorld emptyAuthority(8304);
const auto emptySnapshot = emptyAuthority.saveSnapshot().expect("empty authority snapshot");
authority.restoreSnapshot(emptySnapshot).expect("rewind authority to empty epoch");
const auto reset = cursor.capture(authority, {0, 0, 0, 0}).expect("rewound full sync");
CHECK(reset.fullResync);
CHECK(reset.batch.fullResync);
CHECK_EQ(reset.batch.sourceRevision, std::uint64_t(0));
CHECK(reset.batch.chunks.empty());
replica.applyChunkBatch(reset.batch, replica.revision()).expect("replace projection after rewind");
CHECK_EQ(replica.revision(), std::uint64_t(0));
CHECK_EQ(replica.chunkCount(), std::size_t(0));
CHECK_EQ(replica.getMaterial(3, 3), int(MaterialId::Air));
}
TEST_CASE("pixelworld_streaming_zstd_chunk_archive_round_trips_and_is_smaller_than_raw") {
const auto hash = archiveHashProvider();
PixelWorld authority(8401);
for (int y = 0; y < kPixelChunkSize; ++y)
for (int x = 0; x < kPixelChunkSize; ++x)
authority.setCell(x, y, {MaterialId::Stone, 20, 0});
authority.setCell(17, 23, {MaterialId::Stone, 20, 0, 73});
PixelChunkStreamCursor cursor;
const auto update = cursor.capture(authority, {0, 0, 0, 0}).expect("capture archive batch");
const auto compressed = archiveChunkBatch(update.batch, eve::PersistentId::nil(),
PixelChunkArchiveCodec::Zstd, hash)
.expect("compress archive");
const auto raw = archiveChunkBatch(update.batch, eve::PersistentId::nil(),
PixelChunkArchiveCodec::None, hash)
.expect("raw archive");
const auto compressedJson = eve::serializeSnapshotEnvelope(compressed).expect("serialize compressed");
const auto rawJson = eve::serializeSnapshotEnvelope(raw).expect("serialize raw");
CHECK(compressedJson.size() < rawJson.size() / 4);
const auto parsed = eve::parseSnapshotEnvelope(compressedJson, hash).expect("parse compressed");
const auto decoded = decodeChunkBatchArchive(parsed, hash).expect("decode archive");
PixelWorld replica(8401);
replica.applyChunkBatch(decoded, 0).expect("apply decoded archive");
CHECK_EQ(replica.getCell(17, 23), authority.getCell(17, 23));
CHECK_EQ(replica.getCell(17, 23).thermalRemainder, std::uint16_t(73));
CHECK_EQ(replica.revision(), authority.revision());
}
TEST_CASE("pixelworld_streaming_chunk_archive_v1_migrates_and_corruption_is_isolated") {
const auto hash = archiveHashProvider();
PixelWorld authority(8402);
authority.setMaterial(3, 4, "water");
PixelChunkStreamCursor cursor;
const auto update = cursor.capture(authority, {0, 0, 0, 0}).expect("capture");
const auto current = archiveChunkBatch(update.batch, eve::PersistentId::nil(),
PixelChunkArchiveCodec::Zstd, hash)
.expect("archive");
auto legacyPayload = current.payload;
legacyPayload.getIf<eve::Value::Object>()->erase("sourceLastEditSequence");
legacyPayload.getIf<eve::Value::Object>()->erase("fullResync");
const auto legacy = eve::makeSnapshotEnvelope(
"pixelworld.chunk-batch", chunkArchiveSchema(), eve::SchemaVersion(1),
eve::PersistentId::nil(), current.revision, current.tick,
std::move(legacyPayload), hash)
.expect("seal legacy");
const auto migrated = decodeChunkBatchArchive(legacy, hash).expect("decode legacy");
CHECK_EQ(migrated.sourceLastEditSequence, std::uint64_t(0));
REQUIRE_EQ(migrated.chunks.size(), std::size_t(1));
auto corruptPayload = current.payload;
auto& chunks = *corruptPayload.getIf<eve::Value::Object>()->at("chunks").getIf<eve::Value::Array>();
auto& stored = *chunks.front().getIf<eve::Value::Object>()->at("stored").getIf<std::string>();
REQUIRE(!stored.empty());
stored[0] = stored[0] == '0' ? '1' : '0';
const auto resealedCorrupt = eve::makeSnapshotEnvelope(
"pixelworld.chunk-batch", chunkArchiveSchema(), eve::SchemaVersion(2),
eve::PersistentId::nil(), current.revision, current.tick,
std::move(corruptPayload), hash)
.expect("seal corrupt physical payload");
PixelWorld untouched(8402);
untouched.setMaterial(9, 9, "wood");
const auto before = untouched.saveSnapshot().expect("before corrupt decode");
CHECK(!decodeChunkBatchArchive(resealedCorrupt, hash).ok());
const auto after = untouched.saveSnapshot().expect("after corrupt decode");
CHECK(std::equal(before.begin(), before.end(), after.begin()));
auto badOuterHash = current;
badOuterHash.contentHash = eve::ContentId::nil();
CHECK(!decodeChunkBatchArchive(badOuterHash, hash).ok());
}
TEST_CASE("pixelworld_transport_retransmits_loss_and_commits_complete_transfer_atomically") {
PixelWorld authority(8501);
authority.setMaterial(1, 1, "stone");
authority.setMaterial(kPixelChunkSize + 1, 1, "water");
authority.setMaterial(2 * kPixelChunkSize + 1, 1, "wood");
auto sender = ReliablePixelChunkSender::create(91).expect("create sender");
auto receiver = ReliablePixelChunkReceiver::create(91).expect("create receiver");
const auto parts = sender.capture(authority, {0, 0, 2, 0}).expect("capture transfer");
REQUIRE_EQ(parts.size(), std::size_t(3));
PixelWorld replica(8501);
CHECK_EQ(receiver.receive(parts[2], replica).expect("part 2").transfersApplied,
std::uint32_t(0));
CHECK_EQ(receiver.receive(parts[0], replica).expect("part 0").transfersApplied,
std::uint32_t(0));
CHECK(receiver.receive(parts[2], replica).expect("duplicate part 2").duplicate);
CHECK_EQ(replica.chunkCount(), std::size_t(0));
REQUIRE_EQ(sender.pendingParts().size(), std::size_t(3));
const auto completed = receiver.receive(sender.pendingParts()[1], replica)
.expect("retransmitted missing part");
CHECK_EQ(completed.transfersApplied, std::uint32_t(1));
CHECK_EQ(completed.ack.acknowledgedThrough, std::uint64_t(1));
CHECK_EQ(replica.getCell(2 * kPixelChunkSize + 1, 1),
authority.getCell(2 * kPixelChunkSize + 1, 1));
const auto released = sender.acknowledge(completed.ack).expect("application ACK");
CHECK_EQ(released.transfersReleased, std::uint32_t(1));
CHECK_EQ(sender.inFlightTransferCount(), std::size_t(0));
}
TEST_CASE("pixelworld_transport_buffers_transfer_order_and_retains_tombstones_until_ACK") {
PixelWorld authority(8502);
authority.setMaterial(1, 1, "stone");
authority.setMaterial(kPixelChunkSize + 1, 1, "water");
auto sender = ReliablePixelChunkSender::create(92).expect("create sender");
auto receiver = ReliablePixelChunkReceiver::create(92).expect("create receiver");
PixelWorld replica(8502);
const auto initial = sender.capture(authority, {0, 0, 1, 0}).expect("initial");
PixelChunkReceiveReceipt receipt;
for (const auto& part : initial)
receipt = receiver.receive(part, replica).expect("receive initial");
sender.acknowledge(receipt.ack).expect("ACK initial");
authority.setMaterial(2, 2, "oil");
const auto second = sender.capture(authority, {0, 0, 1, 0}).expect("second");
authority.setMaterial(kPixelChunkSize + 2, 2, "wood");
const auto third = sender.capture(authority, {0, 0, 1, 0}).expect("third");
REQUIRE_EQ(second.size(), std::size_t(1));
REQUIRE_EQ(third.size(), std::size_t(1));
CHECK_EQ(receiver.receive(third.front(), replica).expect("third early").transfersApplied,
std::uint32_t(0));
const auto ordered = receiver.receive(second.front(), replica).expect("fill transfer gap");
CHECK_EQ(ordered.transfersApplied, std::uint32_t(2));
CHECK_EQ(ordered.ack.acknowledgedThrough, std::uint64_t(3));
CHECK_EQ(replica.getCell(kPixelChunkSize + 2, 2),
authority.getCell(kPixelChunkSize + 2, 2));
sender.acknowledge(ordered.ack).expect("ACK ordered transfers");
const auto evicted = sender.capture(authority, {0, 0, 0, 0}).expect("evict interest");
REQUIRE_EQ(evicted.size(), std::size_t(1));
CHECK(evicted.front().batch.chunks.front().removed);
CHECK_EQ(sender.retainedTombstoneCount(), std::size_t(1));
const auto tombstone = receiver.receive(evicted.front(), replica).expect("apply tombstone");
CHECK_EQ(replica.getMaterial(kPixelChunkSize + 1, 1), int(MaterialId::Air));
sender.acknowledge(tombstone.ack).expect("ACK tombstone");
CHECK_EQ(sender.retainedTombstoneCount(), std::size_t(0));
CHECK_EQ(sender.acknowledge(tombstone.ack).expect("duplicate ACK").transfersReleased,
std::uint32_t(0));
}
TEST_CASE("pixelworld_transport_window_failure_does_not_advance_capture_cursor") {
PixelChunkTransportConfig config;
config.maximumInFlightTransfers = 1;
PixelWorld authority(8503);
authority.setMaterial(1, 1, "stone");
auto sender = ReliablePixelChunkSender::create(93, config).expect("create sender");
auto receiver = ReliablePixelChunkReceiver::create(93, config).expect("create receiver");
PixelWorld replica(8503);
const auto initial = sender.capture(authority, {0, 0, 0, 0}).expect("initial");
authority.setMaterial(2, 2, "water");
CHECK(!sender.capture(authority, {0, 0, 0, 0}).ok());
const auto first = receiver.receive(initial.front(), replica).expect("receive initial");
sender.acknowledge(first.ack).expect("free window");
const auto retry = sender.capture(authority, {0, 0, 0, 0}).expect("retry capture");
REQUIRE_EQ(retry.size(), std::size_t(1));
CHECK_EQ(retry.front().batch.sourceRevision, authority.revision());
CHECK_EQ(int(retry.front().batch.chunks.front().cells[2 * kPixelChunkSize + 2].material),
int(MaterialId::Water));
}
TEST_CASE("pixelworld_transport_rejects_conflicting_duplicate_and_invalid_ACK") {
PixelWorld authority(8504);
authority.setMaterial(1, 1, "stone");
authority.setMaterial(kPixelChunkSize + 1, 1, "water");
auto sender = ReliablePixelChunkSender::create(94).expect("create sender");
auto receiver = ReliablePixelChunkReceiver::create(94).expect("create receiver");
const auto parts = sender.capture(authority, {0, 0, 1, 0}).expect("capture");
REQUIRE_EQ(parts.size(), std::size_t(2));
PixelWorld replica(8504);
receiver.receive(parts.front(), replica).expect("receive partial");
auto conflict = parts.front();
conflict.batch.sourceRevision += 1;
CHECK(!receiver.receive(std::move(conflict), replica).ok());
CHECK(!sender.acknowledge({94, 1, authority.revision() + 1}).ok());
CHECK_EQ(sender.inFlightTransferCount(), std::size_t(1));
}