diff --git a/CMakeLists.txt b/CMakeLists.txt index 4e3b1da1..ae90e394 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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() diff --git a/benchmarks/pixelworld/CMakeLists.txt b/benchmarks/pixelworld/CMakeLists.txt new file mode 100644 index 00000000..8a72de92 --- /dev/null +++ b/benchmarks/pixelworld/CMakeLists.txt @@ -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() diff --git a/benchmarks/pixelworld/main.cpp b/benchmarks/pixelworld/main.cpp new file mode 100644 index 00000000..57ddfefd --- /dev/null +++ b/benchmarks/pixelworld/main.cpp @@ -0,0 +1,187 @@ +#include "pixelworld/PixelWorld.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +class BenchmarkScheduler final : public eve::pixelworld::PixelWorkScheduler { +public: + explicit BenchmarkScheduler(std::size_t workerCount) + : workerCount_(std::max(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& 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* 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 workers_; + std::mutex mutex_; + std::condition_variable wake_; + std::condition_variable complete_; + const std::function* body_ = nullptr; + std::atomic 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 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 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( + 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; +} diff --git a/cmake/module_manifest/core_foundation.cmake b/cmake/module_manifest/core_foundation.cmake index f823d5aa..3ba1bc91 100644 --- a/cmake/module_manifest/core_foundation.cmake +++ b/cmake/module_manifest/core_foundation.cmake @@ -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) diff --git a/cmake/module_manifest/rendering_simulation.cmake b/cmake/module_manifest/rendering_simulation.cmake index 8f473cc5..b3c8874d 100644 --- a/cmake/module_manifest/rendering_simulation.cmake +++ b/cmake/module_manifest/rendering_simulation.cmake @@ -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 diff --git a/cmake/modules.cmake b/cmake/modules.cmake index 7004de3f..3ea0558b 100644 --- a/cmake/modules.cmake +++ b/cmake/modules.cmake @@ -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() diff --git a/docs/CMakeLists.txt b/docs/CMakeLists.txt index 35ee0c37..445d0eb2 100644 --- a/docs/CMakeLists.txt +++ b/docs/CMakeLists.txt @@ -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|程序化生成模块" diff --git a/docs/dev/PixelWorld-Noita技术路线.md b/docs/dev/PixelWorld-Noita技术路线.md new file mode 100644 index 00000000..5ccc3090 --- /dev/null +++ b/docs/dev/PixelWorld-Noita技术路线.md @@ -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` 的私有观察 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,但增量按需生成 +调度与生成器接入示例/编辑器仍需完成,不能据现有演示宣称完整路线已交付。 diff --git a/docs/dox/usr-pages.dox b/docs/dox/usr-pages.dox index f3071c89..2c6ad24b 100644 --- a/docs/dox/usr-pages.dox +++ b/docs/dox/usr-pages.dox @@ -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 diff --git a/docs/usr/MODULES.md b/docs/usr/MODULES.md index 4db75ef4..0f4ec393 100644 --- a/docs/usr/MODULES.md +++ b/docs/usr/MODULES.md @@ -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):组合属性、效果、状态、技能、施法与伤害结算。 diff --git a/docs/usr/modules/graphics.md b/docs/usr/modules/graphics.md index 3c18c0ff..ffea88c3 100644 --- a/docs/usr/modules/graphics.md +++ b/docs/usr/modules/graphics.md @@ -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()` diff --git a/docs/usr/modules/pixelworld.md b/docs/usr/modules/pixelworld.md new file mode 100644 index 00000000..1a0af45b --- /dev/null +++ b/docs/usr/modules/pixelworld.md @@ -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)。 diff --git a/docs/usr/modules/pixelworld_editor.md b/docs/usr/modules/pixelworld_editor.md new file mode 100644 index 00000000..6fe42e7a --- /dev/null +++ b/docs/usr/modules/pixelworld_editor.md @@ -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)。 diff --git a/docs/usr/modules/pixelworld_graphics.md b/docs/usr/modules/pixelworld_graphics.md new file mode 100644 index 00000000..24319f85 --- /dev/null +++ b/docs/usr/modules/pixelworld_graphics.md @@ -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()`。 diff --git a/docs/usr/modules/pixelworld_physics.md b/docs/usr/modules/pixelworld_physics.md new file mode 100644 index 00000000..0333385b --- /dev/null +++ b/docs/usr/modules/pixelworld_physics.md @@ -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. diff --git a/docs/usr/modules/pixelworld_replay.md b/docs/usr/modules/pixelworld_replay.md new file mode 100644 index 00000000..2067797b --- /dev/null +++ b/docs/usr/modules/pixelworld_replay.md @@ -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)。 diff --git a/docs/usr/modules/pixelworld_streaming.md b/docs/usr/modules/pixelworld_streaming.md new file mode 100644 index 00000000..13c94f49 --- /dev/null +++ b/docs/usr/modules/pixelworld_streaming.md @@ -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)。 diff --git a/docs/usr/modules/pixelworld_thread.md b/docs/usr/modules/pixelworld_thread.md new file mode 100644 index 00000000..42aff7df --- /dev/null +++ b/docs/usr/modules/pixelworld_thread.md @@ -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. diff --git a/examples/README.md b/examples/README.md index 78d265bf..da25b39b 100644 --- a/examples/README.md +++ b/examples/README.md @@ -36,6 +36,7 @@ make run/-debug GAME=examples/ | [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 | diff --git a/examples/pixelworld/README.md b/examples/pixelworld/README.md new file mode 100644 index 00000000..82b91915 --- /dev/null +++ b/examples/pixelworld/README.md @@ -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`,不存在与材料状态可能失同步的第二套破坏遮罩。 diff --git a/examples/pixelworld/config.nut b/examples/pixelworld/config.nut new file mode 100644 index 00000000..2665bdc5 --- /dev/null +++ b/examples/pixelworld/config.nut @@ -0,0 +1,7 @@ +config = { + width = 960 + height = 576 + title = "EVEngine PixelWorld" + debug = true + hotReload = true +}; diff --git a/examples/pixelworld/eve_snapshot.json b/examples/pixelworld/eve_snapshot.json new file mode 100644 index 00000000..94ad5fa1 --- /dev/null +++ b/examples/pixelworld/eve_snapshot.json @@ -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,"layer":0,"name":"database","optionalDeps":[],"profiles":[],"required":false,"slots":[]},{"classes":[],"deps":["pixelworld","thread"],"enabled":true,"layer":1,"name":"pixelworld_thread","optionalDeps":[],"profiles":["2d","3d","web"],"required":false,"slots":[]},{"classes":[],"deps":["pixelworld"],"enabled":true,"layer":1,"name":"pixelworld_replay","optionalDeps":[],"profiles":["2d","3d","web"],"required":false,"slots":[]},{"classes":[],"deps":["asset","pixelworld"],"enabled":true,"layer":1,"name":"pixelworld_streaming","optionalDeps":[],"profiles":["2d","3d","web"],"required":false,"slots":[]},{"classes":["PixelWorldEditorModule"],"deps":["pixelworld","ui"],"enabled":true,"layer":6,"name":"pixelworld_editor","optionalDeps":[],"profiles":["2d","3d","web"],"required":false,"slots":["pixelworldEditor"]},{"classes":["RPG"],"deps":["action","attributes","decision","definitions","effects","inventory","settlement"],"enabled":true,"layer":1,"name":"rpg","optionalDeps":[],"profiles":[],"required":false,"slots":[]},{"classes":["Inventory"],"deps":[],"enabled":true,"layer":0,"name":"inventory","optionalDeps":[],"profiles":[],"required":false,"slots":[]},{"classes":["Economy"],"deps":[],"enabled":true,"layer":0,"name":"economy","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":["economy"]},{"classes":["Attributes"],"deps":[],"enabled":true,"layer":0,"name":"attributes","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":["attributes"]},{"classes":["Authority"],"deps":[],"enabled":true,"layer":0,"name":"authority","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":["authority"]},{"classes":["Decision"],"deps":[],"enabled":true,"layer":0,"name":"decision","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":["decision"]},{"classes":["Definitions"],"deps":["schema"],"enabled":true,"layer":0,"name":"definitions","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":["definitions"]},{"classes":["Effects"],"deps":["definitions"],"enabled":true,"layer":0,"name":"effects","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":["effects"]},{"classes":["GameEvent"],"deps":["schema"],"enabled":true,"layer":0,"name":"game_event","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":["game_event"]},{"classes":[],"deps":["game_event"],"enabled":true,"layer":0,"name":"settlement","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":[]},{"classes":["Orders"],"deps":[],"enabled":true,"layer":0,"name":"orders","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":["orders"]},{"classes":["PolicyRegistryModule"],"deps":["schema"],"enabled":true,"layer":0,"name":"policyregistry","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":["policies"]},{"classes":["Production"],"deps":[],"enabled":true,"layer":0,"name":"production","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":["production"]},{"classes":["Sensing"],"deps":["spatial"],"enabled":true,"layer":1,"name":"sensing","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":["sensing"]},{"classes":[],"deps":[],"enabled":true,"layer":1,"name":"npc_ai","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":[]},{"classes":[],"deps":["decision","sensing","tags","transaction"],"enabled":true,"layer":1,"name":"action","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":[]},{"classes":[],"deps":["action","attributes","tags"],"enabled":true,"layer":2,"name":"combat","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":[]},{"classes":["Schema"],"deps":[],"enabled":true,"layer":0,"name":"schema","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":["schema"]},{"classes":["Social"],"deps":[],"enabled":true,"layer":0,"name":"social","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":["social"]},{"classes":["StatePatch"],"deps":["transaction"],"enabled":true,"layer":0,"name":"statepatch","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":["statepatch"]},{"classes":["Steering"],"deps":[],"enabled":true,"layer":0,"name":"steering","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":["steering"]},{"classes":["Tags"],"deps":[],"enabled":true,"layer":0,"name":"tags","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":["tags"]},{"classes":["Transaction"],"deps":[],"enabled":true,"layer":0,"name":"transaction","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":["transaction"]},{"classes":["Building"],"deps":["grid","data","game_event"],"enabled":true,"layer":4,"name":"building","optionalDeps":[],"profiles":[],"required":false,"slots":[]},{"classes":["Window"],"deps":["platform_event"],"enabled":true,"layer":1,"name":"window","optionalDeps":[],"profiles":[],"required":true,"slots":["win"]},{"classes":["Image"],"deps":["filesystem"],"enabled":true,"layer":1,"name":"image","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":[]},{"classes":[],"deps":["asset","data","cmdline"],"enabled":true,"layer":1,"name":"asset_import","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":[]},{"classes":["I18n"],"deps":["filesystem"],"enabled":true,"layer":1,"name":"i18n","optionalDeps":[],"profiles":["2d","3d","web"],"required":false,"slots":["i18n"]},{"classes":["Rx"],"deps":["platform_event"],"enabled":true,"layer":1,"name":"rx","optionalDeps":[],"profiles":["2d","3d","web"],"required":false,"slots":[]},{"classes":["Joystick"],"deps":["platform_event"],"enabled":true,"layer":1,"name":"joystick","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":[]},{"classes":["Model3D"],"deps":["filesystem"],"enabled":true,"layer":4,"name":"model3d","optionalDeps":[],"profiles":["3d"],"required":false,"slots":["model3d"]},{"classes":["Sound"],"deps":["filesystem"],"enabled":true,"layer":1,"name":"sound","optionalDeps":[],"profiles":["2d","3d"],"required":false,"slots":["sound"]},{"classes":["Network"],"deps":["data","platform_event"],"enabled":true,"layer":1,"name":"network","optionalDeps":[],"profiles":[],"required":false,"slots":[]},{"classes":[],"deps":["editing"],"enabled":true,"layer":2,"name":"scene_editing","optionalDeps":[],"profiles":["3d","web"],"required":false,"slots":[]},{"classes":[],"deps":["editing"],"enabled":true,"layer":2,"name":"material_editing","optionalDeps":[],"profiles":["3d","web"],"required":false,"slots":[]},{"classes":[],"deps":["asset","scene"],"enabled":true,"layer":2,"name":"asset_scene","optionalDeps":[],"profiles":["3d","web"],"required":false,"slots":[]},{"classes":["Keyboard"],"deps":["platform_event","window"],"enabled":true,"layer":2,"name":"keyboard","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":["keyboard"]},{"classes":["Mouse"],"deps":["window"],"enabled":true,"layer":2,"name":"mouse","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":["mouse"]},{"classes":["Touch"],"deps":["platform_event","window"],"enabled":true,"layer":2,"name":"touch","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":["touch"]},{"classes":["Audio"],"deps":["platform_event","sound"],"enabled":true,"layer":2,"name":"audio","optionalDeps":["scene"],"profiles":["2d","3d"],"required":false,"slots":["audio"]},{"classes":["Font"],"deps":["filesystem","image"],"enabled":true,"layer":2,"name":"font","optionalDeps":[],"profiles":["2d","3d","web"],"required":false,"slots":["font"]},{"classes":["Graphics"],"deps":["data","filesystem","image","thread"],"enabled":true,"layer":3,"name":"graphics","optionalDeps":["font"],"profiles":[],"required":true,"slots":["gfx"]},{"classes":["PixelWorldGraphics"],"deps":["graphics","pixelworld"],"enabled":true,"layer":4,"name":"pixelworld_graphics","optionalDeps":[],"profiles":["2d","3d","web"],"required":false,"slots":["pixelworldGraphics"]},{"classes":[],"deps":["asset","graphics"],"enabled":true,"layer":4,"name":"asset_graphics","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":[]},{"classes":["Camera"],"deps":["platform_event","graphics","scene"],"enabled":true,"layer":4,"name":"camera","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":["camera"]},{"classes":["Gpgpu"],"deps":["data","filesystem","graphics"],"enabled":true,"layer":4,"name":"gpgpu","optionalDeps":[],"profiles":["2d","3d","web"],"required":false,"slots":["gpgpu"]},{"classes":["UI"],"deps":["platform_event","filesystem","graphics","image","property_access","scriptmodel","timer","window"],"enabled":true,"layer":4,"name":"ui","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":["ui"]},{"classes":["Physics"],"deps":["platform_event","graphics","gpgpu","sensing"],"enabled":true,"layer":4,"name":"physics","optionalDeps":["scene"],"profiles":["2d","3d","web"],"required":false,"slots":["physics"]},{"classes":["Map"],"deps":["data","filesystem","graphics","grid"],"enabled":true,"layer":4,"name":"map","optionalDeps":[],"profiles":["2d","3d"],"required":false,"slots":["map"]},{"classes":["BuildingFx"],"deps":["building","graphics"],"enabled":true,"layer":4,"name":"buildingfx","optionalDeps":[],"profiles":[],"required":false,"slots":["buildingfx"]},{"classes":["Weapon"],"deps":["action","attributes","effects","transaction","definitions"],"enabled":true,"layer":4,"name":"weapon","optionalDeps":[],"profiles":["2d","3d"],"required":false,"slots":["weapon"]},{"classes":[],"deps":["pixelworld","physics"],"enabled":true,"layer":5,"name":"pixelworld_physics","optionalDeps":[],"profiles":["2d"],"required":false,"slots":[]},{"classes":[],"deps":["editing","physics"],"enabled":true,"layer":5,"name":"physics_editing","optionalDeps":[],"profiles":["3d","web"],"required":false,"slots":[]},{"classes":["Vehicle"],"deps":["attributes","definitions","effects","orders","weapon","settlement","game_event"],"enabled":true,"layer":5,"name":"vehicle","optionalDeps":["physics"],"profiles":["2d","3d"],"required":false,"slots":["vehicle"]},{"classes":["Animation"],"deps":["data","filesystem","graphics","image","model3d"],"enabled":true,"layer":4,"name":"animation","optionalDeps":[],"profiles":["2d","3d"],"required":false,"slots":["anim"]},{"classes":["DayNight"],"deps":["graphics"],"enabled":true,"layer":4,"name":"daynight","optionalDeps":[],"profiles":["3d","web"],"required":false,"slots":["daynight"]},{"classes":["Weather"],"deps":["graphics"],"enabled":true,"layer":4,"name":"weather","optionalDeps":[],"profiles":["3d","web"],"required":false,"slots":["weather"]},{"classes":["Decal"],"deps":["graphics"],"enabled":true,"layer":4,"name":"decal","optionalDeps":[],"profiles":["3d"],"required":false,"slots":["decal"]},{"classes":["Stylize"],"deps":["graphics","image"],"enabled":true,"layer":4,"name":"stylize","optionalDeps":[],"profiles":["3d"],"required":false,"slots":["stylize"]},{"classes":["Voxel"],"deps":["graphics","procgen","thread"],"enabled":true,"layer":5,"name":"voxel","optionalDeps":[],"profiles":["3d"],"required":false,"slots":[]},{"classes":["SpriteStack"],"deps":["graphics","image","model3d"],"enabled":true,"layer":4,"name":"spritestack","optionalDeps":[],"profiles":["2d"],"required":false,"slots":["spritestack"]},{"classes":["HouseGen"],"deps":["data","graphics","image","model3d"],"enabled":true,"layer":4,"name":"housegen","optionalDeps":[],"profiles":[],"required":false,"slots":[]},{"classes":["Card"],"deps":["attributes","decision","definitions","effects","graphics","transaction"],"enabled":true,"layer":4,"name":"card","optionalDeps":[],"profiles":[],"required":false,"slots":[]},{"classes":["Demo"],"deps":["graphics","sound"],"enabled":true,"layer":4,"name":"demo","optionalDeps":[],"profiles":[],"required":false,"slots":[]},{"classes":["Scene"],"deps":["spatial"],"enabled":true,"layer":1,"name":"scene","optionalDeps":[],"profiles":["3d","web"],"required":false,"slots":["scene"]},{"classes":["Particles"],"deps":["animation","data","filesystem","graphics","ik"],"enabled":true,"layer":5,"name":"particles","optionalDeps":[],"profiles":["2d","3d"],"required":false,"slots":["particles"]},{"classes":["Fluids"],"deps":["gpgpu","graphics","physics"],"enabled":true,"layer":5,"name":"fluids","optionalDeps":[],"profiles":["3d","web"],"required":false,"slots":["fluids"]},{"classes":["Procgen"],"deps":["gpgpu","graphics","image","map","transaction"],"enabled":true,"layer":5,"name":"procgen","optionalDeps":[],"profiles":["3d"],"required":false,"slots":["procgen"]},{"classes":["RTS"],"deps":["action","attributes","effects","orders","production","transaction"],"enabled":true,"layer":5,"name":"rts","optionalDeps":[],"profiles":["2d","3d"],"required":false,"slots":["rts"]},{"classes":["Tactics"],"deps":["action"],"enabled":true,"layer":5,"name":"tactics","optionalDeps":[],"profiles":["2d","3d","web"],"required":false,"slots":["tactics"]},{"classes":["Avatar"],"deps":["animation","graphics","model3d","scene"],"enabled":true,"layer":5,"name":"avatar","optionalDeps":[],"profiles":[],"required":false,"slots":["avatar"]},{"classes":["Climbing"],"deps":["animation","physics"],"enabled":true,"layer":5,"name":"climbing","optionalDeps":[],"profiles":["3d"],"required":false,"slots":["climbing"]},{"classes":["TF"],"deps":["gpgpu"],"enabled":true,"layer":5,"name":"tensor","optionalDeps":[],"profiles":["3d","web"],"required":false,"slots":["tf"]},{"classes":["VirtualGeometry"],"deps":["data","gpgpu","graphics"],"enabled":true,"layer":5,"name":"virtualgeometry","optionalDeps":[],"profiles":["3d"],"required":false,"slots":[]},{"classes":["Hd2D"],"deps":["graphics","map"],"enabled":true,"layer":5,"name":"hd2d","optionalDeps":[],"profiles":["3d"],"required":false,"slots":["hd2d"]},{"classes":[],"deps":["asset","procgen"],"enabled":true,"layer":6,"name":"asset_procgen","optionalDeps":[],"profiles":["3d"],"required":false,"slots":[]},{"classes":["Snow"],"deps":["graphics","procgen"],"enabled":true,"layer":6,"name":"snow","optionalDeps":[],"profiles":["3d"],"required":false,"slots":["snow"]},{"classes":["SceneLoader"],"deps":["animation","data","filesystem","graphics","image","model3d","scene","thread"],"enabled":true,"layer":6,"name":"sceneloader","optionalDeps":[],"profiles":["3d"],"required":false,"slots":[]},{"classes":["Dialogue","DialogueUX","DialogueVoice","DialogueFlow"],"deps":["avatar","audio","decision","filesystem","transaction"],"enabled":true,"layer":6,"name":"dialogue","optionalDeps":[],"profiles":[],"required":false,"slots":["dialogue","dialogueUX","dialogueVoice","dialogueFlow"]}],"eve_modules":[{"cls":"Math","slot":"math"},{"cls":"Filesystem","slot":"fs"},{"cls":"HotReload","slot":"hot"},{"cls":"PlatformEvent","slot":"platform_event"},{"cls":"Timer","slot":"timer"},{"cls":"System","slot":"system"},{"cls":"Profiler","slot":"profiler"},{"cls":"Thread","slot":"thread"},{"cls":"Spatial","slot":"spatial"},{"cls":"Crowd","slot":"crowd"},{"cls":"PixelWorldModule","slot":"pixelworld"},{"cls":"Editor","slot":"editor"},{"cls":"PixelWorldEditorModule","slot":"pixelworldEditor"},{"cls":"Economy","slot":"economy"},{"cls":"Attributes","slot":"attributes"},{"cls":"Authority","slot":"authority"},{"cls":"Decision","slot":"decision"},{"cls":"Definitions","slot":"definitions"},{"cls":"Effects","slot":"effects"},{"cls":"GameEvent","slot":"game_event"},{"cls":"Orders","slot":"orders"},{"cls":"PolicyRegistryModule","slot":"policies"},{"cls":"Production","slot":"production"},{"cls":"Sensing","slot":"sensing"},{"cls":"Schema","slot":"schema"},{"cls":"Social","slot":"social"},{"cls":"StatePatch","slot":"statepatch"},{"cls":"Steering","slot":"steering"},{"cls":"Tags","slot":"tags"},{"cls":"Transaction","slot":"transaction"},{"cls":"Window","slot":"win"},{"cls":"I18n","slot":"i18n"},{"cls":"Model3D","slot":"model3d"},{"cls":"Sound","slot":"sound"},{"cls":"Keyboard","slot":"keyboard"},{"cls":"Mouse","slot":"mouse"},{"cls":"Touch","slot":"touch"},{"cls":"Audio","slot":"audio"},{"cls":"Font","slot":"font"},{"cls":"Graphics","slot":"gfx"},{"cls":"PixelWorldGraphics","slot":"pixelworldGraphics"},{"cls":"Camera","slot":"camera"},{"cls":"Gpgpu","slot":"gpgpu"},{"cls":"UI","slot":"ui"},{"cls":"Physics","slot":"physics"},{"cls":"Map","slot":"map"},{"cls":"BuildingFx","slot":"buildingfx"},{"cls":"Weapon","slot":"weapon"},{"cls":"Vehicle","slot":"vehicle"},{"cls":"Animation","slot":"anim"},{"cls":"DayNight","slot":"daynight"},{"cls":"Weather","slot":"weather"},{"cls":"Decal","slot":"decal"},{"cls":"Stylize","slot":"stylize"},{"cls":"SpriteStack","slot":"spritestack"},{"cls":"Scene","slot":"scene"},{"cls":"Particles","slot":"particles"},{"cls":"Fluids","slot":"fluids"},{"cls":"Procgen","slot":"procgen"},{"cls":"RTS","slot":"rts"},{"cls":"Tactics","slot":"tactics"},{"cls":"Avatar","slot":"avatar"},{"cls":"Climbing","slot":"climbing"},{"cls":"TF","slot":"tf"},{"cls":"Hd2D","slot":"hd2d"},{"cls":"Snow","slot":"snow"},{"cls":"Dialogue","slot":"dialogue"},{"cls":"DialogueUX","slot":"dialogueUX"},{"cls":"DialogueVoice","slot":"dialogueVoice"},{"cls":"DialogueFlow","slot":"dialogueFlow"}],"remote_sync_started":false,"showDiagnostics":true,"startup_frames":0},"version":2} \ No newline at end of file diff --git a/examples/pixelworld/main.nut b/examples/pixelworld/main.nut new file mode 100644 index 00000000..210d170d --- /dev/null +++ b/examples/pixelworld/main.nut @@ -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(); +}; diff --git a/scripts/architecture_contracts.json b/scripts/architecture_contracts.json index bafcdabd..793e56ea 100644 --- a/scripts/architecture_contracts.json +++ b/scripts/architecture_contracts.json @@ -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", diff --git a/src/engine/common/PixelWorldAutomation.h b/src/engine/common/PixelWorldAutomation.h new file mode 100644 index 00000000..2e7f2489 --- /dev/null +++ b/src/engine/common/PixelWorldAutomation.h @@ -0,0 +1,19 @@ +#pragma once + +#include "common/Export.h" + +#include + +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 diff --git a/src/engine/common/Result.h b/src/engine/common/Result.h index 1fab663e..86a88fb3 100644 --- a/src/engine/common/Result.h +++ b/src/engine/common/Result.h @@ -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 + // 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 @@ -93,8 +93,9 @@ using ResultReturn = std::invoke_result_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. */ diff --git a/src/engine/devtools/McpServer.cpp b/src/engine/devtools/McpServer.cpp index 76fe78cb..3c4501b7 100644 --- a/src/engine/devtools/McpServer.cpp +++ b/src/engine/devtools/McpServer.cpp @@ -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::IEditorHost* mcpHost() { return eve::cap::query(); } eve::IUIAutomation* mcpUI() { return eve::cap::query(); } eve::IEditorAutomation* mcpEditor() { return eve::cap::query(); } +eve::IPixelWorldAutomation* mcpPixelWorld() { + return eve::cap::query(); +} 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 " diff --git a/src/modules/graphics/Graphics.h b/src/modules/graphics/Graphics.h index 1ae13ed8..0c3155a1 100644 --- a/src/modules/graphics/Graphics.h +++ b/src/modules/graphics/Graphics.h @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #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 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 updateTextureRegion( + Texture *texture, int x, int y, int width, int height, + std::span 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 updateTextureRegions( + Texture *texture, std::span 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. diff --git a/src/modules/graphics/vulkan/Graphics.h b/src/modules/graphics/vulkan/Graphics.h index 826173b8..238ae5a4 100644 --- a/src/modules/graphics/vulkan/Graphics.h +++ b/src/modules/graphics/vulkan/Graphics.h @@ -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 updateTextureRegion(Texture *texture, int x, int y, int width, + int height, std::span rgba, + std::size_t bytesPerRow = 0) override; + eve::Result updateTextureRegions( + Texture *texture, std::span 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; diff --git a/src/modules/graphics/vulkan/GraphicsTexture.cpp b/src/modules/graphics/vulkan/GraphicsTexture.cpp index 0cfb1be8..ed108666 100644 --- a/src/modules/graphics/vulkan/GraphicsTexture.cpp +++ b/src/modules/graphics/vulkan/GraphicsTexture.cpp @@ -680,6 +680,111 @@ bool Graphics::updateTexture(Texture *tex, int width, int height, const uint8_t return replaceTexturePixelsRGBA(tex, width, height, rgba); } +eve::Result Graphics::updateTextureRegion(Texture *tex, int x, int y, int width, + int height, + std::span rgba, + std::size_t bytesPerRow) { + const TextureRegionUpload upload{x, y, width, height, rgba, bytesPerRow}; + return updateTextureRegions(tex, std::span(&upload, 1)); +} + +eve::Result Graphics::updateTextureRegions( + Texture *tex, std::span regions) { + if (!tex) + return eve::Result::failure(eve::Diagnostic::error( + eve::DiagnosticCode::InvalidArgument, "texture must be non-null", + "graphics.updateTextureRegions.texture")); + if (tex->mipmapCount != 1) + return eve::Result::failure(eve::Diagnostic::error( + eve::DiagnosticCode::Unsupported, "partial updates require a single-mip texture", + "graphics.updateTextureRegions.mipmaps")); + + auto *gpu = static_cast(tex->gpuHandle); + const auto owned = std::find_if(ownedGpuTextures.begin(), ownedGpuTextures.end(), + [gpu](const std::unique_ptr &candidate) { + return candidate.get() == gpu; + }); + if (!gpu || owned == ownedGpuTextures.end() || gpu->isCube) + return eve::Result::failure(eve::Diagnostic::error( + eve::DiagnosticCode::InvalidArgument, "texture is not an owned 2D texture", + "graphics.updateTextureRegions.texture")); + if (regions.empty()) return eve::Result::success(); + + std::vector packedOffsets; + packedOffsets.reserve(regions.size()); + std::size_t packedSize = 0; + for (const TextureRegionUpload ®ion : 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::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::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 packed(packedSize); + for (std::size_t index = 0; index < regions.size(); ++index) { + const TextureRegionUpload ®ion = 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 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::success(); +} + bool Graphics::replaceTexturePixelsRGBA(Texture *tex, int w, int h, const uint8_t *rgba) { if (!tex || !rgba || w <= 0 || h <= 0 || !initialized) return false; diff --git a/src/modules/graphics/webgpu/Graphics.cpp b/src/modules/graphics/webgpu/Graphics.cpp index e9e75b15..69747956 100644 --- a/src/modules/graphics/webgpu/Graphics.cpp +++ b/src/modules/graphics/webgpu/Graphics.cpp @@ -2265,6 +2265,65 @@ bool Graphics::updateTexture(Texture *texture, int width, int height, return true; } +eve::Result Graphics::updateTextureRegion(Texture *texture, int x, int y, int width, + int height, + std::span rgba, + std::size_t bytesPerRow) { + const TextureRegionUpload upload{x, y, width, height, rgba, bytesPerRow}; + return updateTextureRegions(texture, std::span(&upload, 1)); +} + +eve::Result Graphics::updateTextureRegions( + Texture *texture, std::span regions) { + auto *gpu = gpuForTexture(texture); + if (!texture || !gpu || gpu->isCube) + return eve::Result::failure(eve::Diagnostic::error( + eve::DiagnosticCode::InvalidArgument, "texture is not an owned 2D texture", + "graphics.updateTextureRegions.texture")); + if (texture->mipmapCount != 1) + return eve::Result::failure(eve::Diagnostic::error( + eve::DiagnosticCode::Unsupported, "partial updates require a single-mip texture", + "graphics.updateTextureRegions.mipmaps")); + + for (const TextureRegionUpload ®ion : 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::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::failure(eve::Diagnostic::error( + eve::DiagnosticCode::InvalidArgument, + "source bytes do not cover the texture region", + "graphics.updateTextureRegions.bytes")); + } + + for (const TextureRegionUpload ®ion : 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(&dst), + region.rgba.data(), requiredBytes, + reinterpret_cast(&layout), + reinterpret_cast(&extent)); + } + return eve::Result::success(); +} + GpuTexture *Graphics::gpuForTexture(Texture *t) const { return t ? static_cast(t->gpuHandle) : nullptr; } diff --git a/src/modules/graphics/webgpu/Graphics.h b/src/modules/graphics/webgpu/Graphics.h index 131c273b..56d1fac5 100644 --- a/src/modules/graphics/webgpu/Graphics.h +++ b/src/modules/graphics/webgpu/Graphics.h @@ -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 updateTextureRegion(Texture *texture, int x, int y, int width, + int height, std::span rgba, + std::size_t bytesPerRow = 0) override; + eve::Result updateTextureRegions( + Texture *texture, std::span regions) override; void drawTexturedRect(Texture *texture, float x, float y, float w, float h, const Color &color) override; diff --git a/src/modules/pixelworld/PixelMaterial.cpp b/src/modules/pixelworld/PixelMaterial.cpp new file mode 100644 index 00000000..35ff4471 --- /dev/null +++ b/src/modules/pixelworld/PixelMaterial.cpp @@ -0,0 +1,252 @@ +#include "pixelworld/PixelMaterial.h" + +#include "common/Diagnostic.h" + +#include +#include +#include +#include +#include + +namespace eve::pixelworld { +namespace { + +eve::Result invalidCatalog(std::string message, std::string path) { + return eve::Result::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 +void hashValue(std::uint64_t& hash, T value) noexcept { + using Unsigned = std::make_unsigned_t; + const auto bits = static_cast(value); + for (std::size_t i = 0; i < sizeof(T); ++i) { + hash ^= std::uint8_t(bits >> (i * 8U)); + hash *= 1099511628211ULL; + } +} + +} // namespace + +eve::Result MaterialCatalog::create(std::vector definitions, + std::vector reactions, + std::vector phaseRules) { + if (definitions.empty()) return invalidCatalog("material catalog must contain air", "materials"); + if (definitions.size() > std::size_t(std::numeric_limits::max())) + return invalidCatalog("material catalog exceeds uint16 id capacity", "materials"); + + std::unordered_set 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 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 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::success(std::move(catalog)); +} + +MaterialCatalog MaterialCatalog::builtIn() { + std::vector 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 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 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 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 MaterialCatalog::resolve(std::string_view name) const { + for (const auto& definition : definitions_) + if (definition.name == name) return eve::Result::success(definition.id); + return eve::Result::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 MaterialCatalog::definitions() const noexcept { return definitions_; } +std::span MaterialCatalog::reactions() const noexcept { return reactions_; } +std::span 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 diff --git a/src/modules/pixelworld/PixelMaterial.h b/src/modules/pixelworld/PixelMaterial.h new file mode 100644 index 00000000..6c962b93 --- /dev/null +++ b/src/modules/pixelworld/PixelMaterial.h @@ -0,0 +1,118 @@ +#pragma once + +#include "common/Result.h" + +#include +#include +#include +#include +#include + +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 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 create( + std::vector definitions, + std::vector reactions = {}, + std::vector 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 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 definitions() const noexcept; + /** @brief Borrow immutable canonical-priority reaction rules. */ + std::span reactions() const noexcept; + /** @brief Borrow immutable canonical-priority phase rules. */ + std::span 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 definitions_; + std::vector reactions_; + std::vector phaseRules_; + std::vector reactionMask_; + std::vector phaseMask_; + std::uint64_t fingerprint_ = 0; +}; + +} // namespace eve::pixelworld diff --git a/src/modules/pixelworld/PixelMaterialCatalogCodec.cpp b/src/modules/pixelworld/PixelMaterialCatalogCodec.cpp new file mode 100644 index 00000000..f046d651 --- /dev/null +++ b/src/modules/pixelworld/PixelMaterialCatalogCodec.cpp @@ -0,0 +1,296 @@ +#include "pixelworld/PixelMaterialCatalogCodec.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace eve::pixelworld { +namespace { + +using Object = Poco::JSON::Object; +using Array = Poco::JSON::Array; + +eve::Result malformed(std::string message, std::string path = "document") { + return eve::Result::failure(eve::Diagnostic::error( + eve::DiagnosticCode::SerializationError, std::move(message), std::move(path), {}, + "pixelworld.catalog-codec")); +} + +bool exactFields(const Object& object, std::initializer_list allowed) { + std::unordered_set 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 +bool readNumber(const Object& object, const std::string& key, T& result) { + static_assert(std::is_integral_v && !std::is_same_v); + if (!object.has(key) || object.isNull(key)) return false; + try { + const auto wide = object.get(key).convert(); + if constexpr (std::is_unsigned_v) { + if (wide < 0 || std::uint64_t(wide) > std::numeric_limits::max()) return false; + } else if (wide < std::numeric_limits::min() || + wide > std::numeric_limits::max()) { + return false; + } + result = static_cast(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 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::success(text.str()); +} + +eve::Result decodeMaterialCatalogJson(std::string_view json) { + Object::Ptr root; + try { + root = Poco::JSON::Parser().parse(std::string(json)).extract(); + } 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("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 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("name", ""); + if (!parseState(value->optValue("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("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(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 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("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 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("id", ""); + const std::string direction = value->optValue("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 diff --git a/src/modules/pixelworld/PixelMaterialCatalogCodec.h b/src/modules/pixelworld/PixelMaterialCatalogCodec.h new file mode 100644 index 00000000..394e45ed --- /dev/null +++ b/src/modules/pixelworld/PixelMaterialCatalogCodec.h @@ -0,0 +1,21 @@ +#pragma once + +#include "common/Result.h" +#include "pixelworld/PixelMaterial.h" + +#include +#include + +namespace eve::pixelworld { + +/** @brief Encode schema `eve.pixelworld.material-catalog` version 1 canonically. */ +[[nodiscard]] eve::Result 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 decodeMaterialCatalogJson(std::string_view json); + +} // namespace eve::pixelworld diff --git a/src/modules/pixelworld/PixelWorld.cpp b/src/modules/pixelworld/PixelWorld.cpp new file mode 100644 index 00000000..115fd5f9 --- /dev/null +++ b/src/modules/pixelworld/PixelWorld.cpp @@ -0,0 +1,1625 @@ +#include "pixelworld/PixelWorld.h" + +#include "pixelworld/PixelWorldControl.h" + +#include "common/Status.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace eve::pixelworld { +namespace { + +std::atomic nextPixelWorldId{1}; + +int floorDiv(int value) noexcept { + return value >= 0 ? value / kPixelChunkSize : -((-value + kPixelChunkSize - 1) / kPixelChunkSize); +} + +int floorMod(int value) noexcept { + const int result = value % kPixelChunkSize; + return result < 0 ? result + kPixelChunkSize : result; +} + +struct ChunkCoord { + int x = 0; + int y = 0; + friend bool operator==(const ChunkCoord&, const ChunkCoord&) = default; + friend bool operator<(const ChunkCoord& a, const ChunkCoord& b) { + return a.y != b.y ? a.y < b.y : a.x < b.x; + } +}; + +struct ChunkCoordHash { + std::size_t operator()(ChunkCoord coord) const noexcept { + const auto x = std::uint64_t(std::uint32_t(coord.x)); + const auto y = std::uint64_t(std::uint32_t(coord.y)); + return std::size_t((x << 32U) ^ y); + } +}; + +struct Chunk { + std::array cells{}; + std::array updated{}; + std::array materialCounts{}; + std::uint32_t nonAir = 0; + std::uint32_t mobile = 0; + std::uint32_t thermalRemainderCells = 0; + std::uint32_t materialOverflow = 0; + std::int16_t minimumTemperature = 32767; + std::int16_t maximumTemperature = -32768; + bool temperatureBoundsDirty = false; + std::uint64_t revision = 0; + bool active = true; + bool touched = true; + std::uint8_t idleTicks = 0; + + static std::size_t index(int x, int y) noexcept { + return std::size_t(y * kPixelChunkSize + x); + } +}; + +eve::Status malformed(std::string message) { + return eve::Status::failure(eve::Diagnostic::error( + eve::DiagnosticCode::SerializationError, std::move(message), "snapshot", {}, "pixelworld")); +} + +template +void append(std::vector& out, T value) { + const auto* first = reinterpret_cast(&value); + out.insert(out.end(), first, first + sizeof(T)); +} + +template +bool read(std::span bytes, std::size_t& cursor, T& value) { + if (cursor > bytes.size() || bytes.size() - cursor < sizeof(T)) return false; + std::memcpy(&value, bytes.data() + cursor, sizeof(T)); + cursor += sizeof(T); + return true; +} + +} // namespace + +struct PixelWorld::Impl { + explicit Impl(MaterialCatalog ownedCatalog = MaterialCatalog::builtIn()) + : catalog(std::move(ownedCatalog)) { + rebuildMaterialRuntimeTables(); + } + + std::unordered_map chunks; + std::unordered_map removedChunks; + MaterialCatalog catalog; + std::vector canDisplaceTable; + std::vector materialStates; + std::vector thermalConductivities; + std::vector heatCapacities; + std::vector> reactionRulesByPair; + std::size_t materialRuntimeCount = 0; + std::uint64_t seed = 1; + std::uint64_t revision = 0; + std::uint64_t tick = 0; + std::uint64_t lastEditSequence = 0; + std::uint64_t worldId = nextPixelWorldId.fetch_add(1, std::memory_order_relaxed); + std::uint64_t epoch = 1; + std::uint64_t nextFragmentId = 1; + bool paused = false; + + const Chunk* findChunk(int x, int y) const noexcept { + const auto found = chunks.find({floorDiv(x), floorDiv(y)}); + return found == chunks.end() ? nullptr : &found->second; + } + + Chunk* findChunk(int x, int y) noexcept { + const auto found = chunks.find({floorDiv(x), floorDiv(y)}); + return found == chunks.end() ? nullptr : &found->second; + } + + Chunk& ensureChunk(int x, int y) { return chunks[{floorDiv(x), floorDiv(y)}]; } + + bool isMobile(MaterialId material) const noexcept { + const std::size_t index = std::size_t(material); + const auto state = index < materialStates.size() ? materialStates[index] + : MaterialState::Empty; + return state == MaterialState::Powder || state == MaterialState::Liquid; + } + + MaterialState materialState(MaterialId material) const noexcept { + const std::size_t index = std::size_t(material); + return index < materialStates.size() ? materialStates[index] : MaterialState::Empty; + } + + void rebuildMaterialRuntimeTables() { + materialRuntimeCount = catalog.definitions().size(); + canDisplaceTable.assign(materialRuntimeCount * materialRuntimeCount, 0); + reactionRulesByPair.assign(materialRuntimeCount * materialRuntimeCount, {}); + materialStates.resize(materialRuntimeCount); + thermalConductivities.resize(materialRuntimeCount); + heatCapacities.resize(materialRuntimeCount); + for (std::size_t source = 0; source < materialRuntimeCount; ++source) { + const auto& sourceDef = catalog.definition(MaterialId(source)); + materialStates[source] = sourceDef.state; + thermalConductivities[source] = sourceDef.thermalConductivity; + heatCapacities[source] = sourceDef.heatCapacity; + for (std::size_t target = 0; target < materialRuntimeCount; ++target) { + const auto& targetDef = catalog.definition(MaterialId(target)); + const bool targetFluid = targetDef.state == MaterialState::Empty || + targetDef.state == MaterialState::Gas || + targetDef.state == MaterialState::Liquid; + canDisplaceTable[source * materialRuntimeCount + target] = + std::uint8_t(targetFluid && targetDef.density < sourceDef.density); + } + } + const auto reactions = catalog.reactions(); + for (std::size_t ruleIndex = 0; ruleIndex < reactions.size(); ++ruleIndex) { + const auto& rule = reactions[ruleIndex]; + const std::size_t first = std::size_t(rule.first); + const std::size_t second = std::size_t(rule.second); + if (first >= materialRuntimeCount || second >= materialRuntimeCount || + ruleIndex > std::size_t(std::numeric_limits::max())) + continue; + reactionRulesByPair[first * materialRuntimeCount + second].push_back( + std::uint16_t(ruleIndex)); + if (first != second) + reactionRulesByPair[second * materialRuntimeCount + first].push_back( + std::uint16_t(ruleIndex)); + } + } + + bool canDisplace(MaterialId source, MaterialId target) const noexcept { + const std::size_t sourceIndex = std::size_t(source); + const std::size_t targetIndex = std::size_t(target); + if (sourceIndex >= materialRuntimeCount || targetIndex >= materialRuntimeCount) return false; + return canDisplaceTable[sourceIndex * materialRuntimeCount + targetIndex] != 0; + } + + std::span reactionRules(MaterialId source, + MaterialId target) const noexcept { + const std::size_t sourceIndex = std::size_t(source); + const std::size_t targetIndex = std::size_t(target); + if (sourceIndex >= materialRuntimeCount || targetIndex >= materialRuntimeCount) return {}; + return reactionRulesByPair[sourceIndex * materialRuntimeCount + targetIndex]; + } + + std::uint8_t thermalConductivity(MaterialId material) const noexcept { + const std::size_t index = std::size_t(material); + return index < thermalConductivities.size() ? thermalConductivities[index] : 0; + } + + std::uint16_t heatCapacity(MaterialId material) const noexcept { + const std::size_t index = std::size_t(material); + return index < heatCapacities.size() ? heatCapacities[index] : 1; + } + + void addActivity(Chunk& chunk, const PixelCell& cell) const noexcept { + if (cell.material == MaterialId::Air) return; + ++chunk.nonAir; + if (isMobile(cell.material)) ++chunk.mobile; + if (cell.thermalRemainder != 0) ++chunk.thermalRemainderCells; + const auto material = std::size_t(cell.material); + if (material < chunk.materialCounts.size()) + ++chunk.materialCounts[material]; + else + ++chunk.materialOverflow; + chunk.minimumTemperature = std::min(chunk.minimumTemperature, cell.temperature); + chunk.maximumTemperature = std::max(chunk.maximumTemperature, cell.temperature); + } + + void removeActivity(Chunk& chunk, const PixelCell& cell) const noexcept { + if (cell.material == MaterialId::Air) return; + --chunk.nonAir; + if (isMobile(cell.material)) --chunk.mobile; + if (cell.thermalRemainder != 0) --chunk.thermalRemainderCells; + const auto material = std::size_t(cell.material); + if (material < chunk.materialCounts.size()) + --chunk.materialCounts[material]; + else + --chunk.materialOverflow; + if (cell.temperature == chunk.minimumTemperature || cell.temperature == chunk.maximumTemperature) + chunk.temperatureBoundsDirty = true; + } + + void rebuildActivity(Chunk& chunk) const noexcept { + chunk.materialCounts.fill(0); + chunk.nonAir = 0; + chunk.mobile = 0; + chunk.thermalRemainderCells = 0; + chunk.materialOverflow = 0; + chunk.minimumTemperature = 32767; + chunk.maximumTemperature = -32768; + chunk.temperatureBoundsDirty = false; + for (const PixelCell& cell : chunk.cells) addActivity(chunk, cell); + } + + void refreshTemperatureBounds(Chunk& chunk) const noexcept { + if (!chunk.temperatureBoundsDirty) return; + chunk.minimumTemperature = 32767; + chunk.maximumTemperature = -32768; + for (const PixelCell& cell : chunk.cells) { + if (cell.material == MaterialId::Air) continue; + chunk.minimumTemperature = std::min(chunk.minimumTemperature, cell.temperature); + chunk.maximumTemperature = std::max(chunk.maximumTemperature, cell.temperature); + } + chunk.temperatureBoundsDirty = false; + } + + PixelCell get(int x, int y) const noexcept { + const Chunk* chunk = findChunk(x, y); + return chunk ? chunk->cells[Chunk::index(floorMod(x), floorMod(y))] : PixelCell{}; + } + + PixelCell normalizeCell(PixelCell cell) const noexcept { + if (cell.material == MaterialId::Air) { + cell.thermalRemainder = 0; + return cell; + } + const std::int64_t capacity = catalog.definition(cell.material).heatCapacity; + std::int64_t energy = std::int64_t(cell.temperature) * capacity + cell.thermalRemainder; + std::int64_t temperature = energy / capacity; + std::int64_t remainder = energy % capacity; + if (remainder < 0) { + --temperature; + remainder += capacity; + } + if (temperature < std::numeric_limits::min()) { + temperature = std::numeric_limits::min(); + remainder = 0; + } else if (temperature > std::numeric_limits::max()) { + temperature = std::numeric_limits::max(); + remainder = capacity - 1; + } + cell.temperature = std::int16_t(temperature); + cell.thermalRemainder = std::uint16_t(remainder); + return cell; + } + + std::uint64_t updatedAt(int x, int y) const noexcept { + const Chunk* chunk = findChunk(x, y); + return chunk ? chunk->updated[Chunk::index(floorMod(x), floorMod(y))] : 0; + } + + void wakeAround(int x, int y) { + const int cx = floorDiv(x), cy = floorDiv(y); + for (int oy = -1; oy <= 1; ++oy) + for (int ox = -1; ox <= 1; ++ox) { + const auto found = chunks.find({cx + ox, cy + oy}); + if (found != chunks.end()) found->second.active = true; + } + } + + void put(int x, int y, PixelCell cell, std::uint64_t updatedTick, bool markUpdated = true) { + cell = normalizeCell(cell); + removedChunks.erase({floorDiv(x), floorDiv(y)}); + Chunk& chunk = ensureChunk(x, y); + const auto index = Chunk::index(floorMod(x), floorMod(y)); + const PixelCell oldCell = chunk.cells[index]; + if (oldCell != cell) { + removeActivity(chunk, oldCell); + addActivity(chunk, cell); + } + chunk.cells[index] = cell; + if (markUpdated) chunk.updated[index] = updatedTick; + chunk.revision = revision + 1; + chunk.active = true; + chunk.touched = true; + } + + void putNormalizedWithinChunk(Chunk& chunk, std::size_t index, const PixelCell& cell, + std::uint64_t updatedTick, bool markUpdated = true) { + const PixelCell oldCell = chunk.cells[index]; + if (oldCell != cell) { + removeActivity(chunk, oldCell); + addActivity(chunk, cell); + } + chunk.cells[index] = cell; + if (markUpdated) chunk.updated[index] = updatedTick; + chunk.revision = revision + 1; + chunk.active = true; + chunk.touched = true; + } + + void putWithinChunk(Chunk& chunk, std::size_t index, PixelCell cell, + std::uint64_t updatedTick, bool markUpdated = true) { + putNormalizedWithinChunk(chunk, index, normalizeCell(cell), updatedTick, markUpdated); + } + + bool moveOrSwap(int x, int y, int tx, int ty, std::uint64_t currentTick) { + const PixelCell source = get(x, y); + return moveOrSwapKnownSource(x, y, source, tx, ty, currentTick); + } + + bool moveOrSwapKnownSource(int x, int y, PixelCell source, int tx, int ty, + std::uint64_t currentTick) { + const PixelCell target = get(tx, ty); + if (!canDisplace(source.material, target.material)) return false; + put(tx, ty, source, currentTick); + put(x, y, target, currentTick); + wakeAround(tx, ty); + return true; + } + + bool moveOrSwapWithinChunk(ChunkCoord coord, Chunk& chunk, int sourceX, int sourceY, + PixelCell source, int targetX, int targetY, + std::uint64_t currentTick) { + const auto sourceIndex = Chunk::index(sourceX, sourceY); + const auto targetIndex = Chunk::index(targetX, targetY); + const PixelCell target = chunk.cells[targetIndex]; + if (!canDisplace(source.material, target.material)) return false; + chunk.cells[targetIndex] = source; + chunk.cells[sourceIndex] = target; + chunk.updated[targetIndex] = currentTick; + chunk.updated[sourceIndex] = currentTick; + chunk.revision = revision + 1; + chunk.active = true; + chunk.touched = true; + const bool touchesBoundary = sourceX == 0 || sourceX == kPixelChunkSize - 1 || + sourceY == 0 || sourceY == kPixelChunkSize - 1 || + targetX == 0 || targetX == kPixelChunkSize - 1 || + targetY == 0 || targetY == kPixelChunkSize - 1; + if (touchesBoundary) + wakeAround(coord.x * kPixelChunkSize + targetX, + coord.y * kPixelChunkSize + targetY); + return true; + } + + bool pseudoBit(int x, int y, std::uint64_t currentTick) const noexcept { + std::uint64_t value = seed ^ (std::uint64_t(std::uint32_t(x)) << 32U) ^ + std::uint64_t(std::uint32_t(y)) ^ (currentTick * 0x9E3779B97F4A7C15ULL); + value ^= value >> 30U; + value *= 0xBF58476D1CE4E5B9ULL; + value ^= value >> 27U; + return (value & 1U) != 0; + } +}; + +PixelWorld::PixelWorld(std::uint64_t seed) : impl_(std::make_unique()) { + impl_->seed = seed; + pixelWorldControlService().registerWorld(*this); +} +PixelWorld::PixelWorld(std::uint64_t seed, MaterialCatalog catalog) + : impl_(std::make_unique(std::move(catalog))) { + impl_->seed = seed; + pixelWorldControlService().registerWorld(*this); +} +PixelWorld::~PixelWorld() { + if (impl_) pixelWorldControlService().unregisterWorld(*this); +} +PixelWorld::PixelWorld(PixelWorld&& other) noexcept : impl_(std::move(other.impl_)) { + if (impl_) pixelWorldControlService().rebindWorld(&other, *this); +} +PixelWorld& PixelWorld::operator=(PixelWorld&& other) noexcept { + if (this == &other) return *this; + if (impl_) pixelWorldControlService().unregisterWorld(*this); + impl_ = std::move(other.impl_); + if (impl_) pixelWorldControlService().rebindWorld(&other, *this); + return *this; +} + +PixelCell PixelWorld::getCell(int x, int y) const noexcept { return impl_->get(x, y); } +int PixelWorld::getMaterial(int x, int y) const noexcept { return int(impl_->get(x, y).material); } +bool PixelWorld::isSolidMaterial(MaterialId material) const noexcept { + return impl_->catalog.definition(material).state == MaterialState::Solid; +} +std::uint32_t PixelWorld::materialDisplayRgba(MaterialId material) const noexcept { + return impl_->catalog.definition(material).displayRgba; +} + +eve::Result PixelWorld::reloadMaterialCatalog( + MaterialCatalog catalog, std::uint64_t expectedFingerprint) { + const auto reject = [](eve::DiagnosticCode code, std::string message, std::string path) { + return eve::Result::failure(eve::Diagnostic::error( + code, std::move(message), std::move(path), {}, "pixelworld.catalog-reload")); + }; + if (!impl_->paused) + return reject(eve::DiagnosticCode::PreconditionViolation, + "material Catalog reload requires a paused world", "paused"); + if (expectedFingerprint != impl_->catalog.fingerprint()) + return reject(eve::DiagnosticCode::Conflict, + "material Catalog expected fingerprint is stale", "expectedFingerprint"); + const auto current = impl_->catalog.definitions(); + const auto replacement = catalog.definitions(); + if (current.size() != replacement.size()) + return reject(eve::DiagnosticCode::Conflict, + "live Catalog reload cannot add or remove material ids", "materials"); + for (std::size_t index = 0; index < current.size(); ++index) + if (current[index].id != replacement[index].id || current[index].name != replacement[index].name) + return reject(eve::DiagnosticCode::Conflict, + "live Catalog reload must preserve every material id and name", + "materials[" + std::to_string(index) + "]"); + + PixelCatalogReloadReceipt receipt; + receipt.fingerprintBefore = impl_->catalog.fingerprint(); + receipt.fingerprintAfter = catalog.fingerprint(); + receipt.revisionBefore = impl_->revision; + if (receipt.fingerprintBefore == receipt.fingerprintAfter) { + receipt.revisionAfter = impl_->revision; + receipt.worldEpoch = impl_->epoch; + return eve::Result::success(receipt); + } + impl_->catalog = std::move(catalog); + impl_->rebuildMaterialRuntimeTables(); + ++impl_->revision; + ++impl_->epoch; + impl_->lastEditSequence = 0; + for (auto& [coord, chunk] : impl_->chunks) { + for (PixelCell& cell : chunk.cells) cell = impl_->normalizeCell(cell); + impl_->rebuildActivity(chunk); + impl_->refreshTemperatureBounds(chunk); + chunk.revision = impl_->revision; + chunk.active = true; + chunk.touched = true; + chunk.idleTicks = 0; + ++receipt.chunksRebuilt; + } + receipt.revisionAfter = impl_->revision; + receipt.worldEpoch = impl_->epoch; + receipt.replayHistoryInvalidated = true; + return eve::Result::success(receipt); +} + +void PixelWorld::setCell(int x, int y, PixelCell cell) { + if (cell.material == MaterialId::Air && impl_->findChunk(x, y) == nullptr) return; + impl_->put(x, y, cell, impl_->tick); + impl_->wakeAround(x, y); + ++impl_->revision; +} + +void PixelWorld::setMaterial(int x, int y, std::string_view material) { + setMaterialChecked(x, y, material).expect("PixelWorld::setMaterial requires a registered material"); +} + +eve::Result PixelWorld::setMaterialChecked(int x, int y, std::string_view material) { + auto resolved = impl_->catalog.resolve(material); + if (!resolved.ok()) return eve::Result::failure(resolved.status()); + PixelCell cell; + cell.material = resolved.value(); + cell.temperature = impl_->catalog.definition(cell.material).defaultTemperature; + cell.lifetime = std::uint8_t(std::min(impl_->catalog.definition(cell.material).defaultLifetime, + std::numeric_limits::max())); + setCell(x, y, cell); + return eve::Result::success(); +} + +std::size_t PixelWorld::paintCircle(int centerX, int centerY, int radius, std::string_view material) { + return std::move(paintCircleChecked(centerX, centerY, radius, material)) + .expect("PixelWorld::paintCircle requires a registered material"); +} + +eve::Result PixelWorld::paintCircleChecked(int centerX, int centerY, int radius, + std::string_view material) { + if (radius < 0) return eve::Result::success(0); + std::size_t changed = 0; + auto resolved = impl_->catalog.resolve(material); + if (!resolved.ok()) return eve::Result::failure(resolved.status()); + const MaterialId id = resolved.value(); + for (int y = centerY - radius; y <= centerY + radius; ++y) + for (int x = centerX - radius; x <= centerX + radius; ++x) { + const int dx = x - centerX, dy = y - centerY; + if (dx * dx + dy * dy > radius * radius || impl_->get(x, y).material == id) continue; + PixelCell cell; + cell.material = id; + cell.temperature = impl_->catalog.definition(id).defaultTemperature; + cell.lifetime = std::uint8_t(std::min(impl_->catalog.definition(id).defaultLifetime, + std::numeric_limits::max())); + setCell(x, y, cell); + ++changed; + } + return eve::Result::success(changed); +} + +eve::Result PixelWorld::applyEdit(const PixelEditCommand& command) { + const auto reject = [](std::string message, std::string path) { + return eve::Result::failure(eve::Diagnostic::error( + eve::DiagnosticCode::PreconditionViolation, std::move(message), std::move(path), {}, + "pixelworld.edit")); + }; + if (command.sequence != impl_->lastEditSequence + 1) + return reject("edit sequence must be exactly previous sequence + 1", "sequence"); + if (command.radius < 0 || command.radius > 4096) + return reject("edit radius must be in [0, 4096]", "radius"); + if (command.centerX < -100'000'000 || command.centerX > 100'000'000 || + command.centerY < -100'000'000 || command.centerY > 100'000'000) + return reject("edit center is outside the supported coordinate range", "center"); + if (command.kind == PixelEditKind::PaintCircle && + std::size_t(command.material) >= impl_->catalog.definitions().size()) + return reject("paint command references an unknown material id", "material"); + if (command.kind == PixelEditKind::Explosion && (command.strength < 0 || command.strength > 1'000'000)) + return reject("explosion strength must be in [0, 1000000]", "strength"); + + struct Candidate { + int x = 0; + int y = 0; + PixelCell cell; + bool removed = false; + bool heated = false; + }; + std::vector candidates; + const std::int64_t radiusSquared = std::int64_t(command.radius) * command.radius; + for (int y = command.centerY - command.radius; y <= command.centerY + command.radius; ++y) + for (int x = command.centerX - command.radius; x <= command.centerX + command.radius; ++x) { + const std::int64_t dx = std::int64_t(x) - command.centerX; + const std::int64_t dy = std::int64_t(y) - command.centerY; + const std::int64_t distanceSquared = dx * dx + dy * dy; + if (distanceSquared > radiusSquared) continue; + const PixelCell oldCell = impl_->get(x, y); + PixelCell newCell = oldCell; + bool removed = false; + bool heated = false; + if (command.kind == PixelEditKind::PaintCircle) { + newCell = {}; + newCell.material = command.material; + const auto& definition = impl_->catalog.definition(command.material); + newCell.temperature = definition.defaultTemperature; + newCell.lifetime = std::uint8_t(std::min( + definition.defaultLifetime, std::numeric_limits::max())); + } else if (command.kind == PixelEditKind::HeatCircle) { + if (oldCell.material == MaterialId::Air) continue; + newCell.temperature = std::int16_t(std::clamp( + int(oldCell.temperature) + command.temperatureDelta, -32768, 32767)); + heated = newCell.temperature != oldCell.temperature; + } else { + if (oldCell.material == MaterialId::Air) continue; + const std::int64_t denominator = std::max(1, radiusSquared); + const int radialStrength = int(std::int64_t(command.strength) * + (radiusSquared - distanceSquared + 1) / denominator); + if (radialStrength >= impl_->catalog.definition(oldCell.material).blastResistance) { + newCell = {}; + removed = true; + } else if (command.temperatureDelta != 0) { + const int radialHeat = int(std::int64_t(command.temperatureDelta) * + (radiusSquared - distanceSquared + 1) / denominator); + newCell.temperature = std::int16_t(std::clamp( + int(oldCell.temperature) + radialHeat, -32768, 32767)); + heated = newCell.temperature != oldCell.temperature; + } + } + if (newCell == oldCell) continue; + candidates.push_back({x, y, newCell, removed, heated}); + } + + PixelEditReceipt receipt; + receipt.sequence = command.sequence; + receipt.revisionBefore = impl_->revision; + for (const Candidate& candidate : candidates) { + impl_->put(candidate.x, candidate.y, candidate.cell, impl_->tick); + impl_->wakeAround(candidate.x, candidate.y); + ++receipt.cellsChanged; + if (candidate.removed) ++receipt.cellsRemoved; + if (candidate.heated) ++receipt.cellsHeated; + } + impl_->lastEditSequence = command.sequence; + ++impl_->revision; + receipt.revisionAfter = impl_->revision; + return eve::Result::success(receipt); +} + +PixelEditReceipt PixelWorld::explode(int centerX, int centerY, int radius, int strength, + std::int16_t temperatureDelta) { + PixelEditCommand command; + command.sequence = impl_->lastEditSequence + 1; + command.kind = PixelEditKind::Explosion; + command.centerX = centerX; + command.centerY = centerY; + command.radius = radius; + command.strength = strength; + command.temperatureDelta = temperatureDelta; + return std::move(applyEdit(command)).expect("PixelWorld::explode generated an invalid edit command"); +} + +eve::Result> PixelWorld::extractUnsupportedFragments( + PixelRegion region, int supportY, std::uint32_t minimumCells) { + const auto reject = [](eve::DiagnosticCode code, std::string message, std::string path) { + return eve::Result>::failure(eve::Diagnostic::error( + code, std::move(message), std::move(path), {}, "pixelworld.fragment.extract")); + }; + const std::int64_t width = std::int64_t(region.maxX) - region.minX + 1; + const std::int64_t height = std::int64_t(region.maxY) - region.minY + 1; + if (region.minX < -100'000'000 || region.maxX > 100'000'000 || + region.minY < -100'000'000 || region.maxY > 100'000'000) + return reject(eve::DiagnosticCode::InvalidArgument, + "fragment scan region is outside the supported coordinate range", "region"); + if (width <= 0 || height <= 0 || width * height > 16'777'216) + return reject(eve::DiagnosticCode::InvalidArgument, + "fragment scan region must be non-empty and contain at most 16777216 cells", "region"); + if (minimumCells == 0) + return reject(eve::DiagnosticCode::InvalidArgument, "minimumCells must be positive", "minimumCells"); + + using Coord = std::pair; + std::set visited; + std::vector fragments; + constexpr std::array neighbors{{{0, -1}, {-1, 0}, {1, 0}, {0, 1}}}; + const auto isSolid = [this](int x, int y) { + return impl_->catalog.definition(impl_->get(x, y).material).state == MaterialState::Solid; + }; + + for (int y = region.minY; y <= region.maxY; ++y) + for (int x = region.minX; x <= region.maxX; ++x) { + if (!isSolid(x, y) || visited.contains({x, y})) continue; + std::deque pending{{x, y}}; + std::vector component; + visited.emplace(x, y); + bool supported = false; + int minX = x, maxX = x, minY = y, maxY = y; + while (!pending.empty()) { + const auto [cx, cy] = pending.front(); + pending.pop_front(); + component.emplace_back(cx, cy); + minX = std::min(minX, cx); + maxX = std::max(maxX, cx); + minY = std::min(minY, cy); + maxY = std::max(maxY, cy); + if (cy >= supportY) supported = true; + for (const auto [dx, dy] : neighbors) { + const int nx = cx + dx, ny = cy + dy; + const bool inside = nx >= region.minX && nx <= region.maxX && + ny >= region.minY && ny <= region.maxY; + if (!inside) { + if (isSolid(nx, ny)) supported = true; + continue; + } + if (isSolid(nx, ny) && visited.emplace(nx, ny).second) + pending.emplace_back(nx, ny); + } + } + if (supported || component.size() < minimumCells) continue; + + PixelFragment fragment; + fragment.source = worldLink(); + fragment.id = impl_->nextFragmentId++; + fragment.originX = minX; + fragment.originY = minY; + fragment.width = maxX - minX + 1; + fragment.height = maxY - minY + 1; + fragment.solidCellCount = std::uint32_t(component.size()); + fragment.cells.resize(std::size_t(fragment.width) * std::size_t(fragment.height)); + for (const auto [cx, cy] : component) + fragment.cells[std::size_t(cy - minY) * std::size_t(fragment.width) + std::size_t(cx - minX)] = + impl_->get(cx, cy); + fragments.push_back(std::move(fragment)); + } + + if (!fragments.empty()) { + for (const PixelFragment& fragment : fragments) + for (int y = 0; y < fragment.height; ++y) + for (int x = 0; x < fragment.width; ++x) { + const PixelCell& cell = fragment.cells[std::size_t(y) * std::size_t(fragment.width) + x]; + if (cell.material == MaterialId::Air) continue; + impl_->put(fragment.originX + x, fragment.originY + y, {}, impl_->tick); + impl_->wakeAround(fragment.originX + x, fragment.originY + y); + } + ++impl_->revision; + } + return eve::Result>::success(std::move(fragments)); +} + +eve::Result PixelWorld::rasterizeFragment( + const PixelFragment& fragment, int originX, int originY) { + const auto reject = [](eve::DiagnosticCode code, std::string message, std::string path) { + return eve::Result::failure(eve::Diagnostic::error( + code, std::move(message), std::move(path), {}, "pixelworld.fragment.rasterize")); + }; + if (fragment.source != worldLink()) + return reject(eve::DiagnosticCode::StaleHandle, + "fragment belongs to another world or an invalidated world epoch", "source"); + if (fragment.id == 0 || fragment.width <= 0 || fragment.height <= 0 || + std::uint64_t(fragment.width) * std::uint64_t(fragment.height) != fragment.cells.size()) + return reject(eve::DiagnosticCode::InvalidArgument, "fragment bitmap metadata is invalid", "fragment"); + std::uint32_t cellsPlaced = 0; + for (int y = 0; y < fragment.height; ++y) + for (int x = 0; x < fragment.width; ++x) { + const PixelCell& cell = fragment.cells[std::size_t(y) * std::size_t(fragment.width) + x]; + if (cell.material == MaterialId::Air) continue; + if (std::size_t(cell.material) >= impl_->catalog.definitions().size()) + return reject(eve::DiagnosticCode::InvalidArgument, "fragment contains an unknown material", "cells"); + const std::int64_t wx = std::int64_t(originX) + x, wy = std::int64_t(originY) + y; + if (wx < std::numeric_limits::min() || wx > std::numeric_limits::max() || + wy < std::numeric_limits::min() || wy > std::numeric_limits::max()) + return reject(eve::DiagnosticCode::InvalidArgument, "fragment target coordinates overflow", "origin"); + if (impl_->get(int(wx), int(wy)).material != MaterialId::Air) + return reject(eve::DiagnosticCode::Conflict, "fragment target overlaps authoritative terrain", "origin"); + ++cellsPlaced; + } + if (cellsPlaced != fragment.solidCellCount) + return reject(eve::DiagnosticCode::InvalidArgument, "fragment solid cell count does not match bitmap", "solidCellCount"); + + PixelFragmentRasterReceipt receipt; + receipt.fragmentId = fragment.id; + receipt.revisionBefore = impl_->revision; + receipt.cellsPlaced = cellsPlaced; + for (int y = 0; y < fragment.height; ++y) + for (int x = 0; x < fragment.width; ++x) { + const PixelCell& cell = fragment.cells[std::size_t(y) * std::size_t(fragment.width) + x]; + if (cell.material == MaterialId::Air) continue; + impl_->put(originX + x, originY + y, cell, impl_->tick); + impl_->wakeAround(originX + x, originY + y); + } + if (cellsPlaced != 0) ++impl_->revision; + receipt.revisionAfter = impl_->revision; + return eve::Result::success(receipt); +} + +PixelWorldLink PixelWorld::worldLink() const noexcept { return {impl_->worldId, impl_->epoch}; } + +void PixelWorld::clear() noexcept { + impl_->chunks.clear(); + impl_->removedChunks.clear(); + impl_->revision = 0; + impl_->tick = 0; + impl_->lastEditSequence = 0; + ++impl_->epoch; +} + +eve::Result PixelWorld::advance(eve::SimulationTick tick) { + if (impl_->paused) return eve::Result::success({eve::SimulationTick(impl_->tick)}); + return advanceImpl(tick, nullptr); +} + +eve::Result PixelWorld::advanceScheduled(eve::SimulationTick tick, + PixelWorkScheduler& scheduler) { + if (impl_->paused) return eve::Result::success({eve::SimulationTick(impl_->tick)}); + return advanceImpl(tick, &scheduler); +} + +eve::Result PixelWorld::advanceImpl(eve::SimulationTick tick, + PixelWorkScheduler* scheduler) { + if (tick.value() <= impl_->tick) + return eve::Result::failure(eve::Diagnostic::error( + eve::DiagnosticCode::PreconditionViolation, + "PixelWorld ticks must increase monotonically", "tick", {}, "pixelworld")); + + const auto stepStarted = std::chrono::steady_clock::now(); + impl_->tick = tick.value(); + StepStats stats; + stats.tick = tick; + std::vector order; + order.reserve(impl_->chunks.size()); + for (auto& [coord, chunk] : impl_->chunks) { + if (chunk.active) order.push_back(coord); + chunk.touched = false; + } + std::sort(order.begin(), order.end()); + + struct ChunkPhaseSummary { + std::uint32_t nonAir = 0; + std::uint32_t mobile = 0; + std::uint32_t thermalRemainderCells = 0; + std::uint64_t materialMask = 0; + std::int16_t minimumTemperature = 32767; + std::int16_t maximumTemperature = -32768; + bool materialMaskOverflow = false; + }; + const auto summarize = [](const Chunk& chunk) { + ChunkPhaseSummary summary; + summary.nonAir = chunk.nonAir; + summary.mobile = chunk.mobile; + summary.thermalRemainderCells = chunk.thermalRemainderCells; + summary.minimumTemperature = chunk.minimumTemperature; + summary.maximumTemperature = chunk.maximumTemperature; + summary.materialMaskOverflow = chunk.materialOverflow != 0; + for (std::size_t material = 0; material < chunk.materialCounts.size(); ++material) + if (chunk.materialCounts[material] != 0) + summary.materialMask |= std::uint64_t(1) << material; + return summary; + }; + std::unordered_map phaseSummaries; + const auto rebuildSummaries = [&] { + phaseSummaries.clear(); + phaseSummaries.reserve(impl_->chunks.size()); + for (auto& [coord, chunk] : impl_->chunks) { + impl_->refreshTemperatureBounds(chunk); + phaseSummaries.emplace(coord, summarize(chunk)); + } + }; + rebuildSummaries(); + + struct MovementRows { + std::array mobile{}; + std::array rightFirst{}; + }; + std::vector movementCandidates(order.size()); + std::vector movementWork; + movementWork.reserve(order.size()); + for (std::size_t index = 0; index < order.size(); ++index) { + const auto summary = phaseSummaries.find(order[index]); + if (summary != phaseSummaries.end() && summary->second.mobile != 0) + movementWork.push_back(index); + } + const auto generateMovementCandidates = [&](std::size_t workIndex) { + const std::size_t orderIndex = movementWork[workIndex]; + const ChunkCoord coord = order[orderIndex]; + auto& candidates = movementCandidates[orderIndex]; + candidates.mobile.fill(0); + candidates.rightFirst.fill(0); + const auto found = impl_->chunks.find(coord); + if (found == impl_->chunks.end()) return; + const Chunk& chunk = found->second; + for (int ly = kPixelChunkSize - 1; ly >= 0; --ly) { + for (int lx = 0; lx < kPixelChunkSize; ++lx) { + const auto cellIndex = Chunk::index(lx, ly); + if (chunk.updated[cellIndex] == tick.value()) continue; + const PixelCell cell = chunk.cells[cellIndex]; + const auto state = impl_->materialState(cell.material); + if (state != MaterialState::Powder && state != MaterialState::Liquid) continue; + const int x = coord.x * kPixelChunkSize + lx; + const int y = coord.y * kPixelChunkSize + ly; + const int direction = impl_->pseudoBit(x, y, tick.value()) ? 1 : -1; + const std::uint64_t bit = std::uint64_t(1) << lx; + candidates.mobile[std::size_t(ly)] |= bit; + if (direction > 0) candidates.rightFirst[std::size_t(ly)] |= bit; + } + } + }; + if (scheduler && movementWork.size() > 1) { + scheduler->parallelFor(movementWork.size(), generateMovementCandidates); + stats.parallelTasks += std::uint32_t(movementWork.size()); + } else { + for (std::size_t index = 0; index < movementWork.size(); ++index) + generateMovementCandidates(index); + } + for (std::size_t orderIndex = 0; orderIndex < order.size(); ++orderIndex) { + ++stats.chunksVisited; + const ChunkCoord sourceCoord = order[orderIndex]; + auto sourceFound = impl_->chunks.find(sourceCoord); + if (sourceFound == impl_->chunks.end()) continue; + Chunk& sourceChunk = sourceFound->second; + const MovementRows& candidates = movementCandidates[orderIndex]; + for (int sourceLocalY = kPixelChunkSize - 1; sourceLocalY >= 0; --sourceLocalY) { + std::uint64_t remaining = candidates.mobile[std::size_t(sourceLocalY)]; + const bool leftFirst = ((tick.value() + std::uint64_t(sourceLocalY)) & 1U) == 0; + while (remaining != 0) { + const int sourceLocalX = leftFirst + ? int(std::countr_zero(remaining)) + : 63 - int(std::countl_zero(remaining)); + const std::uint64_t candidateBit = std::uint64_t(1) << sourceLocalX; + remaining &= ~candidateBit; + const int direction = + (candidates.rightFirst[std::size_t(sourceLocalY)] & candidateBit) != 0 ? 1 : -1; + const auto sourceIndex = Chunk::index(sourceLocalX, sourceLocalY); + if (sourceChunk.updated[sourceIndex] == tick.value()) continue; + const PixelCell cell = sourceChunk.cells[sourceIndex]; + const auto state = impl_->materialState(cell.material); + if (state != MaterialState::Powder && state != MaterialState::Liquid) continue; + ++stats.cellsVisited; + const auto tryMove = [&](int targetLocalX, int targetLocalY) { + if (targetLocalX >= 0 && targetLocalX < kPixelChunkSize && + targetLocalY >= 0 && targetLocalY < kPixelChunkSize) + return impl_->moveOrSwapWithinChunk( + sourceCoord, sourceChunk, sourceLocalX, sourceLocalY, cell, + targetLocalX, targetLocalY, tick.value()); + const int sourceX = sourceCoord.x * kPixelChunkSize + sourceLocalX; + const int sourceY = sourceCoord.y * kPixelChunkSize + sourceLocalY; + return impl_->moveOrSwapKnownSource( + sourceX, sourceY, cell, + sourceCoord.x * kPixelChunkSize + targetLocalX, + sourceCoord.y * kPixelChunkSize + targetLocalY, tick.value()); + }; + bool moved = tryMove(sourceLocalX, sourceLocalY + 1); + if (!moved) + moved = tryMove(sourceLocalX + direction, sourceLocalY + 1); + if (!moved) + moved = tryMove(sourceLocalX - direction, sourceLocalY + 1); + if (!moved && state == MaterialState::Liquid) + moved = tryMove(sourceLocalX + direction, sourceLocalY); + if (!moved && state == MaterialState::Liquid) + moved = tryMove(sourceLocalX - direction, sourceLocalY); + if (moved) { + ++stats.cellsMoved; + stats.cellsChanged += 2; + } + } + } + } + + if (stats.cellsMoved != 0) rebuildSummaries(); + + // Thermal phase: calculate all pair transfers from the same pre-phase state, + // then apply deltas in canonical coordinate order. Integer arithmetic makes + // the reference backend bit-exact and independent of unordered-map order. + struct ThermalChunkContributions { + std::array local{}; + std::array rightHalo{}; + std::array bottomHalo{}; + }; + std::vector thermalCandidates(order.size()); + std::vector transferCounts(order.size()); + std::vector transferEnergy(order.size()); + const auto hasPossibleThermalGradient = [&](ChunkCoord coord) { + const auto ownSummary = phaseSummaries.find(coord); + if (ownSummary == phaseSummaries.end() || ownSummary->second.nonAir == 0) return false; + const auto& own = ownSummary->second; + if (own.thermalRemainderCells != 0) return true; + if (own.minimumTemperature != own.maximumTemperature) return true; + for (const auto [dx, dy] : + std::array, 4>{{{-1, 0}, {1, 0}, {0, -1}, {0, 1}}}) { + const auto neighbor = phaseSummaries.find({coord.x + dx, coord.y + dy}); + if (neighbor == phaseSummaries.end() || neighbor->second.nonAir == 0) continue; + if (neighbor->second.minimumTemperature != own.minimumTemperature || + neighbor->second.maximumTemperature != own.maximumTemperature) + return true; + } + return false; + }; + std::vector thermalWork; + thermalWork.reserve(order.size()); + for (std::size_t index = 0; index < order.size(); ++index) + if (hasPossibleThermalGradient(order[index])) thermalWork.push_back(index); + const auto& readOnlyChunks = impl_->chunks; + const auto calculateThermalChunk = [&](std::size_t workIndex) { + const std::size_t orderIndex = thermalWork[workIndex]; + const ChunkCoord coord = order[orderIndex]; + const auto ownFound = readOnlyChunks.find(coord); + if (ownFound == readOnlyChunks.end()) return; + const Chunk& ownChunk = ownFound->second; + const auto rightFound = readOnlyChunks.find({coord.x + 1, coord.y}); + const auto bottomFound = readOnlyChunks.find({coord.x, coord.y + 1}); + const Chunk* rightChunk = rightFound == readOnlyChunks.end() ? nullptr : &rightFound->second; + const Chunk* bottomChunk = bottomFound == readOnlyChunks.end() ? nullptr : &bottomFound->second; + auto& candidates = thermalCandidates[orderIndex]; + transferCounts[orderIndex] = 0; + transferEnergy[orderIndex] = 0; + for (int ly = 0; ly < kPixelChunkSize; ++ly) + for (int lx = 0; lx < kPixelChunkSize; ++lx) { + const PixelCell source = ownChunk.cells[Chunk::index(lx, ly)]; + if (source.material == MaterialId::Air) continue; + for (const auto [ox, oy] : std::array, 2>{{{1, 0}, {0, 1}}}) { + PixelCell target; + if (ox != 0 && lx == kPixelChunkSize - 1) { + if (rightChunk == nullptr) continue; + target = rightChunk->cells[Chunk::index(0, ly)]; + } else if (oy != 0 && ly == kPixelChunkSize - 1) { + if (bottomChunk == nullptr) continue; + target = bottomChunk->cells[Chunk::index(lx, 0)]; + } else { + target = ownChunk.cells[Chunk::index(lx + ox, ly + oy)]; + } + if (target.material == MaterialId::Air) continue; + const int conductivity = std::min( + impl_->thermalConductivity(source.material), + impl_->thermalConductivity(target.material)); + if (conductivity == 0) continue; + const std::int64_t sourceCapacity = impl_->heatCapacity(source.material); + const std::int64_t targetCapacity = impl_->heatCapacity(target.material); + const std::int64_t sourceEnergy = + std::int64_t(source.temperature) * sourceCapacity + source.thermalRemainder; + const std::int64_t targetEnergy = + std::int64_t(target.temperature) * targetCapacity + target.thermalRemainder; + const std::int64_t equilibriumTransfer = + (targetEnergy * sourceCapacity - sourceEnergy * targetCapacity) / + (sourceCapacity + targetCapacity); + if (equilibriumTransfer == 0) continue; + std::int64_t transfer = equilibriumTransfer * conductivity / 255; + if (transfer == 0) transfer = equilibriumTransfer < 0 ? -1 : 1; + const std::size_t sourceIndex = Chunk::index(lx, ly); + candidates.local[sourceIndex] += transfer; + if (ox != 0 && lx == kPixelChunkSize - 1) + candidates.rightHalo[std::size_t(ly)] -= transfer; + else if (oy != 0 && ly == kPixelChunkSize - 1) + candidates.bottomHalo[std::size_t(lx)] -= transfer; + else + candidates.local[Chunk::index(lx + ox, ly + oy)] -= transfer; + ++transferCounts[orderIndex]; + transferEnergy[orderIndex] += std::uint64_t(transfer < 0 ? -transfer : transfer); + } + } + }; + if (scheduler && thermalWork.size() > 1) { + scheduler->parallelFor(thermalWork.size(), calculateThermalChunk); + stats.parallelTasks += std::uint32_t(thermalWork.size()); + } else { + for (std::size_t index = 0; index < thermalWork.size(); ++index) calculateThermalChunk(index); + } + using ThermalDeltaChunk = std::array; + std::unordered_map thermalEnergyDeltas; + thermalEnergyDeltas.reserve(order.size()); + for (std::size_t index = 0; index < thermalCandidates.size(); ++index) { + stats.temperatureTransfers += transferCounts[index]; + stats.thermalEnergyTransferred += transferEnergy[index]; + const ChunkCoord coord = order[index]; + const ThermalChunkContributions& contributions = thermalCandidates[index]; + auto& localDeltas = thermalEnergyDeltas[coord]; + for (std::size_t cellIndex = 0; cellIndex < contributions.local.size(); ++cellIndex) + localDeltas[cellIndex] += contributions.local[cellIndex]; + auto& rightDeltas = thermalEnergyDeltas[{coord.x + 1, coord.y}]; + auto& bottomDeltas = thermalEnergyDeltas[{coord.x, coord.y + 1}]; + for (std::size_t offset = 0; offset < kPixelChunkSize; ++offset) { + rightDeltas[Chunk::index(0, int(offset))] += contributions.rightHalo[offset]; + bottomDeltas[Chunk::index(int(offset), 0)] += contributions.bottomHalo[offset]; + } + } + std::vector thermalChunkOrder; + thermalChunkOrder.reserve(thermalEnergyDeltas.size()); + for (const auto& [coord, deltas] : thermalEnergyDeltas) { + (void)deltas; + thermalChunkOrder.push_back(coord); + } + std::sort(thermalChunkOrder.begin(), thermalChunkOrder.end()); + struct ThermalCommitStats { + std::uint32_t cellsChanged = 0; + std::uint64_t energyClamped = 0; + }; + std::vector thermalCommitChunks; + std::vector thermalCommitDeltas; + thermalCommitChunks.reserve(thermalChunkOrder.size()); + thermalCommitDeltas.reserve(thermalChunkOrder.size()); + for (const ChunkCoord coord : thermalChunkOrder) { + auto chunkFound = impl_->chunks.find(coord); + if (chunkFound == impl_->chunks.end()) continue; + thermalCommitChunks.push_back(&chunkFound->second); + thermalCommitDeltas.push_back(&thermalEnergyDeltas.at(coord)); + } + std::vector thermalCommitStats(thermalCommitChunks.size()); + const auto commitThermalChunk = [&](std::size_t index) { + Chunk& chunk = *thermalCommitChunks[index]; + const ThermalDeltaChunk& deltas = *thermalCommitDeltas[index]; + ThermalCommitStats& commitStats = thermalCommitStats[index]; + for (int localY = 0; localY < kPixelChunkSize; ++localY) + for (int localX = 0; localX < kPixelChunkSize; ++localX) { + const std::size_t cellIndex = Chunk::index(localX, localY); + const std::int64_t energyDelta = deltas[cellIndex]; + if (energyDelta == 0) continue; + PixelCell cell = chunk.cells[cellIndex]; + const std::int64_t capacity = + impl_->catalog.definition(cell.material).heatCapacity; + const std::int64_t beforeEnergy = + std::int64_t(cell.temperature) * capacity + cell.thermalRemainder; + const std::int64_t requestedEnergy = beforeEnergy + energyDelta; + std::int64_t temperature = requestedEnergy / capacity; + std::int64_t remainder = requestedEnergy % capacity; + if (remainder < 0) { + --temperature; + remainder += capacity; + } + if (temperature < std::numeric_limits::min()) { + temperature = std::numeric_limits::min(); + remainder = 0; + } else if (temperature > std::numeric_limits::max()) { + temperature = std::numeric_limits::max(); + remainder = capacity - 1; + } + cell.temperature = std::int16_t(temperature); + cell.thermalRemainder = std::uint16_t(remainder); + const std::int64_t appliedEnergy = + std::int64_t(cell.temperature) * capacity + cell.thermalRemainder; + if (appliedEnergy != requestedEnergy) + commitStats.energyClamped += std::uint64_t( + appliedEnergy > requestedEnergy ? appliedEnergy - requestedEnergy + : requestedEnergy - appliedEnergy); + if (appliedEnergy == beforeEnergy) continue; + impl_->putNormalizedWithinChunk(chunk, cellIndex, cell, tick.value(), false); + ++commitStats.cellsChanged; + } + }; + if (scheduler && thermalCommitChunks.size() > 1) { + scheduler->parallelFor(thermalCommitChunks.size(), commitThermalChunk); + stats.parallelTasks += std::uint32_t(thermalCommitChunks.size()); + } else { + for (std::size_t index = 0; index < thermalCommitChunks.size(); ++index) + commitThermalChunk(index); + } + for (const ThermalCommitStats& commitStats : thermalCommitStats) { + stats.cellsChanged += commitStats.cellsChanged; + stats.thermalEnergyClamped += commitStats.energyClamped; + } + + // Phase transitions are table-driven and choose the first canonical rule. + for (const ChunkCoord coord : order) { + if (impl_->chunks.find(coord) == impl_->chunks.end()) continue; + const auto summary = phaseSummaries.find(coord); + if (summary != phaseSummaries.end() && !summary->second.materialMaskOverflow) { + bool mayTransition = false; + for (const auto& rule : impl_->catalog.phaseRules()) { + const auto material = std::size_t(rule.source); + if (material >= 64 || + (summary->second.materialMask & (std::uint64_t(1) << material)) == 0) + continue; + const bool temperatureMayMatch = + rule.direction == TemperatureDirection::AtOrAbove + ? summary->second.maximumTemperature >= rule.threshold + : summary->second.minimumTemperature <= rule.threshold; + if (temperatureMayMatch) { + mayTransition = true; + break; + } + } + if (!mayTransition) continue; + } + for (int ly = 0; ly < kPixelChunkSize; ++ly) + for (int lx = 0; lx < kPixelChunkSize; ++lx) { + const int x = coord.x * kPixelChunkSize + lx; + const int y = coord.y * kPixelChunkSize + ly; + PixelCell cell = impl_->get(x, y); + if (cell.material == MaterialId::Air || !impl_->catalog.canPhaseTransition(cell.material)) + continue; + for (const auto& rule : impl_->catalog.phaseRules()) { + if (rule.source != cell.material) continue; + const bool matches = rule.direction == TemperatureDirection::AtOrAbove + ? cell.temperature >= rule.threshold + : cell.temperature <= rule.threshold; + if (!matches) continue; + cell.material = rule.result; + cell.temperature = std::int16_t(std::clamp( + int(cell.temperature) + rule.temperatureDelta, -32768, 32767)); + cell.lifetime = std::uint8_t(std::min( + impl_->catalog.definition(cell.material).defaultLifetime, + std::numeric_limits::max())); + impl_->put(x, y, cell, tick.value()); + ++stats.phaseChanges; + ++stats.cellsChanged; + break; + } + } + } + + if (stats.phaseChanges != 0) rebuildSummaries(); + + std::uint64_t worldMaterialMask = 0; + bool worldMaterialMaskOverflow = false; + for (const auto& [coord, summary] : phaseSummaries) { + (void)coord; + worldMaterialMask |= summary.materialMask; + worldMaterialMaskOverflow = worldMaterialMaskOverflow || summary.materialMaskOverflow; + } + bool worldCanReact = worldMaterialMaskOverflow; + if (!worldCanReact) + for (const auto& rule : impl_->catalog.reactions()) { + const auto first = std::size_t(rule.first), second = std::size_t(rule.second); + if (first >= 64 || second >= 64 || + ((worldMaterialMask & (std::uint64_t(1) << first)) != 0 && + (worldMaterialMask & (std::uint64_t(1) << second)) != 0)) { + worldCanReact = true; + break; + } + } + + for (auto it = order.rbegin(); it != order.rend(); ++it) { + const ChunkCoord coord = *it; + auto sourceFound = impl_->chunks.find(coord); + if (sourceFound == impl_->chunks.end()) continue; + Chunk* sourceChunk = &sourceFound->second; + const auto summary = phaseSummaries.find(coord); + if (summary != phaseSummaries.end() && !summary->second.materialMaskOverflow) { + bool requiresScan = worldCanReact; + if (!requiresScan) { + for (std::size_t material = 0; material < 64; ++material) { + if ((summary->second.materialMask & (std::uint64_t(1) << material)) == 0) continue; + const auto state = impl_->catalog.definition(MaterialId(material)).state; + if (state == MaterialState::Gas || state == MaterialState::Energy) { + requiresScan = true; + break; + } + } + } + if (!requiresScan) continue; + } + for (int ly = 0; ly < kPixelChunkSize; ++ly) + for (int lx = 0; lx < kPixelChunkSize; ++lx) { + const int x = coord.x * kPixelChunkSize + lx; + const int y = coord.y * kPixelChunkSize + ly; + const std::size_t sourceIndex = Chunk::index(lx, ly); + if (sourceChunk->updated[sourceIndex] == tick.value()) continue; + PixelCell cell = sourceChunk->cells[sourceIndex]; + if (cell.material == MaterialId::Air) continue; + ++stats.cellsVisited; + bool reacted = false; + if (impl_->catalog.canReact(cell.material)) + for (const auto [ox, oy] : + std::array, 4>{{{1, 0}, {-1, 0}, {0, 1}, {0, -1}}}) { + const int neighborLocalX = lx + ox; + const int neighborLocalY = ly + oy; + const bool sameChunk = neighborLocalX >= 0 && + neighborLocalX < kPixelChunkSize && + neighborLocalY >= 0 && + neighborLocalY < kPixelChunkSize; + const std::size_t neighborIndex = + sameChunk ? Chunk::index(neighborLocalX, neighborLocalY) : 0; + PixelCell neighbor = sameChunk ? sourceChunk->cells[neighborIndex] + : impl_->get(x + ox, y + oy); + for (const std::uint16_t ruleIndex : + impl_->reactionRules(cell.material, neighbor.material)) { + const auto& rule = impl_->catalog.reactions()[ruleIndex]; + const bool direct = rule.first == cell.material && rule.second == neighbor.material; + const bool reverse = rule.second == cell.material && rule.first == neighbor.material; + if ((!direct && !reverse) || + std::max(cell.temperature, neighbor.temperature) < rule.minimumTemperature) + continue; + cell.material = direct ? rule.firstResult : rule.secondResult; + neighbor.material = direct ? rule.secondResult : rule.firstResult; + cell.temperature = std::int16_t(std::clamp(int(cell.temperature) + rule.heatDelta, + -32768, 32767)); + neighbor.temperature = std::int16_t(std::clamp( + int(neighbor.temperature) + rule.heatDelta, -32768, 32767)); + if (cell.lifetime == 0) + cell.lifetime = std::uint8_t(std::min( + impl_->catalog.definition(cell.material).defaultLifetime, + std::numeric_limits::max())); + if (neighbor.lifetime == 0) + neighbor.lifetime = std::uint8_t(std::min( + impl_->catalog.definition(neighbor.material).defaultLifetime, + std::numeric_limits::max())); + if (sameChunk) { + impl_->putWithinChunk(*sourceChunk, neighborIndex, neighbor, + tick.value()); + } else { + impl_->put(x + ox, y + oy, neighbor, tick.value()); + // A custom rule may materialize an absent cross-boundary + // Air cell and rehash the sparse Chunk map. + sourceFound = impl_->chunks.find(coord); + if (sourceFound == impl_->chunks.end()) break; + sourceChunk = &sourceFound->second; + } + reacted = true; + ++stats.cellsChanged; + break; + } + } + if (reacted) ++stats.reactions; + const auto state = impl_->catalog.definition(cell.material).state; + if (reacted) + impl_->putWithinChunk(*sourceChunk, sourceIndex, cell, tick.value()); + if (state != MaterialState::Gas && state != MaterialState::Energy) continue; + if (cell.lifetime > 0) { + --cell.lifetime; + ++stats.cellsChanged; + if (cell.lifetime == 0) cell.material = MaterialId::Air; + impl_->put(x, y, cell, tick.value()); + } + if (cell.material != MaterialId::Air) { + const int direction = impl_->pseudoBit(x, y, tick.value()) ? 1 : -1; + bool moved = impl_->moveOrSwap(x, y, x, y - 1, tick.value()); + if (!moved) moved = impl_->moveOrSwap(x, y, x + direction, y - 1, tick.value()); + if (moved) { + ++stats.cellsMoved; + stats.cellsChanged += 2; + } + sourceFound = impl_->chunks.find(coord); + if (sourceFound == impl_->chunks.end()) break; + sourceChunk = &sourceFound->second; + } + } + } + + std::vector reclaimed; + for (auto& [coord, chunk] : impl_->chunks) { + if (chunk.touched) { + chunk.idleTicks = 0; + chunk.active = true; + } else { + chunk.idleTicks = std::uint8_t(std::min(kPixelSleepHysteresisTicks, chunk.idleTicks + 1)); + chunk.active = chunk.idleTicks < kPixelSleepHysteresisTicks; + } + if (!chunk.active && chunk.nonAir == 0) + reclaimed.push_back(coord); + } + for (const ChunkCoord coord : reclaimed) { + impl_->chunks.erase(coord); + impl_->removedChunks[coord] = impl_->revision + 1; + ++stats.chunksReclaimed; + } + if (stats.cellsChanged != 0 || stats.chunksReclaimed != 0) ++impl_->revision; + const auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - stepStarted) + .count(); + pixelWorldControlService().recordStep(impl_->worldId, stats, std::uint64_t(elapsed)); + return eve::Result::success(stats); +} + +StepStats PixelWorld::step() { + auto result = advance(eve::SimulationTick(impl_->tick + 1)); + return std::move(result).expect("PixelWorld::step generated an invalid tick"); +} + +void PixelWorld::setPaused(bool paused) noexcept { impl_->paused = paused; } +bool PixelWorld::isPaused() const noexcept { return impl_->paused; } + +std::uint64_t PixelWorld::seed() const noexcept { return impl_->seed; } +std::uint64_t PixelWorld::revision() const noexcept { return impl_->revision; } +std::uint64_t PixelWorld::tickValue() const noexcept { return impl_->tick; } +std::uint64_t PixelWorld::lastEditSequence() const noexcept { return impl_->lastEditSequence; } +int PixelWorld::chunkCount() const noexcept { return int(impl_->chunks.size()); } +int PixelWorld::activeChunkCount() const noexcept { + return int(std::count_if(impl_->chunks.begin(), impl_->chunks.end(), [](const auto& entry) { + return entry.second.active; + })); +} +std::uint64_t PixelWorld::materialCatalogFingerprint() const noexcept { return impl_->catalog.fingerprint(); } + +std::vector PixelWorld::snapshotChangedChunks(std::uint64_t sinceRevision) const { + std::vector order; + order.reserve(impl_->chunks.size()); + for (const auto& [coord, chunk] : impl_->chunks) + if (chunk.revision > sinceRevision) order.push_back(coord); + for (const auto& [coord, revision] : impl_->removedChunks) + if (revision > sinceRevision) order.push_back(coord); + std::sort(order.begin(), order.end()); + + std::vector snapshots; + snapshots.reserve(order.size()); + for (const ChunkCoord coord : order) { + PixelChunkSnapshot snapshot; + snapshot.x = coord.x; + snapshot.y = coord.y; + const auto chunk = impl_->chunks.find(coord); + if (chunk != impl_->chunks.end()) { + snapshot.revision = chunk->second.revision; + snapshot.cells.assign(chunk->second.cells.begin(), chunk->second.cells.end()); + } else { + snapshot.revision = impl_->removedChunks.at(coord); + snapshot.removed = true; + } + snapshots.push_back(std::move(snapshot)); + } + return snapshots; +} + +eve::Result> PixelWorld::snapshotChunksInRegion( + PixelChunkRegion region, std::uint64_t sinceRevision) const { + if (region.minX > region.maxX || region.minY > region.maxY) + return eve::Result>::failure(eve::Diagnostic::error( + eve::DiagnosticCode::InvalidArgument, "Chunk region bounds are inverted", "region", {}, + "pixelworld.chunk-region")); + const std::uint64_t width = std::uint64_t(std::int64_t(region.maxX) - region.minX) + 1; + const std::uint64_t height = std::uint64_t(std::int64_t(region.maxY) - region.minY) + 1; + if (width > 65'536 || height > 65'536 || width * height > 65'536) + return eve::Result>::failure(eve::Diagnostic::error( + eve::DiagnosticCode::InvalidArgument, "Chunk region exceeds 65536-coordinate budget", "region", {}, + "pixelworld.chunk-region")); + + std::vector result; + for (int y = region.minY;; ++y) { + for (int x = region.minX;; ++x) { + const ChunkCoord coord{x, y}; + const auto present = impl_->chunks.find(coord); + if (present != impl_->chunks.end() && present->second.revision > sinceRevision) { + PixelChunkSnapshot snapshot; + snapshot.x = x; + snapshot.y = y; + snapshot.revision = present->second.revision; + snapshot.cells.assign(present->second.cells.begin(), present->second.cells.end()); + result.push_back(std::move(snapshot)); + } else { + const auto removed = impl_->removedChunks.find(coord); + if (removed != impl_->removedChunks.end() && removed->second > sinceRevision) + result.push_back({x, y, removed->second, true, {}}); + } + if (x == region.maxX) break; + } + if (y == region.maxY) break; + } + return eve::Result>::success(std::move(result)); +} + +eve::Result> PixelWorld::chunkDiagnostics( + PixelChunkRegion region) const { + if (region.minX > region.maxX || region.minY > region.maxY) + return eve::Result>::failure(eve::Diagnostic::error( + eve::DiagnosticCode::InvalidArgument, "Chunk region bounds are inverted", "region", {}, + "pixelworld.chunk-diagnostics")); + const std::uint64_t width = std::uint64_t(std::int64_t(region.maxX) - region.minX) + 1; + const std::uint64_t height = std::uint64_t(std::int64_t(region.maxY) - region.minY) + 1; + if (width > 65'536 || height > 65'536 || width * height > 65'536) + return eve::Result>::failure(eve::Diagnostic::error( + eve::DiagnosticCode::InvalidArgument, "Chunk region exceeds 65536-coordinate budget", "region", {}, + "pixelworld.chunk-diagnostics")); + std::vector result; + for (int y = region.minY;; ++y) { + for (int x = region.minX;; ++x) { + const auto found = impl_->chunks.find({x, y}); + if (found != impl_->chunks.end()) { + const Chunk& chunk = found->second; + PixelChunkDiagnostic diagnostic{x, y, chunk.revision, chunk.nonAir, chunk.mobile, + 20, 20, chunk.idleTicks, chunk.active}; + if (chunk.nonAir != 0) { + diagnostic.minimumTemperature = 32767; + diagnostic.maximumTemperature = -32768; + for (const PixelCell cell : chunk.cells) { + if (cell.material == MaterialId::Air) continue; + diagnostic.minimumTemperature = std::min(diagnostic.minimumTemperature, + cell.temperature); + diagnostic.maximumTemperature = std::max(diagnostic.maximumTemperature, + cell.temperature); + } + } + result.push_back(diagnostic); + } + if (x == region.maxX) break; + } + if (y == region.maxY) break; + } + return eve::Result>::success(std::move(result)); +} + +eve::Result PixelWorld::applyChunkBatch( + const PixelChunkBatch& batch, std::uint64_t expectedRevision) { + const auto reject = [](eve::DiagnosticCode code, std::string message, std::string path) { + return eve::Result::failure(eve::Diagnostic::error( + code, std::move(message), std::move(path), {}, "pixelworld.chunk-batch")); + }; + if (impl_->revision != expectedRevision) + return reject(eve::DiagnosticCode::Conflict, "local world revision is stale", "expectedRevision"); + if (batch.catalogFingerprint != impl_->catalog.fingerprint()) + return reject(eve::DiagnosticCode::Conflict, "material Catalog fingerprint does not match", + "catalogFingerprint"); + if (batch.sourceSeed != impl_->seed) + return reject(eve::DiagnosticCode::Conflict, "deterministic world seed does not match", "sourceSeed"); + if (!batch.fullResync && + (batch.sourceRevision < impl_->revision || batch.sourceTick.value() < impl_->tick || + batch.sourceLastEditSequence < impl_->lastEditSequence)) + return reject(eve::DiagnosticCode::Conflict, "authoritative metadata must not rewind local state", + "source"); + if (batch.chunks.size() > 1'000'000U) + return reject(eve::DiagnosticCode::InvalidArgument, "Chunk batch exceeds admission budget", "chunks"); + + std::optional previous; + for (std::size_t index = 0; index < batch.chunks.size(); ++index) { + const auto& snapshot = batch.chunks[index]; + const ChunkCoord coord{snapshot.x, snapshot.y}; + if (previous && !(previous.value() < coord)) + return reject(eve::DiagnosticCode::InvalidArgument, + "Chunk batch must be unique canonical y/x order", "chunks"); + previous = coord; + if (snapshot.revision == 0 || snapshot.revision > batch.sourceRevision) + return reject(eve::DiagnosticCode::InvalidArgument, + "Chunk revision must be positive and no newer than source", "chunks.revision"); + if (snapshot.removed) { + if (!snapshot.cells.empty()) + return reject(eve::DiagnosticCode::InvalidArgument, + "removed Chunk must not contain cells", "chunks.cells"); + continue; + } + if (snapshot.cells.size() != std::size_t(kPixelChunkSize * kPixelChunkSize)) + return reject(eve::DiagnosticCode::InvalidArgument, + "present Chunk must contain exactly 64x64 cells", "chunks.cells"); + for (const PixelCell cell : snapshot.cells) { + if (std::size_t(cell.material) >= impl_->catalog.definitions().size()) + return reject(eve::DiagnosticCode::InvalidArgument, + "Chunk references an unknown material", "chunks.cells.material"); + const auto capacity = impl_->catalog.definition(cell.material).heatCapacity; + if ((cell.material == MaterialId::Air && cell.thermalRemainder != 0) || + cell.thermalRemainder >= capacity) + return reject(eve::DiagnosticCode::InvalidArgument, + "Chunk contains a non-canonical thermal remainder", + "chunks.cells.thermalRemainder"); + } + } + + Impl candidate = batch.fullResync ? Impl(impl_->catalog) : *impl_; + if (batch.fullResync) { + candidate.worldId = impl_->worldId; + candidate.epoch = impl_->epoch; + candidate.nextFragmentId = impl_->nextFragmentId; + candidate.paused = impl_->paused; + } + PixelChunkApplyReceipt receipt; + receipt.revisionBefore = impl_->revision; + for (const auto& snapshot : batch.chunks) { + const ChunkCoord coord{snapshot.x, snapshot.y}; + if (snapshot.removed) { + candidate.chunks.erase(coord); + candidate.removedChunks[coord] = snapshot.revision; + ++receipt.chunksRemoved; + continue; + } + Chunk chunk; + std::copy(snapshot.cells.begin(), snapshot.cells.end(), chunk.cells.begin()); + chunk.revision = snapshot.revision; + chunk.active = true; + chunk.touched = true; + chunk.idleTicks = 0; + candidate.rebuildActivity(chunk); + candidate.chunks.insert_or_assign(coord, std::move(chunk)); + candidate.removedChunks.erase(coord); + ++receipt.chunksReplaced; + } + candidate.revision = batch.sourceRevision; + candidate.tick = batch.sourceTick.value(); + candidate.lastEditSequence = batch.sourceLastEditSequence; + ++candidate.epoch; + receipt.revisionAfter = candidate.revision; + receipt.worldEpoch = candidate.epoch; + *impl_ = std::move(candidate); + return eve::Result::success(receipt); +} + +eve::Result> PixelWorld::saveSnapshot() const { + std::vector order; + order.reserve(impl_->chunks.size()); + for (const auto& [coord, chunk] : impl_->chunks) { + (void)chunk; + order.push_back(coord); + } + std::sort(order.begin(), order.end()); + std::vector out; + out.insert(out.end(), {std::byte{0x45}, std::byte{0x56}, std::byte{0x50}, std::byte{0x57}}); + append(out, 4); + append(out, impl_->catalog.fingerprint()); + append(out, impl_->seed); + append(out, impl_->revision); + append(out, impl_->tick); + append(out, impl_->lastEditSequence); + append(out, std::uint32_t(order.size())); + for (const ChunkCoord coord : order) { + append(out, coord.x); + append(out, coord.y); + const Chunk& chunk = impl_->chunks.at(coord); + for (const PixelCell cell : chunk.cells) { + append(out, std::uint16_t(cell.material)); + append(out, cell.temperature); + append(out, cell.lifetime); + append(out, cell.thermalRemainder); + } + } + return eve::Result>::success(std::move(out)); +} + +eve::Result PixelWorld::restoreSnapshot(std::span bytes) { + std::size_t cursor = 0; + if (bytes.size() < 4 || bytes[0] != std::byte{0x45} || bytes[1] != std::byte{0x56} || + bytes[2] != std::byte{0x50} || bytes[3] != std::byte{0x57}) + return eve::Result::failure(malformed("missing EVPW header")); + cursor = 4; + std::uint16_t version = 0; + Impl candidate(impl_->catalog); + std::uint32_t count = 0; + if (!read(bytes, cursor, version) || + (version != 1 && version != 2 && version != 3 && version != 4)) + return eve::Result::failure(malformed("unsupported pixelworld schema version")); + if (version >= 2) { + std::uint64_t fingerprint = 0; + if (!read(bytes, cursor, fingerprint) || fingerprint != impl_->catalog.fingerprint()) + return eve::Result::failure(malformed("material catalog fingerprint mismatch")); + } else if (impl_->catalog.fingerprint() != MaterialCatalog::builtIn().fingerprint()) { + return eve::Result::failure(malformed("version-1 snapshots require the built-in material catalog")); + } + if (!read(bytes, cursor, candidate.seed) || !read(bytes, cursor, candidate.revision) || + !read(bytes, cursor, candidate.tick)) + return eve::Result::failure(malformed("truncated or unreasonable snapshot header")); + if (version >= 3 && !read(bytes, cursor, candidate.lastEditSequence)) + return eve::Result::failure(malformed("truncated edit sequence")); + if (!read(bytes, cursor, count) || count > 1'000'000U) + return eve::Result::failure(malformed("truncated or unreasonable chunk count")); + for (std::uint32_t i = 0; i < count; ++i) { + ChunkCoord coord; + if (!read(bytes, cursor, coord.x) || !read(bytes, cursor, coord.y)) + return eve::Result::failure(malformed("truncated chunk coordinate")); + Chunk chunk; + for (PixelCell& cell : chunk.cells) { + std::uint16_t material = 0; + if (!read(bytes, cursor, material) || !read(bytes, cursor, cell.temperature) || + !read(bytes, cursor, cell.lifetime) || material >= candidate.catalog.definitions().size()) + return eve::Result::failure(malformed("invalid chunk cell payload")); + cell.material = MaterialId(material); + if (version >= 4 && !read(bytes, cursor, cell.thermalRemainder)) + return eve::Result::failure(malformed("truncated thermal remainder")); + const auto capacity = candidate.catalog.definition(cell.material).heatCapacity; + if ((cell.material == MaterialId::Air && cell.thermalRemainder != 0) || + cell.thermalRemainder >= capacity) + return eve::Result::failure(malformed("non-canonical thermal remainder")); + } + const auto [ignored, inserted] = candidate.chunks.emplace(coord, std::move(chunk)); + (void)ignored; + if (!inserted) return eve::Result::failure(malformed("duplicate chunk coordinate")); + } + if (cursor != bytes.size()) return eve::Result::failure(malformed("trailing snapshot bytes")); + for (auto& [coord, chunk] : candidate.chunks) { + (void)coord; + candidate.rebuildActivity(chunk); + chunk.revision = candidate.revision; + } + candidate.worldId = impl_->worldId; + candidate.epoch = impl_->epoch + 1; + candidate.nextFragmentId = impl_->nextFragmentId; + candidate.paused = impl_->paused; + *impl_ = std::move(candidate); + return eve::Result::success(); +} + +} // namespace eve::pixelworld diff --git a/src/modules/pixelworld/PixelWorld.h b/src/modules/pixelworld/PixelWorld.h new file mode 100644 index 00000000..e9166034 --- /dev/null +++ b/src/modules/pixelworld/PixelWorld.h @@ -0,0 +1,361 @@ +#pragma once + +#include "common/Result.h" +#include "common/Time.h" +#include "pixelworld/PixelMaterial.h" + +#include +#include +#include +#include +#include +#include + +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& 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 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 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 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 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 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 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 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> 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 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 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 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 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> snapshotChunksInRegion( + PixelChunkRegion region, std::uint64_t sinceRevision = 0) const; + + /** @brief Query allocated Chunk diagnostics in a bounded inclusive region. */ + [[nodiscard]] eve::Result> 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 applyChunkBatch( + const PixelChunkBatch& batch, std::uint64_t expectedRevision); + + /** @brief Serialize schema `eve.pixelworld`, version 3, in canonical Chunk order. */ + [[nodiscard]] eve::Result> 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 restoreSnapshot(std::span bytes); + +private: + friend class PixelWorldControlService; + eve::Result advanceImpl(eve::SimulationTick tick, PixelWorkScheduler* scheduler); + struct Impl; + std::unique_ptr impl_; +}; + +} // namespace eve::pixelworld diff --git a/src/modules/pixelworld/PixelWorldAutomation.cpp b/src/modules/pixelworld/PixelWorldAutomation.cpp new file mode 100644 index 00000000..68c167ae --- /dev/null +++ b/src/modules/pixelworld/PixelWorldAutomation.cpp @@ -0,0 +1,274 @@ +#include "common/Capability.h" +#include "common/PixelWorldAutomation.h" +#include "pixelworld/PixelWorldControl.h" +#include "pixelworld/PixelMaterialCatalogCodec.h" + +#include +#include +#include +#include +#include + +#include +#include +#include + +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 +std::string failure(const eve::Result& 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(); + } catch (...) { + return Object::Ptr(new Object); + } +} + +std::string hex(std::span bytes) { + std::ostringstream out; + out << std::hex << std::setfill('0'); + for (const std::byte value : bytes) out << std::setw(2) << std::to_integer(value); + return out.str(); +} + +eve::Result> unhex(const std::string& value) { + if ((value.size() & 1U) != 0) + return eve::Result>::failure(eve::Diagnostic::error( + eve::DiagnosticCode::InvalidArgument, "snapshot hex length must be even", "snapshot", {}, + "pixelworld.automation")); + std::vector 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>::failure(eve::Diagnostic::error( + eve::DiagnosticCode::InvalidArgument, "snapshot contains non-hex data", "snapshot", {}, + "pixelworld.automation")); + bytes.push_back(std::byte(parsed)); + } + return eve::Result>::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() : 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() : stringify(value); + } + std::uint64_t expected = 0; + try { + expected = std::stoull(args->optValue("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("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("world", 0); + if (operation == "pause") { + auto result = control.setPaused(worldId, args->optValue("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("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("sequence", 0); + const std::string kind = args->optValue("kind", "paint"); + command.kind = kind == "heat" ? PixelEditKind::HeatCircle + : kind == "explosion" ? PixelEditKind::Explosion + : PixelEditKind::PaintCircle; + command.centerX = args->optValue("x", 0); + command.centerY = args->optValue("y", 0); + command.radius = args->optValue("radius", 0); + command.material = MaterialId(args->optValue("material", 0)); + command.strength = args->optValue("strength", 0); + command.temperatureDelta = std::int16_t(std::clamp( + args->optValue("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("minX", 0), args->optValue("minY", 0), + args->optValue("maxX", 0), args->optValue("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("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("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(&automation); } +void unregisterPixelWorldAutomation() { eve::cap::revoke(&automation); } + +} // namespace eve::pixelworld diff --git a/src/modules/pixelworld/PixelWorldControl.cpp b/src/modules/pixelworld/PixelWorldControl.cpp new file mode 100644 index 00000000..316ec657 --- /dev/null +++ b/src/modules/pixelworld/PixelWorldControl.cpp @@ -0,0 +1,176 @@ +#include "pixelworld/PixelWorldControl.h" + +#include "pixelworld/PixelMaterialCatalogCodec.h" + +#include +#include +#include +#include + +namespace eve::pixelworld { +namespace { + +template +eve::Result missingWorld() { + return eve::Result::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 worlds; + std::unordered_map> 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 PixelWorldControlService::worlds() const { + std::vector 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 PixelWorldControlService::world(std::uint64_t worldId) const { + const PixelWorld* value = find(worldId); + return value ? eve::Result::success(summarize(*value)) + : missingWorld(); +} + +eve::Result PixelWorldControlService::setPaused(std::uint64_t worldId, bool paused) { + PixelWorld* value = find(worldId); + if (!value) return missingWorld(); + value->setPaused(paused); + return eve::Result::success(); +} + +eve::Result PixelWorldControlService::step(std::uint64_t worldId, + std::uint32_t count) { + PixelWorld* value = find(worldId); + if (!value) return missingWorld(); + if (count == 0 || count > 1024) + return eve::Result::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::failure(advanced.status()); + receipt.finalStep = advanced.value(); + } + receipt.lastTick = value->tickValue(); + receipt.elapsedMicroseconds = std::uint64_t(std::chrono::duration_cast( + std::chrono::steady_clock::now() - start) + .count()); + return eve::Result::success(receipt); +} + +eve::Result PixelWorldControlService::applyEdit( + std::uint64_t worldId, const PixelEditCommand& command) { + PixelWorld* value = find(worldId); + return value ? value->applyEdit(command) : missingWorld(); +} + +eve::Result PixelWorldControlService::reloadMaterialCatalog( + std::uint64_t worldId, std::string_view catalogJson, + std::uint64_t expectedFingerprint) { + PixelWorld* value = find(worldId); + if (!value) return missingWorld(); + auto catalog = decodeMaterialCatalogJson(catalogJson); + if (!catalog.ok()) + return eve::Result::failure(catalog.status()); + return value->reloadMaterialCatalog(std::move(catalog).value(), expectedFingerprint); +} + +eve::Result> PixelWorldControlService::chunkDiagnostics( + std::uint64_t worldId, PixelChunkRegion region) const { + const PixelWorld* value = find(worldId); + return value ? value->chunkDiagnostics(region) + : missingWorld>(); +} + +eve::Result> +PixelWorldControlService::performanceSamples(std::uint64_t worldId, std::uint32_t limit) const { + if (!find(worldId)) return missingWorld>(); + if (limit == 0 || limit > 256) + return eve::Result>::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>::success({}); + const auto& samples = found->second; + const std::size_t begin = samples.size() > limit ? samples.size() - limit : 0; + std::vector result(samples.begin() + std::ptrdiff_t(begin), + samples.end()); + return eve::Result>::success( + std::move(result)); +} + +eve::Result> PixelWorldControlService::captureSnapshot( + std::uint64_t worldId) const { + const PixelWorld* value = find(worldId); + return value ? value->saveSnapshot() : missingWorld>(); +} + +eve::Result PixelWorldControlService::restoreSnapshot( + std::uint64_t worldId, std::span bytes) { + PixelWorld* value = find(worldId); + return value ? value->restoreSnapshot(bytes) : missingWorld(); +} + +} // namespace eve::pixelworld diff --git a/src/modules/pixelworld/PixelWorldControl.h b/src/modules/pixelworld/PixelWorldControl.h new file mode 100644 index 00000000..2d84fbdb --- /dev/null +++ b/src/modules/pixelworld/PixelWorldControl.h @@ -0,0 +1,91 @@ +#pragma once + +#include "pixelworld/PixelWorld.h" + +#include +#include +#include + +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 worlds() const; + /** @brief Query one live world summary. */ + [[nodiscard]] eve::Result world(std::uint64_t worldId) const; + /** @brief Pause or resume one live world. */ + [[nodiscard]] eve::Result setPaused(std::uint64_t worldId, bool paused); + /** @brief Advance a live world explicitly even while paused; count is limited to 1024. */ + [[nodiscard]] eve::Result step(std::uint64_t worldId, + std::uint32_t count = 1); + /** @brief Apply the world's canonical strictly sequenced edit operation. */ + [[nodiscard]] eve::Result applyEdit(std::uint64_t worldId, + const PixelEditCommand& command); + /** @brief Validate JSON and transactionally hot-reload a compatible paused world Catalog. */ + [[nodiscard]] eve::Result 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> chunkDiagnostics( + std::uint64_t worldId, PixelChunkRegion region) const; + /** @brief Copy up to the latest 256 simulation performance samples. */ + [[nodiscard]] eve::Result> performanceSamples( + std::uint64_t worldId, std::uint32_t limit = 120) const; + /** @brief Capture an owning canonical world snapshot. */ + [[nodiscard]] eve::Result> captureSnapshot(std::uint64_t worldId) const; + /** @brief Transactionally restore a captured world snapshot. */ + [[nodiscard]] eve::Result restoreSnapshot(std::uint64_t worldId, + std::span 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 diff --git a/src/modules/pixelworld/PixelWorldGeneration.cpp b/src/modules/pixelworld/PixelWorldGeneration.cpp new file mode 100644 index 00000000..4f12d352 --- /dev/null +++ b/src/modules/pixelworld/PixelWorldGeneration.cpp @@ -0,0 +1,201 @@ +#include "pixelworld/PixelWorldGeneration.h" + +#include +#include +#include +#include +#include + +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( + definition.defaultLifetime, std::numeric_limits::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 invalidGeneration(std::string message, + std::string path) { + return eve::Result::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 +void hashValue(std::uint64_t& hash, T value) noexcept { + using Unsigned = std::make_unsigned_t; + const Unsigned bits = static_cast(value); + for (std::size_t index = 0; index < sizeof(T); ++index) + hashByte(hash, std::uint8_t(bits >> (index * 8U))); +} + +} // namespace + +eve::Result 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::success(std::move(output)); +} + +} // namespace eve::pixelworld diff --git a/src/modules/pixelworld/PixelWorldGeneration.h b/src/modules/pixelworld/PixelWorldGeneration.h new file mode 100644 index 00000000..8debd223 --- /dev/null +++ b/src/modules/pixelworld/PixelWorldGeneration.h @@ -0,0 +1,73 @@ +#pragma once + +#include "pixelworld/PixelWorld.h" + +#include +#include + +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 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 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 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 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 diff --git a/src/modules/pixelworld/PixelWorldGenerationCodec.cpp b/src/modules/pixelworld/PixelWorldGenerationCodec.cpp new file mode 100644 index 00000000..46d9a015 --- /dev/null +++ b/src/modules/pixelworld/PixelWorldGenerationCodec.cpp @@ -0,0 +1,204 @@ +#include "pixelworld/PixelWorldGenerationCodec.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace eve::pixelworld { +namespace { + +using Object = Poco::JSON::Object; +using Array = Poco::JSON::Array; + +eve::Result malformed(std::string message, + std::string path = "document") { + return eve::Result::failure(eve::Diagnostic::error( + eve::DiagnosticCode::SerializationError, std::move(message), std::move(path), {}, + "pixelworld.generation-codec")); +} + +bool exactFields(const Object& object, std::initializer_list allowed) { + std::unordered_set 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 +bool readNumber(const Object& object, const std::string& key, T& result) { + static_assert(std::is_integral_v && !std::is_same_v); + if (!object.has(key) || object.isNull(key)) return false; + try { + const auto wide = object.get(key).convert(); + if constexpr (std::is_unsigned_v) { + if (wide < 0 || std::uint64_t(wide) > std::numeric_limits::max()) return false; + } else if (wide < std::numeric_limits::min() || + wide > std::numeric_limits::max()) { + return false; + } + result = static_cast(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("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 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::success(text.str()); +} + +eve::Result decodePixelWorldGenerationRequestJson( + std::string_view json) { + Object::Ptr root; + try { + root = Poco::JSON::Parser().parse(std::string(json)).extract(); + } 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("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::success(std::move(request)); +} + +} // namespace eve::pixelworld diff --git a/src/modules/pixelworld/PixelWorldGenerationCodec.h b/src/modules/pixelworld/PixelWorldGenerationCodec.h new file mode 100644 index 00000000..522218ab --- /dev/null +++ b/src/modules/pixelworld/PixelWorldGenerationCodec.h @@ -0,0 +1,23 @@ +#pragma once + +#include "common/Result.h" +#include "pixelworld/PixelWorldGeneration.h" + +#include +#include + +namespace eve::pixelworld { + +/** @brief Encode schema `eve.pixelworld.generation-request` version 1 canonically. */ +[[nodiscard]] eve::Result 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 +decodePixelWorldGenerationRequestJson(std::string_view json); + +} // namespace eve::pixelworld diff --git a/src/modules/pixelworld/PixelWorldModule.cpp b/src/modules/pixelworld/PixelWorldModule.cpp new file mode 100644 index 00000000..d6c037d8 --- /dev/null +++ b/src/modules/pixelworld/PixelWorldModule.cpp @@ -0,0 +1,62 @@ +#include "pixelworld/PixelWorldModule.h" + +#include + +#include +#include +#include +#include + +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", std::function([]() -> 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 diff --git a/src/modules/pixelworld/PixelWorldModule.h b/src/modules/pixelworld/PixelWorldModule.h new file mode 100644 index 00000000..a7520bf8 --- /dev/null +++ b/src/modules/pixelworld/PixelWorldModule.h @@ -0,0 +1,22 @@ +#pragma once + +#include "common/Module.h" +#include "pixelworld/PixelWorld.h" + +#include + +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 diff --git a/src/modules/pixelworld_editor/PixelWorldCatalogPanel.cpp b/src/modules/pixelworld_editor/PixelWorldCatalogPanel.cpp new file mode 100644 index 00000000..44c4a196 --- /dev/null +++ b/src/modules/pixelworld_editor/PixelWorldCatalogPanel.cpp @@ -0,0 +1,618 @@ +#include "pixelworld_editor/PixelWorldCatalogPanel.h" + +#include "pixelworld/PixelMaterialCatalogCodec.h" +#include "ui/UIHost.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace eve::pixelworld_editor { +namespace { + +using namespace eve::pixelworld; + +constexpr const char* kCatalogPanelHostName = "eve_pixelworld_catalog"; + +template +eve::Result fail(eve::DiagnosticCode code, std::string message, std::string path) { + return eve::Result::failure(eve::Diagnostic::error( + code, std::move(message), std::move(path), {}, "pixelworld.catalog-panel")); +} + +std::vector definitions(const MaterialCatalog& catalog) { + return {catalog.definitions().begin(), catalog.definitions().end()}; +} + +std::vector reactions(const MaterialCatalog& catalog) { + return {catalog.reactions().begin(), catalog.reactions().end()}; +} + +std::vector 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(state); + return index < names.size() ? names[index] : "Unknown"; +} + +template +std::optional 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 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 splitTags(std::string_view value) { + std::vector 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& tags) { + std::string result; + for (const auto& tag : tags) { + if (!result.empty()) result += ", "; + result += tag; + } + return result; +} + +template +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(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::create(std::string_view catalogJson) { + auto decoded = decodeMaterialCatalogJson(catalogJson); + if (!decoded.ok()) + return eve::Result::failure(decoded.status()); + return eve::Result::success( + PixelWorldCatalogPanel(std::move(decoded).takeValue())); +} + +PixelWorldCatalogPanel PixelWorldCatalogPanel::builtIn() { + return PixelWorldCatalogPanel(MaterialCatalog::builtIn()); +} + +std::vector PixelWorldCatalogPanel::materialCards() const { + std::vector 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 PixelWorldCatalogPanel::selectMaterial(std::size_t index) { + if (index >= draft_.definitions().size()) + return fail(eve::DiagnosticCode::InvalidArgument, + "material selection is outside the Catalog", "materialIndex"); + selectedMaterial_ = index; + return eve::Result::success(); +} + +eve::Result PixelWorldCatalogPanel::selectReaction(std::size_t index) { + if (index >= draft_.reactions().size()) + return fail(eve::DiagnosticCode::InvalidArgument, + "reaction selection is outside the Catalog", "reactionIndex"); + selectedReaction_ = index; + return eve::Result::success(); +} + +eve::Result PixelWorldCatalogPanel::selectPhase(std::size_t index) { + if (index >= draft_.phaseRules().size()) + return fail(eve::DiagnosticCode::InvalidArgument, + "phase selection is outside the Catalog", "phaseIndex"); + selectedPhase_ = index; + return eve::Result::success(); +} + +eve::Result PixelWorldCatalogPanel::commitCandidate( + std::vector candidateDefinitions, + std::vector candidateReactions, + std::vector candidatePhases) { + auto candidate = MaterialCatalog::create(std::move(candidateDefinitions), + std::move(candidateReactions), + std::move(candidatePhases)); + if (!candidate.ok()) + return eve::Result::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::success(receipt); +} + +eve::Result PixelWorldCatalogPanel::replaceMaterial( + MaterialDefinition definition) { + const auto index = static_cast(definition.id); + if (index >= draft_.definitions().size()) + return fail(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 PixelWorldCatalogPanel::replaceReaction( + std::size_t index, MaterialReactionRule rule) { + if (index >= draft_.reactions().size()) + return fail(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 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 PixelWorldCatalogPanel::removeReaction(std::size_t index) { + if (index >= draft_.reactions().size()) + return fail(eve::DiagnosticCode::InvalidArgument, + "reaction index is outside the Catalog", "reactionIndex"); + auto candidate = reactions(draft_); + candidate.erase(candidate.begin() + static_cast(index)); + return commitCandidate(definitions(draft_), std::move(candidate), phases(draft_)); +} + +eve::Result PixelWorldCatalogPanel::replacePhase( + std::size_t index, MaterialPhaseRule rule) { + if (index >= draft_.phaseRules().size()) + return fail(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 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 PixelWorldCatalogPanel::removePhase(std::size_t index) { + if (index >= draft_.phaseRules().size()) + return fail(eve::DiagnosticCode::InvalidArgument, + "phase index is outside the Catalog", "phaseIndex"); + auto candidate = phases(draft_); + candidate.erase(candidate.begin() + static_cast(index)); + return commitCandidate(definitions(draft_), reactions(draft_), std::move(candidate)); +} + +eve::Result PixelWorldCatalogPanel::document() const { + return encodeMaterialCatalogJson(draft_); +} + +eve::Result PixelWorldCatalogPanel::loadDocument( + std::string_view catalogJson) { + auto decoded = decodeMaterialCatalogJson(catalogJson); + if (!decoded.ok()) + return eve::Result::failure(decoded.status()); + return commitCandidate(definitions(decoded.value()), reactions(decoded.value()), + phases(decoded.value())); +} + +eve::Result PixelWorldCatalogPanel::apply( + PixelWorldControlService& control, std::uint64_t worldId, + std::uint64_t expectedFingerprint) const { + auto encoded = document(); + if (!encoded.ok()) + return eve::Result::failure(encoded.status()); + return control.reloadMaterialCatalog(worldId, encoded.value(), expectedFingerprint); +} + +eve::Result 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::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::failure(refreshed.status()); + } + return eve::Result::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 PixelWorldCatalogPanel::refresh() { + auto host = eve::ui::UIHost::resolve(host_); + if (!host) + return fail(eve::DiagnosticCode::PreconditionViolation, + "pixelworld catalog panel is not mounted", "uiHost"); + host->get().setTree(buildWidgetTree()); + return eve::Result::success(); +} + +void PixelWorldCatalogPanel::observe(eve::Result 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 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 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 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 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(selected.state), "material-state", + [mutateMaterial](float value) mutable { + mutateMaterial([&](auto& material) { + material.state = static_cast(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 materialNames; + for (const auto& material : draft_.definitions()) materialNames.push_back(material.name); + std::vector reactionNames; + for (const auto& reaction : draft_.reactions()) reactionNames.push_back(reaction.id); + std::vector 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(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 phaseNames; + for (const auto& phase : draft_.phaseRules()) phaseNames.push_back(phase.id); + std::vector 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(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(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 diff --git a/src/modules/pixelworld_editor/PixelWorldCatalogPanel.h b/src/modules/pixelworld_editor/PixelWorldCatalogPanel.h new file mode 100644 index 00000000..c4addd6c --- /dev/null +++ b/src/modules/pixelworld_editor/PixelWorldCatalogPanel.h @@ -0,0 +1,141 @@ +#pragma once + +#include "common/Result.h" +#include "pixelworld/PixelMaterial.h" +#include "pixelworld/PixelWorldControl.h" +#include "ui/Widget.h" + +#include +#include +#include +#include +#include +#include + +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 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 selectedReactionIndex() const noexcept { return selectedReaction_; } + /** @brief Selected phase-rule index when at least one transition exists. */ + std::optional selectedPhaseIndex() const noexcept { return selectedPhase_; } + + /** @brief Project deterministic cards for the material browser. */ + std::vector materialCards() const; + /** @brief Select a material by canonical index without mutating the Catalog. */ + [[nodiscard]] eve::Result selectMaterial(std::size_t index); + /** @brief Select a binary reaction rule by canonical index. */ + [[nodiscard]] eve::Result selectReaction(std::size_t index); + /** @brief Select a phase-transition rule by canonical index. */ + [[nodiscard]] eve::Result selectPhase(std::size_t index); + + /** @brief Replace one material definition after validating the entire reconstructed Catalog. */ + [[nodiscard]] eve::Result replaceMaterial( + eve::pixelworld::MaterialDefinition definition); + /** @brief Replace one binary rule after validating the entire reconstructed Catalog. */ + [[nodiscard]] eve::Result replaceReaction( + std::size_t index, eve::pixelworld::MaterialReactionRule rule); + /** @brief Add a binary rule and select its canonical post-validation position. */ + [[nodiscard]] eve::Result addReaction( + eve::pixelworld::MaterialReactionRule rule); + /** @brief Remove one binary rule transactionally. */ + [[nodiscard]] eve::Result removeReaction(std::size_t index); + /** @brief Replace one phase rule after validating the entire reconstructed Catalog. */ + [[nodiscard]] eve::Result replacePhase( + std::size_t index, eve::pixelworld::MaterialPhaseRule rule); + /** @brief Add a phase rule and select its canonical post-validation position. */ + [[nodiscard]] eve::Result addPhase( + eve::pixelworld::MaterialPhaseRule rule); + /** @brief Remove one phase rule transactionally. */ + [[nodiscard]] eve::Result removePhase(std::size_t index); + + /** @brief Encode the current validated draft using the canonical versioned JSON codec. */ + [[nodiscard]] eve::Result document() const; + /** @brief Replace the draft from a validated document; failure leaves all panel state intact. */ + [[nodiscard]] eve::Result loadDocument(std::string_view catalogJson); + /** @brief Apply the draft through the canonical paused-world authority path. */ + [[nodiscard]] eve::Result 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 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 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 commitCandidate( + std::vector definitions, + std::vector reactions, + std::vector phases); + void observe(eve::Result result); + void observe(eve::Result result); + + eve::pixelworld::MaterialCatalog draft_; + std::uint64_t revision_ = 1; + std::size_t selectedMaterial_ = 0; + std::optional selectedReaction_; + std::optional selectedPhase_; + std::string statusText_ = "Catalog draft is valid"; + eve::ui::UIHostHandle host_{}; +}; + +} // namespace eve::pixelworld_editor diff --git a/src/modules/pixelworld_editor/PixelWorldEditorModule.cpp b/src/modules/pixelworld_editor/PixelWorldEditorModule.cpp new file mode 100644 index 00000000..37688b7a --- /dev/null +++ b/src/modules/pixelworld_editor/PixelWorldEditorModule.cpp @@ -0,0 +1,42 @@ +#include "pixelworld_editor/PixelWorldEditorModule.h" + +#include + +#include + +namespace eve::pixelworld_editor { + +Module_IMPL(PixelWorldEditorModule, new PixelWorldEditorModule()); + +eve::Result PixelWorldEditorModule::openCatalog() { + if (!panel_) panel_ = std::make_unique(PixelWorldCatalogPanel::builtIn()); + auto opened = panel_->open(); + if (!opened) return eve::Result::failure(opened.status()); + return eve::Result::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 diff --git a/src/modules/pixelworld_editor/PixelWorldEditorModule.h b/src/modules/pixelworld_editor/PixelWorldEditorModule.h new file mode 100644 index 00000000..f7ee2e60 --- /dev/null +++ b/src/modules/pixelworld_editor/PixelWorldEditorModule.h @@ -0,0 +1,29 @@ +#pragma once + +#include "common/Module.h" +#include "pixelworld_editor/PixelWorldCatalogPanel.h" + +#include + +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 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 panel_; +}; + +} // namespace eve::pixelworld_editor diff --git a/src/modules/pixelworld_graphics/PixelWorldGraphics.cpp b/src/modules/pixelworld_graphics/PixelWorldGraphics.cpp new file mode 100644 index 00000000..ff255186 --- /dev/null +++ b/src/modules/pixelworld_graphics/PixelWorldGraphics.cpp @@ -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 + +#include +#include +#include +#include +#include +#include + +namespace eve::pixelworld_graphics { + +namespace { + +using Rgba = std::array; + +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 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()) { + 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 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 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(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(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", + std::function([]() -> 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 diff --git a/src/modules/pixelworld_graphics/PixelWorldGraphics.h b/src/modules/pixelworld_graphics/PixelWorldGraphics.h new file mode 100644 index 00000000..f5d6b57b --- /dev/null +++ b/src/modules/pixelworld_graphics/PixelWorldGraphics.h @@ -0,0 +1,92 @@ +#pragma once + +#include "common/Module.h" + +#include +#include + +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_; +}; + +/** @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 diff --git a/src/modules/pixelworld_physics/PixelWorldPhysics.cpp b/src/modules/pixelworld_physics/PixelWorldPhysics.cpp new file mode 100644 index 00000000..73df1e58 --- /dev/null +++ b/src/modules/pixelworld_physics/PixelWorldPhysics.cpp @@ -0,0 +1,627 @@ +#include "pixelworld_physics/PixelWorldPhysics.h" + +#include "common/Exception.h" +#include "physics/Body.h" +#include "physics/World.h" + +#include +#include +#include +#include +#include +#include + +namespace eve::pixelworld_physics { +namespace { + +template +eve::Result failure(eve::DiagnosticCode code, std::string message, std::string path) { + return eve::Result::failure(eve::Diagnostic::error( + code, std::move(message), std::move(path), {}, "pixelworld_physics")); +} + +std::vector decompose(const eve::pixelworld::PixelFragment& fragment) { + const int width = fragment.width, height = fragment.height; + std::vector consumed(std::size_t(width) * std::size_t(height)); + std::vector 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::min()) + 2.0 && + double(maximum) <= double(std::numeric_limits::max()) - 2.0; +} + +} // namespace + +eve::Result> extractTerrainContours( + const eve::pixelworld::PixelWorld& pixelWorld, int chunkX, int chunkY, + std::uint32_t maximumVertices) { + if (maximumVertices < 2) + return failure>( + 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::min() || + wideOriginX + size > std::numeric_limits::max() || + wideOriginY < std::numeric_limits::min() || + wideOriginY + size > std::numeric_limits::max()) + return failure>( + 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 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> outgoing; + for (std::size_t index = 0; index < edges.size(); ++index) + outgoing[edges[index].from].push_back(index); + std::vector used(edges.size()); + std::vector contours; + std::uint64_t vertexCount = 0; + for (std::size_t first = 0; first < edges.size(); ++first) { + if (used[first]) continue; + std::vector 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>( + 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>::success(std::move(contours)); +} + +eve::Result 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(eve::DiagnosticCode::InvalidArgument, + "circle probe parameters are invalid", "probe"); + if (!validCellBounds(centerX - radius, centerX + radius) || + !validCellBounds(centerY - radius, centerY + radius)) + return failure(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(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::success(best); +} + +eve::Result 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(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(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(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::success(best); +} + +PixelFragmentBody::PixelFragmentBody(eve::pixelworld::PixelFragment fragment, + eve::physics::PhysicsLink link, + std::vector rectangles) + : fragment_(std::move(fragment)), link_(link), rectangles_(std::move(rectangles)) {} + +eve::Result> 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>( + 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>( + eve::DiagnosticCode::InvalidArgument, "fragment body config is invalid", "config"); + auto rectangles = decompose(fragment); + if (rectangles.empty() || rectangles.size() > config.maximumFixtures) + return failure>( + 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>::failure(link.status()); + } + return eve::Result>::success( + std::unique_ptr(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>( + eve::DiagnosticCode::Failed, std::string("failed to create fragment fixtures: ") + error.what(), + "physics"); + } +} + +const std::vector& 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 PixelFragmentBody::settleIfSleeping( + eve::physics::World& physicsWorld, eve::pixelworld::PixelWorld& pixelWorld) { + if (rasterized_ || physicsReleased_) + return failure(eve::DiagnosticCode::PreconditionViolation, + "fragment body is no longer active", "state"); + auto resolved = link_.resolve(physicsWorld); + if (!resolved.ok()) return eve::Result::failure(resolved.status()); + eve::physics::Body* body = resolved.value(); + if (body->isAwake()) + return eve::Result::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::failure(rasterized.status()); + body->destroy(); + physicsReleased_ = true; + rasterized_ = true; + return eve::Result::success( + {FragmentSettleDisposition::Rasterized, fragment_.id, + rasterized.value().cellsPlaced, ((turns % 4) + 4) % 4}); +} + +eve::Result PixelFragmentBody::releasePhysics(eve::physics::World& physicsWorld) { + if (physicsReleased_) return eve::Result::success(); + auto resolved = link_.resolve(physicsWorld); + if (!resolved.ok()) return eve::Result::failure(resolved.status()); + resolved.value()->destroy(); + physicsReleased_ = true; + return eve::Result::success(); +} + +struct PixelTerrainCollisionCache::Impl { + using Coord = std::pair; + std::map bodies; + eve::pixelworld::PixelWorldLink pixelWorld; + eve::physics::PhysicsWorldHandle physicsWorld = eve::physics::PhysicsWorldHandle::invalid(); + std::uint64_t revision = 0; +}; + +PixelTerrainCollisionCache::PixelTerrainCollisionCache() : impl_(std::make_unique()) {} +PixelTerrainCollisionCache::~PixelTerrainCollisionCache() = default; +PixelTerrainCollisionCache::PixelTerrainCollisionCache(PixelTerrainCollisionCache&&) noexcept = default; +PixelTerrainCollisionCache& PixelTerrainCollisionCache::operator=(PixelTerrainCollisionCache&&) noexcept = default; + +eve::Result PixelTerrainCollisionCache::sync( + eve::physics::World& physicsWorld, const eve::pixelworld::PixelWorld& pixelWorld, + std::uint32_t maximumFixturesPerChunk) { + if (maximumFixturesPerChunk == 0) + return failure(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 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 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::failure(extracted.status()); + } + auto contours = std::move(extracted).takeValue(); + if (contours.size() > maximumFixturesPerChunk) { + destroyStaged(); + return failure( + 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::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( + 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::success(receipt); +} + +eve::Result PixelTerrainCollisionCache::clearPhysics(eve::physics::World& physicsWorld) { + if (impl_->physicsWorld.isValid() && impl_->physicsWorld != physicsWorld.runtimeHandle()) + return failure(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::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 diff --git a/src/modules/pixelworld_physics/PixelWorldPhysics.h b/src/modules/pixelworld_physics/PixelWorldPhysics.h new file mode 100644 index 00000000..26b5961b --- /dev/null +++ b/src/modules/pixelworld_physics/PixelWorldPhysics.h @@ -0,0 +1,201 @@ +#pragma once + +#include "common/Result.h" +#include "physics/PhysicsLink.h" +#include "pixelworld/PixelWorld.h" + +#include +#include +#include + +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 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> 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 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 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> 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& 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 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 releasePhysics(eve::physics::World& physicsWorld); + +private: + PixelFragmentBody(eve::pixelworld::PixelFragment fragment, eve::physics::PhysicsLink link, + std::vector rectangles); + + eve::pixelworld::PixelFragment fragment_; + eve::physics::PhysicsLink link_; + std::vector 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 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 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_; +}; + +} // namespace eve::pixelworld_physics diff --git a/src/modules/pixelworld_replay/PixelWorldReplay.cpp b/src/modules/pixelworld_replay/PixelWorldReplay.cpp new file mode 100644 index 00000000..e729e08b --- /dev/null +++ b/src/modules/pixelworld_replay/PixelWorldReplay.cpp @@ -0,0 +1,387 @@ +#include "pixelworld_replay/PixelWorldReplay.h" + +#include +#include +#include +#include +#include +#include + +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 +eve::Result fail(eve::DiagnosticCode code, std::string message, std::string path = {}) { + return eve::Result::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 object(const eve::Value& value, std::string path, + std::initializer_list fields) { + const auto* result = value.getIf(); + if (!result || result->size() != fields.size()) + return fail(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(eve::DiagnosticCode::ParseError, + "replay object has unknown or missing fields", std::move(path)); + return eve::Result::success(result); +} + +eve::Result decimal(const eve::Value& value, std::string path) { + const auto* text = value.getIf(); + if (!text) return fail(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(eve::DiagnosticCode::ParseError, "invalid decimal string", std::move(path)); + return eve::Result::success(result); +} + +eve::Result integer(const eve::Value& value, std::string path) { + const auto* number = value.getIf(); + if (!number || *number < std::numeric_limits::min() || *number > std::numeric_limits::max()) + return fail(eve::DiagnosticCode::ParseError, "expected in-range integer", std::move(path)); + return eve::Result::success(static_cast(*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(command.kind))}, + {"material", eve::Value(static_cast(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 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::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::DiagnosticCode::ParseError, + "invalid replay command", path); + if (kind.value() < 0 || kind.value() > static_cast(eve::pixelworld::PixelEditKind::Explosion) || + material.value() < 0 || material.value() > std::numeric_limits::max() || + temperature.value() < std::numeric_limits::min() || + temperature.value() > std::numeric_limits::max()) + return fail(eve::DiagnosticCode::ParseError, + "replay command enum or scalar is out of range", path); + command.sequence = sequence.value(); + command.kind = static_cast(kind.value()); + command.centerX = centerX.value(); + command.centerY = centerY.value(); + command.radius = radius.value(); + command.material = static_cast(material.value()); + command.strength = strength.value(); + command.temperatureDelta = static_cast(temperature.value()); + return eve::Result::success(command); +} + +std::uint64_t hashBytes(std::span bytes) noexcept { + std::uint64_t hash = 1469598103934665603ULL; + for (const std::byte value : bytes) { + hash ^= std::uint64_t(std::to_integer(value)); + hash *= 1099511628211ULL; + } + return hash; +} + +template +void hashValue(std::uint64_t& hash, T value) noexcept { + static_assert(std::is_integral_v); + using Unsigned = std::make_unsigned_t; + static_assert(sizeof(Unsigned) <= sizeof(std::uint64_t)); + std::uint64_t encoded = static_cast(static_cast(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 checkpointFor(const eve::pixelworld::PixelWorld& world) { + auto snapshot = world.saveSnapshot(); + if (!snapshot.ok()) return eve::Result::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::success(std::move(checkpoint)); +} + +eve::Value replayPayload(const std::vector& entries, + const std::vector& 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 PixelReplayLog::append(PixelReplayEntry entry) { + if (entry.tick.value() == 0) + return eve::Result::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::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::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::success(); +} + +eve::Result PixelReplayLog::captureCheckpoint(const eve::pixelworld::PixelWorld& world) { + if (!checkpoints_.empty() && world.tickValue() <= checkpoints_.back().tick.value()) + return eve::Result::failure(eve::Diagnostic::error( + eve::DiagnosticCode::PreconditionViolation, "checkpoint Tick must increase", + "tick", {}, "pixelworld.replay")); + auto checkpoint = checkpointFor(world); + if (!checkpoint.ok()) return eve::Result::failure(checkpoint.status()); + checkpoints_.push_back(std::move(checkpoint).value()); + return eve::Result::success(); +} + +eve::Result 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::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::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::failure(applied.status()); + ++entryIndex; + ++receipt.commandsApplied; + } + auto advanced = target.advance(eve::SimulationTick(tick)); + if (!advanced.ok()) return eve::Result::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::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::success(std::move(receipt)); + } + ++checkpointIndex; + } + } + return eve::Result::success(std::move(receipt)); +} + +eve::Result 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 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(eve::DiagnosticCode::InvalidArgument, "replay snapshot type or schema does not match", + "schema"); + if (snapshot.schemaVersion.value() < 1 || snapshot.schemaVersion.value() > kReplaySchemaVersion) + return fail(eve::DiagnosticCode::UnknownVersion, "unsupported replay snapshot schema version", + "schemaVersion"); + + auto root = object(snapshot.payload, "payload", {"checkpoints", "entries"}); + if (!root) return eve::Result::failure(root.status()); + const auto* encodedEntries = root.value()->at("entries").getIf(); + const auto* encodedCheckpoints = root.value()->at("checkpoints").getIf(); + if (!encodedEntries || !encodedCheckpoints || encodedEntries->size() > kMaximumReplayEntries || + encodedCheckpoints->size() > kMaximumReplayCheckpoints) + return fail(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::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(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::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(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(); + if (!chunks || chunks->size() > kMaximumReplayChunkDigests - totalChunks) + return fail(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::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(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(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(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::success(); +} + +} // namespace eve::pixelworld_replay diff --git a/src/modules/pixelworld_replay/PixelWorldReplay.h b/src/modules/pixelworld_replay/PixelWorldReplay.h new file mode 100644 index 00000000..a55619bc --- /dev/null +++ b/src/modules/pixelworld_replay/PixelWorldReplay.h @@ -0,0 +1,113 @@ +#pragma once + +#include "common/Result.h" +#include "common/Snapshot.h" +#include "common/Time.h" +#include "pixelworld/PixelWorld.h" + +#include +#include +#include +#include + +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 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 expectedChunk; + std::optional 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 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 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 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 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 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 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 entries() const noexcept { return entries_; } + [[nodiscard]] std::span checkpoints() const noexcept { return checkpoints_; } + +private: + std::vector entries_; + std::vector checkpoints_; +}; + +} // namespace eve::pixelworld_replay diff --git a/src/modules/pixelworld_streaming/PixelWorldArchive.cpp b/src/modules/pixelworld_streaming/PixelWorldArchive.cpp new file mode 100644 index 00000000..f6e072c1 --- /dev/null +++ b/src/modules/pixelworld_streaming/PixelWorldArchive.cpp @@ -0,0 +1,311 @@ +#include "pixelworld_streaming/PixelWorldStreaming.h" + +#include "asset/EvpackCompression.h" + +#include +#include +#include +#include +#include +#include + +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 +eve::Result fail(eve::DiagnosticCode code, std::string message, std::string path = {}) { + return eve::Result::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 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> unhex(const eve::Value& value, std::size_t maximumBytes, + std::string path) { + const auto* text = value.getIf(); + if (!text || text->size() % 2 != 0 || text->size() / 2 > maximumBytes) + return fail>(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 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>(eve::DiagnosticCode::ParseError, + "invalid hexadecimal digit", std::move(path)); + result[index] = std::uint8_t((high << 4) | low); + } + return eve::Result>::success(std::move(result)); +} + +template +void put(std::vector& bytes, T value) { + using Unsigned = std::make_unsigned_t; + std::uint64_t encoded = static_cast(static_cast(value)); + for (std::size_t index = 0; index < sizeof(T); ++index) { + bytes.push_back(std::uint8_t(encoded & 0xff)); + encoded >>= 8; + } +} + +template +T take(std::span bytes, std::size_t& cursor) { + using Unsigned = std::make_unsigned_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) return std::bit_cast(encoded); + return encoded; +} + +std::vector encodeCells(const eve::pixelworld::PixelChunkSnapshot& chunk) { + std::vector 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 object(const eve::Value& value, std::string path, + std::initializer_list fields) { + const auto* result = value.getIf(); + if (!result || result->size() != fields.size()) + return fail(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(eve::DiagnosticCode::ParseError, + "archive object has unknown or missing fields", std::move(path)); + return eve::Result::success(result); +} + +eve::Result decimal(const eve::Value& value, std::string path) { + const auto* text = value.getIf(); + if (!text) return fail(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(eve::DiagnosticCode::ParseError, "invalid decimal string", std::move(path)); + return eve::Result::success(result); +} + +eve::Result integer(const eve::Value& value, std::string path) { + const auto* number = value.getIf(); + if (!number || *number < std::numeric_limits::min() || *number > std::numeric_limits::max()) + return fail(eve::DiagnosticCode::ParseError, "expected in-range integer", std::move(path)); + return eve::Result::success(int(*number)); +} + +eve::Result digest(const std::vector& decoded, + const eve::SnapshotHashProvider& hashProvider) { + if (!hashProvider) + return fail(eve::DiagnosticCode::Unsupported, "archive hash provider is required"); + return hashProvider(hex(decoded)); +} + +} // namespace + +eve::Result 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::DiagnosticCode::Unsupported, "unknown Chunk archive codec"); + if (batch.chunks.size() > kMaximumArchiveChunks) + return fail(eve::DiagnosticCode::InvalidArgument, + "Chunk archive exceeds record budget", "chunks"); + eve::Value::Array records; + std::optional> previous; + for (const auto& chunk : batch.chunks) { + const std::pair key{chunk.y, chunk.x}; + if (previous && !(previous.value() < key)) + return fail(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::DiagnosticCode::InvalidArgument, + "invalid Chunk revision or cell shape", "chunks"); + const auto decoded = chunk.removed ? std::vector{} : encodeCells(chunk); + auto contentHash = digest(decoded, hashProvider); + if (!contentHash) return eve::Result::failure(contentHash.status()); + const auto physical = chunk.removed ? PixelChunkArchiveCodec::None : codec; + auto stored = eve::asset::compressEvpackChunk(assetCodec(physical), decoded); + if (!stored) return eve::Result::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 decodeChunkBatchArchive( + const eve::SnapshotEnvelope& snapshot, const eve::SnapshotHashProvider& hashProvider) { + auto verified = eve::verifySnapshotEnvelope(snapshot, hashProvider); + if (!verified) return eve::Result::failure(verified.status()); + if (snapshot.type != kArchiveType || snapshot.schema != archiveSchema()) + return fail(eve::DiagnosticCode::InvalidArgument, + "Chunk archive type or schema does not match", "schema"); + if (snapshot.schemaVersion.value() < 1 || snapshot.schemaVersion.value() > kArchiveVersion) + return fail(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::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::success(0) + : decimal(root.value()->at("sourceLastEditSequence"), + "payload.sourceLastEditSequence"); + const auto* records = root.value()->at("chunks").getIf(); + if (!catalog || !seed || !sequence || !records || records->size() > kMaximumArchiveChunks) + return fail(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(); + if (!fullResync) + return fail(eve::DiagnosticCode::ParseError, + "invalid fullResync flag", "payload.fullResync"); + batch.fullResync = *fullResync; + } + std::optional> 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::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(); + auto codecValue = integer(record.value()->at("codec"), path + ".codec"); + const auto* hashText = record.value()->at("contentHash").getIf(); + 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::DiagnosticCode::ParseError, + "invalid Chunk archive record", path); + const std::pair key{y.value(), x.value()}; + if (previous && !(previous.value() < key)) + return fail(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::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::failure(stored.status()); + const auto codec = static_cast(codecValue.value()); + if (*removed && (codec != PixelChunkArchiveCodec::None || !stored.value().empty())) + return fail(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::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::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(decoded.value(), cursor)); + cell.temperature = take(decoded.value(), cursor); + cell.lifetime = take(decoded.value(), cursor); + if (currentCells) + cell.thermalRemainder = take(decoded.value(), cursor); + } + } + batch.chunks.push_back(std::move(chunk)); + } + return eve::Result::success(std::move(batch)); +} + +} // namespace eve::pixelworld_streaming diff --git a/src/modules/pixelworld_streaming/PixelWorldStreaming.cpp b/src/modules/pixelworld_streaming/PixelWorldStreaming.cpp new file mode 100644 index 00000000..be397d62 --- /dev/null +++ b/src/modules/pixelworld_streaming/PixelWorldStreaming.cpp @@ -0,0 +1,83 @@ +#include "pixelworld_streaming/PixelWorldStreaming.h" + +#include +#include +#include + +namespace eve::pixelworld_streaming { +namespace { + +using Coord = std::pair; + +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 PixelChunkStreamCursor::capture( + const eve::pixelworld::PixelWorld& source, eve::pixelworld::PixelChunkRegion interest) { + auto all = source.snapshotChunksInRegion(interest, 0); + if (!all) return eve::Result::failure(all.status()); + + const auto link = source.worldLink(); + const bool fullResync = source_.world == 0 || source_ != link; + std::set nextKnown; + std::set emitted; + std::vector 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::success(std::move(update)); +} + +void PixelChunkStreamCursor::reset() noexcept { + source_ = {}; + revision_ = 0; + known_.clear(); +} + +} // namespace eve::pixelworld_streaming diff --git a/src/modules/pixelworld_streaming/PixelWorldStreaming.h b/src/modules/pixelworld_streaming/PixelWorldStreaming.h new file mode 100644 index 00000000..21f0d0ad --- /dev/null +++ b/src/modules/pixelworld_streaming/PixelWorldStreaming.h @@ -0,0 +1,79 @@ +#pragma once + +#include "common/Result.h" +#include "common/Snapshot.h" +#include "pixelworld/PixelWorld.h" + +#include +#include +#include + +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 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 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 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> known_; +}; + +} // namespace eve::pixelworld_streaming diff --git a/src/modules/pixelworld_streaming/PixelWorldTransport.cpp b/src/modules/pixelworld_streaming/PixelWorldTransport.cpp new file mode 100644 index 00000000..fbf80c0c --- /dev/null +++ b/src/modules/pixelworld_streaming/PixelWorldTransport.cpp @@ -0,0 +1,257 @@ +#include "pixelworld_streaming/PixelWorldTransport.h" + +#include +#include +#include + +namespace eve::pixelworld_streaming { +namespace { + +template +eve::Result fail(eve::DiagnosticCode code, std::string message, std::string path) { + return eve::Result::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::create( + std::uint64_t streamId, PixelChunkTransportConfig config) { + if (streamId == 0 || !valid(config)) + return fail(eve::DiagnosticCode::InvalidArgument, + "stream id or transport policy is invalid", "config"); + return eve::Result::success( + ReliablePixelChunkSender(streamId, config)); +} + +eve::Result> ReliablePixelChunkSender::capture( + const eve::pixelworld::PixelWorld& source, eve::pixelworld::PixelChunkRegion interest) { + if (inFlight_.size() >= config_.maximumInFlightTransfers) + return fail>( + eve::DiagnosticCode::PreconditionViolation, "reliable send window is full", + "maximumInFlightTransfers"); + if (nextTransferId_ == std::numeric_limits::max()) + return fail>( + eve::DiagnosticCode::PreconditionViolation, "transfer id space is exhausted", + "transferId"); + auto candidateCursor = cursor_; + auto captured = candidateCursor.capture(source, interest); + if (!captured.ok()) + return eve::Result>::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>::success({}); + } + + const std::size_t chunkCount = update.batch.chunks.size(); + const std::size_t partCount = std::max( + 1, (chunkCount + config_.maximumChunksPerPart - 1) / config_.maximumChunksPerPart); + if (partCount > config_.maximumPartsPerTransfer) + return fail>( + eve::DiagnosticCode::PreconditionViolation, "captured update exceeds part budget", + "maximumPartsPerTransfer"); + std::vector 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>::success(std::move(parts)); +} + +std::vector ReliablePixelChunkSender::pendingParts() const { + std::vector result; + for (const auto& [transfer, parts] : inFlight_) { + (void)transfer; + result.insert(result.end(), parts.begin(), parts.end()); + } + return result; +} + +eve::Result ReliablePixelChunkSender::acknowledge( + PixelChunkTransferAck ack) { + if (ack.streamId != streamId_) + return fail(eve::DiagnosticCode::Conflict, + "ACK belongs to another stream", "streamId"); + if (ack.acknowledgedThrough <= acknowledgedThrough_) + return eve::Result::success( + {0, 0, acknowledgedThrough_}); + if (ack.acknowledgedThrough >= nextTransferId_) + return fail(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(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::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::create( + std::uint64_t streamId, PixelChunkTransportConfig config) { + if (streamId == 0 || !valid(config)) + return fail(eve::DiagnosticCode::InvalidArgument, + "stream id or transport policy is invalid", "config"); + return eve::Result::success( + ReliablePixelChunkReceiver(streamId, config)); +} + +eve::Result 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(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::success(receipt); + } + auto found = buffered_.find(part.transferId); + if (found == buffered_.end()) { + if (buffered_.size() >= config_.maximumBufferedTransfers) + return fail( + 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(eve::DiagnosticCode::Conflict, + "transfer part count changed", "partCount"); + auto& slot = assembly.parts[part.partIndex]; + if (slot) { + if (*slot != part) + return fail(eve::DiagnosticCode::Conflict, + "duplicate part payload conflicts", "part"); + receipt.duplicate = true; + } else { + slot = std::make_unique(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(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::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::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 diff --git a/src/modules/pixelworld_streaming/PixelWorldTransport.h b/src/modules/pixelworld_streaming/PixelWorldTransport.h new file mode 100644 index 00000000..f5fbdc35 --- /dev/null +++ b/src/modules/pixelworld_streaming/PixelWorldTransport.h @@ -0,0 +1,131 @@ +#pragma once + +#include "common/Result.h" +#include "pixelworld/PixelWorld.h" +#include "pixelworld_streaming/PixelWorldStreaming.h" + +#include +#include +#include +#include + +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 create( + std::uint64_t streamId, PixelChunkTransportConfig config = {}); + + /** @brief Capture and enqueue one atomic revision transfer, split into bounded owning parts. */ + [[nodiscard]] eve::Result> 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 pendingParts() const; + /** @brief Release cumulatively acknowledged transfers and their retained tombstones. */ + [[nodiscard]] eve::Result 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> 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 create( + std::uint64_t streamId, PixelChunkTransportConfig config = {}); + + /** @brief Buffer one part and atomically apply every newly contiguous complete transfer. */ + [[nodiscard]] eve::Result 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> parts; + }; + + ReliablePixelChunkReceiver(std::uint64_t streamId, PixelChunkTransportConfig config); + + std::uint64_t streamId_ = 0; + std::uint64_t expectedTransferId_ = 1; + PixelChunkTransportConfig config_{}; + std::map buffered_; +}; + +} // namespace eve::pixelworld_streaming diff --git a/src/modules/pixelworld_thread/PixelWorldThread.cpp b/src/modules/pixelworld_thread/PixelWorldThread.cpp new file mode 100644 index 00000000..0c84723c --- /dev/null +++ b/src/modules/pixelworld_thread/PixelWorldThread.cpp @@ -0,0 +1,33 @@ +#include "pixelworld_thread/PixelWorldThread.h" + +#include "thread/JobSystem.h" + +#include +#include + +namespace eve::pixelworld_thread { + +JobSystemPixelScheduler::JobSystemPixelScheduler(eve::thread::JobSystem& jobs) noexcept + : jobs_(&jobs) {} + +void JobSystemPixelScheduler::parallelFor( + std::size_t workItems, const std::function& body) { + if (workItems == 0) return; + try { + std::unique_ptr 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 diff --git a/src/modules/pixelworld_thread/PixelWorldThread.h b/src/modules/pixelworld_thread/PixelWorldThread.h new file mode 100644 index 00000000..1f902dbb --- /dev/null +++ b/src/modules/pixelworld_thread/PixelWorldThread.h @@ -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& body) override; + [[nodiscard]] std::size_t workerCount() const noexcept override; + +private: + eve::thread::JobSystem* jobs_ = nullptr; +}; + +} // namespace eve::pixelworld_thread diff --git a/test/graphics_backend_parity.cpp b/test/graphics_backend_parity.cpp index 04746c63..d6f73040 100644 --- a/test/graphics_backend_parity.cpp +++ b/test/graphics_backend_parity.cpp @@ -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 #include @@ -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(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); diff --git a/test/mcp.cpp b/test/mcp.cpp index cd231bcc..fc4326e0 100644 --- a/test/mcp.cpp +++ b/test/mcp.cpp @@ -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 @@ -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(i)); const std::string name = t->getValue("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(); diff --git a/test/pixelworld.cpp b/test/pixelworld.cpp new file mode 100644 index 00000000..63560191 --- /dev/null +++ b/test/pixelworld.cpp @@ -0,0 +1,646 @@ +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include "pixelworld/PixelWorld.h" +#include "pixelworld/PixelMaterialCatalogCodec.h" + +#include +#include +#include +#include +#include +#include + +using namespace eve::pixelworld; + +namespace { + +std::vector withoutThermalRemainders(const std::vector& current) { + REQUIRE(current.size() >= 50); + std::uint32_t chunks = 0; + std::memcpy(&chunks, current.data() + 46, sizeof(chunks)); + std::vector 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 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 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 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 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 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 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 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 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 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 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 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 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::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())); +} diff --git a/test/pixelworld_control.cpp b/test/pixelworld_control.cpp new file mode 100644 index 00000000..ef3c02e7 --- /dev/null +++ b/test/pixelworld_control.cpp @@ -0,0 +1,103 @@ +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include "pixelworld/PixelWorldControl.h" +#include "pixelworld/PixelMaterialCatalogCodec.h" + +#include + +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 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()); +} diff --git a/test/pixelworld_editor.cpp b/test/pixelworld_editor.cpp new file mode 100644 index 00000000..67df3bf3 --- /dev/null +++ b/test/pixelworld_editor.cpp @@ -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 + +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()); +} diff --git a/test/pixelworld_generation.cpp b/test/pixelworld_generation.cpp new file mode 100644 index 00000000..c85e5b2c --- /dev/null +++ b/test/pixelworld_generation.cpp @@ -0,0 +1,135 @@ +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include "pixelworld/PixelWorldGeneration.h" +#include "pixelworld/PixelWorldGenerationCodec.h" + +#include +#include +#include +#include + +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::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()); +} diff --git a/test/pixelworld_parallel.cpp b/test/pixelworld_parallel.cpp new file mode 100644 index 00000000..3d6d4c52 --- /dev/null +++ b/test/pixelworld_parallel.cpp @@ -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 +#include +#include +#include +#include +#include + +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, ¶llel}) { + 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 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 jobs(eve::thread::createJobSystem(4)); + eve::pixelworld_thread::JobSystemPixelScheduler scheduler(*jobs); + std::vector 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( + 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 jobs(eve::thread::createJobSystem(4)); + eve::pixelworld_thread::JobSystemPixelScheduler scheduler(*jobs); + std::vector 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( + 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); +} diff --git a/test/pixelworld_physics.cpp b/test/pixelworld_physics.cpp new file mode 100644 index 00000000..88c4f20f --- /dev/null +++ b/test/pixelworld_physics.cpp @@ -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 + +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(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()); +} diff --git a/test/pixelworld_replay.cpp b/test/pixelworld_replay.cpp new file mode 100644 index 00000000..0035cb94 --- /dev/null +++ b/test/pixelworld_replay.cpp @@ -0,0 +1,234 @@ +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include "pixelworld/PixelWorld.h" +#include "pixelworld_replay/PixelWorldReplay.h" + +#include +#include +#include + +using namespace eve::pixelworld; +using namespace eve::pixelworld_replay; + +namespace { + +eve::SnapshotHashProvider replayHashProvider() { + return [](std::string_view input) -> eve::Result { + 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::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()->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)); +} diff --git a/test/pixelworld_script.cpp b/test/pixelworld_script.cpp new file mode 100644 index 00000000..26493441 --- /dev/null +++ b/test/pixelworld_script.cpp @@ -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; +} diff --git a/test/pixelworld_streaming.cpp b/test/pixelworld_streaming.cpp new file mode 100644 index 00000000..e2b8cbd6 --- /dev/null +++ b/test/pixelworld_streaming.cpp @@ -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 +#include +#include + +using namespace eve::pixelworld; +using namespace eve::pixelworld_streaming; + +namespace { + +eve::SnapshotHashProvider archiveHashProvider() { + return [](std::string_view input) -> eve::Result { + 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::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()->erase("sourceLastEditSequence"); + legacyPayload.getIf()->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()->at("chunks").getIf(); + auto& stored = *chunks.front().getIf()->at("stored").getIf(); + 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)); +}