feat(pixelworld): add deterministic Noita-style simulation stack
This commit is contained in:
1 parent
55ed73d4c1
commit
8ccfd5ecbe
75 files changed
+10429
-12
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,187 @@
|
||||
#include "pixelworld/PixelWorld.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <functional>
|
||||
#include <iostream>
|
||||
#include <mutex>
|
||||
#include <span>
|
||||
#include <string_view>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace {
|
||||
|
||||
class BenchmarkScheduler final : public eve::pixelworld::PixelWorkScheduler {
|
||||
public:
|
||||
explicit BenchmarkScheduler(std::size_t workerCount)
|
||||
: workerCount_(std::max<std::size_t>(1, workerCount)) {
|
||||
workers_.reserve(workerCount_);
|
||||
for (std::size_t index = 0; index < workerCount_; ++index)
|
||||
workers_.emplace_back([this] { workerLoop(); });
|
||||
}
|
||||
|
||||
~BenchmarkScheduler() override {
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
stopping_ = true;
|
||||
++generation_;
|
||||
}
|
||||
wake_.notify_all();
|
||||
}
|
||||
|
||||
void parallelFor(std::size_t workItems,
|
||||
const std::function<void(std::size_t)>& body) override {
|
||||
if (workItems == 0) return;
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
body_ = &body;
|
||||
workItems_ = workItems;
|
||||
next_.store(0, std::memory_order_relaxed);
|
||||
remaining_ = workerCount_;
|
||||
++generation_;
|
||||
}
|
||||
wake_.notify_all();
|
||||
std::unique_lock lock(mutex_);
|
||||
complete_.wait(lock, [this] { return remaining_ == 0; });
|
||||
body_ = nullptr;
|
||||
}
|
||||
|
||||
[[nodiscard]] std::size_t workerCount() const noexcept override { return workerCount_; }
|
||||
|
||||
private:
|
||||
void workerLoop() {
|
||||
std::size_t observedGeneration = 0;
|
||||
for (;;) {
|
||||
const std::function<void(std::size_t)>* body = nullptr;
|
||||
std::size_t workItems = 0;
|
||||
{
|
||||
std::unique_lock lock(mutex_);
|
||||
wake_.wait(lock, [this, &observedGeneration] {
|
||||
return stopping_ || generation_ != observedGeneration;
|
||||
});
|
||||
if (stopping_) return;
|
||||
observedGeneration = generation_;
|
||||
body = body_;
|
||||
workItems = workItems_;
|
||||
}
|
||||
for (;;) {
|
||||
const std::size_t index = next_.fetch_add(1, std::memory_order_relaxed);
|
||||
if (index >= workItems) break;
|
||||
(*body)(index);
|
||||
}
|
||||
{
|
||||
std::lock_guard lock(mutex_);
|
||||
if (--remaining_ == 0) complete_.notify_one();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::size_t workerCount_ = 1;
|
||||
std::vector<std::jthread> workers_;
|
||||
std::mutex mutex_;
|
||||
std::condition_variable wake_;
|
||||
std::condition_variable complete_;
|
||||
const std::function<void(std::size_t)>* body_ = nullptr;
|
||||
std::atomic<std::size_t> next_ = 0;
|
||||
std::size_t workItems_ = 0;
|
||||
std::size_t remaining_ = 0;
|
||||
std::size_t generation_ = 0;
|
||||
bool stopping_ = false;
|
||||
};
|
||||
|
||||
std::size_t parsePositive(char* text, std::size_t fallback) {
|
||||
if (text == nullptr) return fallback;
|
||||
char* end = nullptr;
|
||||
const unsigned long long value = std::strtoull(text, &end, 10);
|
||||
return end != text && *end == '\0' && value > 0 ? std::size_t(value) : fallback;
|
||||
}
|
||||
|
||||
std::uint64_t hashSnapshot(std::span<const std::byte> bytes) noexcept {
|
||||
std::uint64_t hash = 1469598103934665603ULL;
|
||||
for (const std::byte byte : bytes) {
|
||||
hash ^= std::uint8_t(byte);
|
||||
hash *= 1099511628211ULL;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
std::string_view benchmarkMaterial(std::string_view workload, int x, int y) noexcept {
|
||||
if (workload == "mixed-reaction") return ((x + y) & 1) != 0 ? "fire" : "water";
|
||||
if (workload == "reaction-only") return ((x + y) & 1) != 0 ? "acid" : "stone";
|
||||
return "sand";
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
using namespace eve::pixelworld;
|
||||
const std::size_t workers = parsePositive(argc > 1 ? argv[1] : nullptr, 4);
|
||||
const std::size_t samples = parsePositive(argc > 2 ? argv[2] : nullptr, 10);
|
||||
const std::string_view workload = argc > 3 ? std::string_view(argv[3]) : "sand";
|
||||
const bool mixedReaction = workload == "mixed-reaction";
|
||||
const bool reactionOnly = workload == "reaction-only";
|
||||
const bool thermalOnly = workload == "thermal-only";
|
||||
BenchmarkScheduler scheduler(workers);
|
||||
std::vector<double> milliseconds;
|
||||
milliseconds.reserve(samples);
|
||||
std::uint64_t totalVisited = 0;
|
||||
std::uint64_t totalMoved = 0;
|
||||
std::uint64_t totalReactions = 0;
|
||||
std::uint64_t totalThermalTransfers = 0;
|
||||
std::uint64_t totalPhaseChanges = 0;
|
||||
std::uint64_t snapshotHash = 0;
|
||||
for (std::size_t sample = 0; sample < samples; ++sample) {
|
||||
PixelWorld world(7003);
|
||||
for (int y = 0; y < 1024; ++y)
|
||||
for (int x = 0; x < 1024; ++x) {
|
||||
if (thermalOnly)
|
||||
world.setCell(x, y, {MaterialId::Stone,
|
||||
std::int16_t(((x + y) & 1) != 0 ? 700 : 20), 0});
|
||||
else
|
||||
world.setMaterial(x, y, benchmarkMaterial(workload, x, y));
|
||||
}
|
||||
const auto started = std::chrono::steady_clock::now();
|
||||
const StepStats stats = world.advanceScheduled(eve::SimulationTick(1), scheduler)
|
||||
.expect("million-mobile-pixel scheduled tick");
|
||||
milliseconds.push_back(std::chrono::duration<double, std::milli>(
|
||||
std::chrono::steady_clock::now() - started).count());
|
||||
totalVisited += stats.cellsVisited;
|
||||
totalMoved += stats.cellsMoved;
|
||||
totalReactions += stats.reactions;
|
||||
totalThermalTransfers += stats.temperatureTransfers;
|
||||
totalPhaseChanges += stats.phaseChanges;
|
||||
const auto snapshot = world.saveSnapshot().expect("million-mobile-pixel snapshot");
|
||||
const std::uint64_t sampleHash = hashSnapshot(snapshot);
|
||||
if (sample == 0)
|
||||
snapshotHash = sampleHash;
|
||||
else if (sampleHash != snapshotHash) {
|
||||
std::cerr << "non-deterministic snapshot at sample " << sample << '\n';
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
std::sort(milliseconds.begin(), milliseconds.end());
|
||||
const double p50 = milliseconds[milliseconds.size() / 2];
|
||||
const std::size_t p95Index = std::min(milliseconds.size() - 1,
|
||||
(milliseconds.size() * 95 + 99) / 100 - 1);
|
||||
std::cout << "PIXELWORLD_ACTIVE_BENCHMARK_JSON={\"occupiedPixels\":1048576,\"mobilePixels\":"
|
||||
<< (thermalOnly ? 0 : mixedReaction || reactionOnly ? 524288 : 1048576)
|
||||
<< ",\"chunks\":256,\"workload\":\""
|
||||
<< (mixedReaction ? "checkerboard-fire-water"
|
||||
: reactionOnly ? "checkerboard-acid-stone"
|
||||
: thermalOnly ? "checkerboard-hot-cold-stone" : "packed-sand-over-empty")
|
||||
<< "\",\"samples\":"
|
||||
<< samples << ",\"workers\":" << workers << ",\"visited\":" << totalVisited
|
||||
<< ",\"moved\":" << totalMoved << ",\"snapshotHash\":" << snapshotHash
|
||||
<< ",\"reactions\":" << totalReactions
|
||||
<< ",\"thermalTransfers\":" << totalThermalTransfers
|
||||
<< ",\"phaseChanges\":" << totalPhaseChanges
|
||||
<< ",\"p50Ms\":" << p50
|
||||
<< ",\"p95Ms\":" << milliseconds[p95Index] << "}\n";
|
||||
return 0;
|
||||
}
|
||||
@@ -73,6 +73,8 @@ eve_declare_module(NAME spatial LAYER 0 SCRIPT Spatial SLOT spatial
|
||||
# 与渲染解耦;examples/crowd 演示渲染由游戏脚本自行完成)。
|
||||
eve_declare_module(NAME crowd LAYER 0 SCRIPT Crowd SLOT crowd
|
||||
GROUP 2d 3d web)
|
||||
eve_declare_module(NAME pixelworld LAYER 0 SCRIPT PixelWorldModule SLOT pixelworld
|
||||
GROUP 2d 3d web)
|
||||
eve_declare_module(NAME ik LIB EVIK LAYER 0 SCRIPT IK
|
||||
GROUP 2d 3d web)
|
||||
# L6 -- editor orchestration
|
||||
@@ -86,6 +88,18 @@ eve_declare_module(NAME plugins LAYER 0 SCRIPT Plugins
|
||||
eve_declare_module(NAME database LAYER 0 SCRIPT Database
|
||||
THIRDPARTY poco_data poco)
|
||||
# L1 -- gameplay domain adapter
|
||||
eve_declare_module(NAME pixelworld_thread LAYER 1
|
||||
DEPS pixelworld thread
|
||||
GROUP 2d 3d web)
|
||||
eve_declare_module(NAME pixelworld_replay LAYER 1
|
||||
DEPS pixelworld
|
||||
GROUP 2d 3d web)
|
||||
eve_declare_module(NAME pixelworld_streaming LAYER 1
|
||||
DEPS asset pixelworld
|
||||
GROUP 2d 3d web)
|
||||
eve_declare_module(NAME pixelworld_editor LAYER 6 SCRIPT PixelWorldEditorModule SLOT pixelworldEditor
|
||||
DEPS pixelworld ui
|
||||
GROUP 2d 3d web)
|
||||
eve_declare_module(NAME rpg LIB EVRPG LAYER 1 SCRIPT RPG
|
||||
DEPS action attributes decision definitions effects inventory settlement)
|
||||
# L0 -- foundation (continued)
|
||||
|
||||
@@ -14,6 +14,10 @@ eve_declare_module(NAME asset_graphics LAYER 4
|
||||
DEPS asset graphics
|
||||
GROUP minimal 2d 3d web)
|
||||
|
||||
eve_declare_module(NAME pixelworld_graphics LAYER 4 SCRIPT PixelWorldGraphics SLOT pixelworldGraphics
|
||||
DEPS graphics pixelworld
|
||||
GROUP 2d 3d web)
|
||||
|
||||
eve_declare_module(NAME camera LAYER 4 SCRIPT Camera SLOT camera
|
||||
DEPS platform_event graphics scene
|
||||
GROUP minimal 2d 3d web)
|
||||
@@ -48,6 +52,9 @@ eve_declare_module(NAME weapon LAYER 4 SCRIPT Weapon SLOT weapon
|
||||
DEPS action attributes effects transaction definitions
|
||||
GROUP 2d 3d)
|
||||
# L5 -- vehicle adapter
|
||||
eve_declare_module(NAME pixelworld_physics LAYER 5
|
||||
DEPS pixelworld physics
|
||||
GROUP 2d)
|
||||
# Optional editing satellite. Runtime-only profiles can enable physics without
|
||||
# pulling editing/editor contracts or AssetDB adapters.
|
||||
eve_declare_module(NAME physics_editing LAYER 5
|
||||
|
||||
+2
-2
@@ -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()
|
||||
|
||||
@@ -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|程序化生成模块"
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
# PixelWorld / Noita 技术路线
|
||||
|
||||
状态:实施中。本文定义“完整 Noita 技术路线”的能力边界和验收证据,当前原型不等于完成。
|
||||
|
||||
## 总体契约
|
||||
|
||||
- `PixelWorld` 是材料、温度、寿命和结构连通性的唯一权威 owner;渲染、物理、音频、网络只消费版本化投影。
|
||||
- CPU 参考后端在相同 seed、输入命令流和 `SimulationTick` 下 bit-exact。并行 CPU 后端必须与参考后端 bit-exact;GPU 仿真若引入则明确标为 tolerance-bounded,不能静默替换回放后端。
|
||||
- 64×64 Chunk 是调度、睡眠、脏区、存档、网络和 GPU 上传的共同粒度。跨 Chunk 写入必须唤醒邻域并参与同一 revision。
|
||||
- 游戏脚本和未知回调只在 Tick 边界调用,不在 Chunk 锁内调用。Worker 仅处理 owning candidate buffer,提交按确定性 Chunk 顺序完成。
|
||||
- 持久格式使用 `eve.pixelworld` schema/version;未知新版本拒绝,迁移和恢复失败不改变可观察世界。
|
||||
|
||||
## P0:确定性材料核心(已具备纵切,继续扩展)
|
||||
|
||||
- 稀疏 Chunk、负坐标、固定 Tick、命名 seed、粉末/液体/气体/固体/能量状态。
|
||||
- 活跃/休眠 Chunk、跨边界唤醒、单 Tick 防重复更新。
|
||||
- 权威 revision 和 `snapshotChangedChunks()` owning 增量投影。
|
||||
- 数据驱动 `MaterialCatalog`:密度、黏度、导热率、热容量、燃点、熔/沸点、腐蚀、寿命、颜色和标签;加载时 schema 验证,运行中只用紧凑 ID。
|
||||
- 数据驱动反应表:输入材料、邻域条件、温度/概率门限、产物、热量和事件;规则冲突按稳定 priority/id 排序。
|
||||
|
||||
验收:跨平台 golden snapshot;10k Tick 重放 hash 一致;边界交换物质守恒;未知材料/规则以结构化诊断拒绝。
|
||||
|
||||
## P1:热力学、化学与破坏
|
||||
|
||||
- 定点温度扩散、热源/冷源、点燃、熔化、冻结、蒸发、凝结。
|
||||
- 油、木、火药、酸、熔岩、冰、烟等代表性链路;反应产物和放热守恒可诊断。
|
||||
- 圆刷、射线、爆炸、腐蚀和布尔 stamp 统一为 Tick 边界 `PixelEditCommand`,支持命令日志、undo/replay。
|
||||
- 静态材料连通域失支撑后生成碎片候选;碎片光栅从世界原子移除,失败不留下半提交状态。
|
||||
|
||||
验收:每条反应有正向/边界/失败测试;爆炸回放确定;大面积编辑只唤醒覆盖 Chunk 与一圈邻域。
|
||||
|
||||
当前结构破坏纵切:`extractUnsupportedFragments()` 在显式有限区域内以四邻域扫描
|
||||
Solid,连接支撑线或区域外 Solid 的分量继续由世界拥有;浮空分量按左上顺序生成
|
||||
owning `PixelFragment` bitmap,并在同一提交中从世界移除。`rasterizeFragment()` 先
|
||||
验证 runtime world/epoch link、bitmap 和所有目标格,再一次提交;冲突、restore 或
|
||||
clear 后的 stale link 均不产生部分写入。Physics adapter 与动态轮廓仍属于 P2。
|
||||
|
||||
## P2:刚体碎片与角色碰撞
|
||||
|
||||
- `PixelWorld` 产出轮廓/碎片 owning artifact,经 capability adapter 交给 Physics;Physics 不回写第二份材料真源。
|
||||
- Marching Squares/轮廓简化生成静态碰撞;按 dirty revision 增量重建。
|
||||
- 刚体碎片保存材料 bitmap 和 PixelWorld Link;碎片破裂或休眠时事务化栅格化回世界。
|
||||
- 角色使用探针/扫掠碰撞和材料接触查询,避免每像素创建刚体或 ECS entity。
|
||||
|
||||
验收:世界先销毁、刚体先销毁、restore/hot reload 两种顺序均无 stale 引用;碰撞重建预算可观测。
|
||||
|
||||
当前动态碎片纵切:可选 L5 `pixelworld_physics` 把 owning `PixelFragment` 以确定性
|
||||
top-left 贪心矩形分解为受显式 budget 限制的 Box2D fixtures。适配器跨帧只保存
|
||||
`PhysicsLink`,不保存 World/Body 裸指针;休眠时将 bitmap snap 到最近 90°,先请求
|
||||
PixelWorld 全量事务回填,成功后才销毁刚体。目标冲突会保留刚体,physics world
|
||||
先销毁、body 先释放和 PixelWorld restore stale 三条生命周期路径均有测试。静态 terrain
|
||||
现以带一格邻域采样的二值边界轮廓、共线简化及 Box2D chain/loop fixture 按 revision 增量
|
||||
重建;边界修改会同时重建已投影的相邻 Chunk,避免残留 seam。角色圆形 probe 与连续
|
||||
sweep 直接查询权威材料,带候选预算、确定性 tie-break 和精确圆角测试,不逐像素创建刚体。
|
||||
|
||||
## P3:规模化调度
|
||||
|
||||
- Chunk phase/color 调度,把无写冲突 Chunk 分批并行;跨边界写入 staging buffer,按 canonical 顺序提交。
|
||||
- 活跃矩形、睡眠迟滞、空 Chunk 回收、世界 streaming 和相机兴趣区。
|
||||
- SoA/packed cell 热数据,冷属性与事件分离;SIMD 温度/邻域计算。
|
||||
- 性能预算:桌面 CPU 参考目标 1M 活跃像素 60 Hz,常规场景按活跃比例降本;基准必须报告硬件、活跃像素、反应数和 p50/p95。
|
||||
|
||||
验收:单线程/并行 snapshot hash 一致;TSAN;百万像素 benchmark;越界 Chunk streaming 压测。
|
||||
|
||||
当前 P3 纵切:L0 `PixelWorkScheduler` 只允许同步执行 index-owned candidate slots,
|
||||
L1 `pixelworld_thread` 使用 JobSystem fork/join。热传导从同一 post-movement 只读状态
|
||||
并行生成 owning contribution buffers,再按 canonical Chunk/坐标提交;4 worker 与参考
|
||||
后端连续 100 Tick 的完整 version-3 snapshot 字节一致。Chunk 经过 3 Tick 无写迟滞才
|
||||
睡眠,空 Chunk 回收会发布 revisioned tombstone,使渲染和静态碰撞删除旧投影。
|
||||
运动阶段同样由 worker 对只读 Chunk 生成 index-owned、行序稳定的候选列表,再按原有
|
||||
Chunk/行/列 canonical 顺序提交;提交时重新读取源状态并保留下落、两侧对角线及液体
|
||||
横移的原 fallback 顺序,因此跨 Chunk 冲突结果不依赖 worker 调度。
|
||||
Chunk 写入路径增量维护持久化 phase metadata(非空/可移动计数、每材质计数、温度范围;
|
||||
温度极值被覆盖时延迟重算),每 Tick 只投影紧凑摘要:
|
||||
无可移动材质时跳过运动,世界不存在可配对反应物时跳过反应,均匀且邻域同温时跳过
|
||||
热传导且不创建空 worker task,phase transition 仅扫描同时含候选材质且温度范围可能
|
||||
越过阈值的 Chunk。反应优化使用世界级快照判定,
|
||||
不使用遍历中可能过期的逐 Chunk 邻域结论,以保持跨 Chunk 反应传播语义。
|
||||
|
||||
当前真实性能边界:AMD Ryzen 7 5700X、Debug、4 worker,1024×1024 均匀 stone、
|
||||
256 个每样本强制唤醒的 Chunk、10 样本,优化前 p50 2760.26 ms / p95 2807.64 ms,
|
||||
初始保守 phase summary 后 p50 319.325 ms / p95 326.232 ms;改为持久化 metadata、
|
||||
温度阈值筛选并消除空 thermal worker 后 p50 0.909 ms / p95 1.756 ms。该工作负载
|
||||
是 1,048,576 occupied pixels 但不是 1M 移动物质;它证明静态占用已低于 16.7 ms,
|
||||
并不证明 1M active 目标。移动/反应负载仍需继续改为 packed Chunk hot data、细粒度
|
||||
activity mask、无冲突运动候选和 SIMD。不得用睡眠空转或均匀固体 fast-path 宣称
|
||||
1M active 达标。
|
||||
|
||||
移动压力基线:同机 Debug、4 worker,初始 1024×1024 packed sand(1,048,576 个
|
||||
mobile pixels)连续 5 Tick,累计 visited 3,858,432、moved 777,142;并行候选初版
|
||||
p50 1190.67 ms / p95 1246.21 ms,改用 Chunk 内直接扫描并在提交中复用 source 后
|
||||
p50 784.636 ms / p95 906.146 ms;进一步按源 Chunk 批处理、同 Chunk 直接交换 cell
|
||||
slot 且仅在边界唤醒邻居后,p50 253.206 ms / p95 259.187 ms。该结果明确证明真正
|
||||
mobile 工作负载的逐像素候选随后改为每 Chunk 64×64 位 packed row mask,提交阶段以
|
||||
count-leading/trailing-zero 保持原左右 canonical 次序;Catalog 同时维护事务热重载时重建的
|
||||
material-state/位移矩阵,避免每次尝试重复解释 density/state。Clang Debug、4 worker、同一
|
||||
5 Tick 工作负载两次无并发复测为 p50 169.540/170.316 ms、p95 175.814/176.425 ms,snapshot
|
||||
和 moved/visited 计数保持不变。独立 Clang Release 构建曾在进入仿真前崩于
|
||||
`MaterialCatalog::builtIn()`:根因是 `Result<T>` 的私有观察 token 受 `ZEROERR_NO_ASSERT`
|
||||
控制对象布局,而 Release 库与保留断言的测试目标采用不同宏,形成跨目标 ODR/ABI 冲突。
|
||||
观察 token 现保持固定布局,裁剪 Release 探针已验证 13 项 built-in catalog 与稳定指纹可正常
|
||||
跨宏边界返回。`EVENGINE_BUILD_PIXELWORLD_BENCHMARK=ON` 现提供 renderer-independent 的
|
||||
`pixelworld_benchmark [workers] [samples]`,每个计时样本都重新创建 1024×1024 packed sand,
|
||||
保证每 Tick 确实访问 1,048,576 个 mobile pixels,初始化和 snapshot hash 不计入 Tick 时间。
|
||||
AMD Ryzen 7 5700X、Clang 21.1.8 Release、4 worker 的两次 10 样本结果为
|
||||
p50 11.078/13.055 ms、p95 12.821/14.753 ms,累计 visited 10,485,760、moved 976,960、
|
||||
reactions 0;1/4 worker 产生相同 snapshot hash `5924589683504813959`。这证明隔离的纯移动
|
||||
压力纵切已达到 16.7 ms 参考预算,但不代表热、相变和高反应密度同时开启的完整仿真已达标。
|
||||
系统缺少 `g++` 且自动 sudo 无法安装,GCC 结果仍未验证。下一瓶颈转向 canonical reaction
|
||||
commit、热传导 SIMD 和混合材质压力,需要颜色批次或确定性目标仲裁继续拆分。
|
||||
|
||||
同一 benchmark 还提供 `reaction-only`(acid/stone checkerboard)和 `mixed-reaction`
|
||||
(fire/water checkerboard)模式。Catalog runtime cache 现为每个有序材质对保存 canonical
|
||||
规则索引序列,热重载时原子重建;这避免每个邻居重复遍历全部规则,同时仍允许高优先级
|
||||
温度规则不匹配后落到低优先级规则。4 worker、Release 的纯反应 5 样本从
|
||||
p50 59.675 / p95 61.510 ms 降到 p50 45.805 / p95 49.319 ms;随后同 Chunk 反应读取和
|
||||
metadata 写入改为直接 slot,跨 Chunk/可能创建 Air 的自定义规则仍走安全稀疏路径并在插入后
|
||||
重取引用,进一步降至 p50 30.141 / p95 30.544 ms。反应数 2,615,830、snapshot hash
|
||||
`6722933230463575247` 始终不变。热阶段随后把每次传热的 heap contribution 和百万项
|
||||
坐标哈希/排序替换为每源 Chunk 的 local/right/bottom 固定 delta buffer,再归并到目标
|
||||
Chunk 的 4096 项稠密 delta;计算预取 own/right/bottom Chunk 和 Catalog 热容/导热率表,
|
||||
提交按互斥 Chunk 并行并跳过已归一化 cell 的重复除法。fire/water 混合压力由
|
||||
p50 223.694 / p95 247.968 ms 降至稳定约 p50 90 ms / p95 98 ms(5 样本、
|
||||
10,449,965 thermal transfers),snapshot hash `16111782154737202544` 不变。独立
|
||||
hot/cold stone 热压力约 p50 36.1 / p95 38.0 ms;完整混合仿真仍未达到 16.7 ms,
|
||||
不能用纯移动达标替代该目标。
|
||||
|
||||
## P4:渲染、视觉和 GPU 上传
|
||||
|
||||
- 每个变化 Chunk 一次紧凑材质/温度纹理上传,禁止逐像素脚本调用和逐像素 draw call。
|
||||
- Chunk atlas/array texture、调色板 LUT、温度发光、火焰/烟雾扰动、法线/液面边缘和屏幕后处理。
|
||||
- CPU readback 只用于 MCP snapshot/诊断;渲染缓存携带 source revision,可检测落后和重建。
|
||||
- 粒子 VFX 是可丢失投影,不反向成为材料权威状态。
|
||||
|
||||
验收:MCP `eve_screenshot` 来自引擎 framebuffer;渲染 revision 追上世界 revision;1M 像素上传与绘制不产生 1M draw call。`pixelworld_graphics` 已将脚本侧绘制降为一张图集/一次 draw,并按 chunk revision 更新 CPU 图集;Vulkan/WebGPU 均实现原纹理子区域上传,不再为动态像素重建 GPU 纹理。Vulkan 批量接口把多个 dirty region 紧密打包进一次 staging/command submission;百万像素测试覆盖 256 个可见 Chunk,随后两个相距很远的修改只提交 2 个 region。真实桌面 MCP framebuffer 证据为 `/tmp/pixelworld_multiregion_mcp.png`,Validation 未报告 VUID。
|
||||
|
||||
## P5:世界生成、存档、回放和网络
|
||||
|
||||
- 确定性 biome/洞穴生成、材料 stamp、兴趣区 streaming、Chunk 压缩与增量存档。
|
||||
- Snapshot v2 使用公共 `SnapshotEnvelope`、内容 hash、Chunk 压缩、N/N-1 migration 和损坏隔离。
|
||||
- 输入命令日志 + 周期 checkpoint;分歧定位到 Tick/Chunk/revision。
|
||||
- 联机默认同步命令与权威 Chunk 修正,不承诺把每个像素作为独立复制对象。
|
||||
|
||||
验收:旧存档迁移、截断/未知字段/坏 hash 不部分恢复;长回放 hash;断线重连 Chunk 校正。
|
||||
当前 `pixelworld_replay` 已实现严格 sequence/Tick 命令日志、周期 world/Chunk
|
||||
checkpoint、空世界位精确重放和首个 Tick/Chunk/revision 分歧报告。replay
|
||||
codec 使用公共 `SnapshotEnvelope` 的 `pixelworld:replay-log` version 2,完整验证
|
||||
hash 后事务化解码,接受缺少 Chunk 诊断的 version 1;坏 hash、截断、未知字段和
|
||||
未知新版本不部分修改旧日志。`pixelworld_streaming` 已实现有界 Chunk 兴趣区、revision
|
||||
增量、离区 tombstone、source epoch 全量重同步,以及带 Catalog/seed/revision/Tick/
|
||||
edit-sequence 校验的事务化权威 Chunk correction;断线后增量校正已有组合测试。
|
||||
`pixelworld:chunk-batch` SnapshotEnvelope version 3 对每个 Chunk 独立使用 none/zstd、
|
||||
decoded-content hash 和有界解码,接受 version 1/2 legacy cell 并将缺失热能 residual 迁移为
|
||||
0;坏外层/Chunk hash 均在 authority 写入前拒绝。可靠会话已实现有界发送/接收窗口、
|
||||
单 Chunk 分片、丢包重传、乱序缓冲、冲突重复包拒绝、整 transfer 原子提交和累计应用层
|
||||
ACK;发送端在 ACK 前保留 owning correction/tombstone,窗口或分片预算失败不推进 cursor。
|
||||
该状态机可承载于 `UdpLink::Reliable`/可靠 RPC,拥塞、认证和加密继续由宿主网络层负责。
|
||||
确定性世界生成纵切现由 `generatePixelWorld` 提供:version 1 有界请求使用全局整数坐标
|
||||
hash 和插值地表生成 forest/desert/fungal/volcanic biome、无 Chunk 接缝洞穴、水穴与熔岩
|
||||
特征,并在地形之后应用可跨 Chunk 的 owning row-major material stamps。生成器不持有或
|
||||
修改世界,只返回 canonical `PixelChunkBatch`、逐 Chunk 统计和稳定 content hash;调用方
|
||||
通过现有 `applyChunkBatch` 的 fingerprint/seed/revision/Tick/edit-sequence 校验事务式提交。
|
||||
测试证明同 seed 重复生成 bit-exact、单 Chunk 与大区域中的同坐标 Chunk 完全一致、换 seed
|
||||
改变 hash、跨 Chunk stamp 正确分片,未知 schema 在 authority 外即被拒绝且世界 snapshot 不变。
|
||||
`eve.pixelworld.generation-request` version 1 JSON codec 已持久化全部生成参数与 owning stamp
|
||||
cells;seed 以 canonical 十进制 uint64 字符串保存,避免 JSON number 精度丢失。解码严格拒绝
|
||||
未知/缺失字段、未知版本、窄整数溢出、尺寸不匹配和超预算 stamp,并且不会接触 live world。
|
||||
|
||||
## P6:创作与诊断工具链
|
||||
|
||||
- 材料/反应 schema 编辑器、实时校验、调色板预览、热更新事务。
|
||||
- Chunk 活跃度、睡眠、温度、反应、脏 revision、上传延迟和模拟预算 overlay。
|
||||
- MCP:查询材料/区域、提交编辑命令、暂停/单步、snapshot/restore、性能采样和引擎 framebuffer 截图。
|
||||
- 自动场景:沙水守恒、火灾、爆炸、酸蚀、碎片、百万像素压力与存档迁移。
|
||||
|
||||
验收:UI/CLI/MCP 复用同一领域操作;无绕过 authority 的调试写入口;每个自动场景同时产出数值断言和引擎截图证据。
|
||||
|
||||
当前进度:共享 `PixelWorldControlService`、权威暂停/显式单步、严格序列编辑、有界
|
||||
Chunk 活跃度/温度诊断、事务化 snapshot/restore 已实现;MCP 通过模块中立的
|
||||
`IPixelWorldAutomation` capability 暴露对应工具。每世界 256 条有界性能历史和可切换
|
||||
Chunk/温度/耗时 overlay 已接入示例。真实桌面 MCP 已查询 live world、Chunk diagnostics
|
||||
和 samples,并通过 `eve_screenshot` 生成 `/tmp/pixelworld_p6_mcp.png`(960x576);未使用
|
||||
Xvfb。材料 Catalog version 1 JSON codec、颜色/标签权威数据、validate/canonicalize MCP 工具,
|
||||
以及 paused + optimistic fingerprint 的事务化 live reload 已完成;规则变更会推进 epoch 并
|
||||
显式使旧 replay history 失效。`pixelworld_editor` 现提供独立的事务式 Catalog draft、材料
|
||||
浏览/颜色预览/完整属性编辑、二元反应和相变规则增删改,以及命名 UI host 的
|
||||
open/close/refresh 生命周期;脚本 `pixelworldEditor.openCatalog()` 是生产入口,示例把 UI
|
||||
pass 合入最终帧。面板通过共享 control service 发布,不保存 world 指针或绕过 authority。
|
||||
真实桌面引擎 MCP 已确认 `eve_pixelworld_catalog` 的 94 节点树,并由 `eve_screenshot`
|
||||
生成 `/tmp/pixelworld_catalog_editor_mcp.png`(960x576);未使用 Xvfb。
|
||||
|
||||
## 当前完成边界
|
||||
|
||||
已完成的是 P0 的稀疏确定性纵切、经验证的 owning `MaterialCatalog`、确定性
|
||||
`MaterialReactionRule`、带 Catalog fingerprint、edit sequence、热能 residual 和 v1/v2/v3
|
||||
读取迁移的 version 4 snapshot、
|
||||
Chunk 增量投影和可交互示例。P1 已有固定阶段的整数热扩散、数据驱动
|
||||
`MaterialPhaseRule`、水/冰/蒸汽、石头/熔岩、酸蚀、火药与淬火代表链路,以及
|
||||
严格 sequenced 的绘制/加热/爆炸 `PixelEditCommand` 与事务 receipt。
|
||||
P1 还具备失支撑 Solid 连通域的事务化提取和带 epoch stale detection 的 bitmap
|
||||
回填;P4 已具备单 atlas draw、按 dirty Chunk 的多 region GPU 增量上传和百万像素
|
||||
覆盖测试;P2 已有动态碎片 Physics adapter、fixture budget、休眠回填和双销毁顺序
|
||||
测试。P3 已有并行热候选、bit-exact 对比、睡眠迟滞、空 Chunk tombstone 与真实性能
|
||||
基线,但完整并行运动/反应、SIMD 和 1M active/60 Hz 尚未达到。P5 已有确定性命令日志、
|
||||
checkpoint、首个分歧定位、兴趣区 streaming 和事务化权威 Chunk 校正。材料 Catalog 外部
|
||||
schema JSON 加载与事务热重载、静态轮廓碰撞、角色 probe/sweep、整数热能 residual 守恒,
|
||||
以及带应用提交 ACK/tombstone 保留的可靠 Chunk transport 状态机、专用可视 Catalog 编辑
|
||||
面板均已完成。路线仍未完整交付:P3 的完整并行反应/SIMD 与高密度热+反应 1M/60 Hz
|
||||
尚未达到;P5 已有 biome/洞穴/stamp 确定性纵切和生成请求持久化 codec,但增量按需生成
|
||||
调度与生成器接入示例/编辑器仍需完成,不能据现有演示宣称完整路线已交付。
|
||||
@@ -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
|
||||
|
||||
@@ -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):组合属性、效果、状态、技能、施法与伤害结算。
|
||||
|
||||
@@ -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()`
|
||||
|
||||
@@ -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)。
|
||||
@@ -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)。
|
||||
@@ -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()`。
|
||||
@@ -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.
|
||||
@@ -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)。
|
||||
@@ -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)。
|
||||
@@ -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.
|
||||
@@ -36,6 +36,7 @@ make run/<platform>-debug GAME=examples/<name>
|
||||
| [galgame](galgame/README.md) | 完整视觉小说《潮汐电台》:原创美术、分支结局、AUTO/SKIP/回想与存读档 |
|
||||
| [building](building/README.md) | 建筑放置:地形约束、道路邻接、鬼影预览、旋转拆除 |
|
||||
| [building-tilemap](building-tilemap/README.md) | 在等距 / 六角 tilemap 上放置建筑(2D 精灵穿插) |
|
||||
| [pixelworld](pixelworld/README.md) | Noita 风格的分块沙、水、油、火焰与蒸汽像素物质仿真 |
|
||||
| [iso-grid-walk](iso-grid-walk/README.md) | 独立 2.5D PNG 经可插拔 pipeline 生成 TileSet,方格移动与 A* |
|
||||
| [dynamic-water-grid](dynamic-water-grid/README.md) | 双网格岸线、逐格守恒水量、等距连续水面 Shader 与素材替换契约 |
|
||||
| [metroidvania](metroidvania/README.md) | 物理驱动的横版动作游戏:连击、蹬墙跳、空中冲刺、Boss |
|
||||
|
||||
@@ -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`,不存在与材料状态可能失同步的第二套破坏遮罩。
|
||||
@@ -0,0 +1,7 @@
|
||||
config = {
|
||||
width = 960
|
||||
height = 576
|
||||
title = "EVEngine PixelWorld"
|
||||
debug = true
|
||||
hotReload = true
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
{"native":{"anim":[],"card":{"cards":[]},"dialogue":[{"characters":[],"choices":[],"currentLineId":"","fullText":"","globalVars":{},"lipSyncAmplitude":0.8500000238418579,"lipSyncEnabled":true,"lipSyncParameter":"mouthOpen","phase":"idle","rngState":2654435769,"sceneVars":{},"selectedChoiceId":"","slotX":{},"speakerId":"","typeSpeed":40,"typed":0}],"rpg":[]},"roots":{"PI":3.1415927410125732,"RAND_MAX":2147483647,"_async":{"continuations":[],"draining":false,"eventWaits":[],"generation":2,"maxDrain":1000,"nextId":1,"nextTicks":[],"taskWaits":[],"timers":[],"waits":[]},"_charsize_":1,"_floatsize_":4,"_frame_count":7384,"_input_edge_state":{"k_1":false,"k_2":false,"k_3":false,"k_4":false,"k_5":false,"k_6":false,"k_7":false,"k_8":false,"k_9":false,"k_d":false,"k_e":false,"k_equals":false,"k_f":false,"k_minus":false,"k_o":false,"k_plus":false,"k_r":false},"_intsize_":8,"_startup_first_frame":true,"_startup_first_present":true,"_startup_t0":0.5241100192070007,"_versionnumber_":310,"accumulator":0.015962159261107445,"brushMaterial":"sand","brushRadius":4,"eve_module_contract":[{"classes":[],"deps":[],"enabled":true,"layer":-1,"name":"common","optionalDeps":[],"profiles":[],"required":true,"slots":[]},{"classes":[],"deps":["filesystem"],"enabled":true,"layer":-1,"name":"cmdline","optionalDeps":[],"profiles":[],"required":true,"slots":[]},{"classes":[],"deps":["platform_event"],"enabled":true,"layer":-1,"name":"devtools","optionalDeps":[],"profiles":["3d"],"required":false,"slots":[]},{"classes":["Math"],"deps":[],"enabled":true,"layer":0,"name":"math","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":["math"]},{"classes":[],"deps":[],"enabled":true,"layer":0,"name":"grid","optionalDeps":[],"profiles":["2d","3d","web"],"required":false,"slots":[]},{"classes":[],"deps":[],"enabled":true,"layer":0,"name":"property_access","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":[]},{"classes":[],"deps":["property_access"],"enabled":true,"layer":1,"name":"scriptmodel","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":[]},{"classes":[],"deps":["property_access","schema","transaction"],"enabled":true,"layer":1,"name":"editing","optionalDeps":[],"profiles":[],"required":false,"slots":[]},{"classes":["DataModule"],"deps":[],"enabled":true,"layer":0,"name":"data","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":[]},{"classes":["Filesystem","HotReload"],"deps":[],"enabled":true,"layer":0,"name":"filesystem","optionalDeps":[],"profiles":[],"required":true,"slots":["fs","hot"]},{"classes":[],"deps":["data"],"enabled":true,"layer":0,"name":"asset","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":[]},{"classes":["PlatformEvent"],"deps":[],"enabled":true,"layer":0,"name":"platform_event","optionalDeps":[],"profiles":[],"required":true,"slots":["platform_event"]},{"classes":["Timer"],"deps":[],"enabled":true,"layer":0,"name":"timer","optionalDeps":[],"profiles":[],"required":true,"slots":["timer"]},{"classes":["System"],"deps":[],"enabled":true,"layer":0,"name":"system","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":["system"]},{"classes":["Profiler"],"deps":[],"enabled":true,"layer":0,"name":"profiler","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":["profiler"]},{"classes":["Thread"],"deps":[],"enabled":true,"layer":0,"name":"thread","optionalDeps":[],"profiles":["minimal","2d","3d","web"],"required":false,"slots":["thread"]},{"classes":["Spatial"],"deps":[],"enabled":true,"layer":0,"name":"spatial","optionalDeps":[],"profiles":["2d","3d","web"],"required":false,"slots":["spatial"]},{"classes":["Crowd"],"deps":[],"enabled":true,"layer":0,"name":"crowd","optionalDeps":[],"profiles":["2d","3d","web"],"required":false,"slots":["crowd"]},{"classes":["PixelWorldModule"],"deps":[],"enabled":true,"layer":0,"name":"pixelworld","optionalDeps":[],"profiles":["2d","3d","web"],"required":false,"slots":["pixelworld"]},{"classes":["IK"],"deps":[],"enabled":true,"layer":0,"name":"ik","optionalDeps":[],"profiles":["2d","3d","web"],"required":false,"slots":[]},{"classes":["Editor"],"deps":["action","asset","editing","material_editing","property_access","scene_editing","tags","transaction"],"enabled":true,"layer":6,"name":"editor","optionalDeps":["animation","audio","avatar","building","camera","crowd","daynight","definitions","dialogue","fluids","graphics","hd2d","housegen","image","network","orders","particles","physics","physics_editing","production","procgen","profiler","map","scene","sceneloader","schema","snow","social","spritestack","ui","virtualgeometry","voxel","weather"],"profiles":["3d","web"],"required":false,"slots":["editor"]},{"classes":["Plugins"],"deps":[],"enabled":true,"layer":0,"name":"plugins","optionalDeps":[],"profiles":["3d"],"required":false,"slots":[]},{"classes":["Database"],"deps":[],"enabled":true,Line truncated
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
#pragma once
|
||||
|
||||
#include "common/Export.h"
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace eve {
|
||||
|
||||
/** @brief Module-neutral JSON bridge from DevTools/MCP to PixelWorld tooling. */
|
||||
class EVENGINE_API IPixelWorldAutomation {
|
||||
public:
|
||||
static constexpr const char* capabilityName = "IPixelWorldAutomation";
|
||||
virtual ~IPixelWorldAutomation() = default;
|
||||
/** @brief Invoke a stable PixelWorld tooling operation and return a JSON object. */
|
||||
virtual std::string invoke(const std::string& operation,
|
||||
const std::string& requestJson) = 0;
|
||||
};
|
||||
|
||||
} // namespace eve
|
||||
@@ -24,8 +24,9 @@ namespace detail {
|
||||
*
|
||||
* The token starts active for a newly-created Result. A move transfers its
|
||||
* responsibility after payload movement succeeds; a moved-from Result is
|
||||
* disarmed. In non-assert builds this class has no data member and its methods
|
||||
* compile to no-ops.
|
||||
* disarmed. The two bytes remain present in non-assert builds so Result keeps
|
||||
* one ABI when a Release engine library is consumed by an assertion-enabled
|
||||
* test or tool; the observation operations themselves compile to no-ops.
|
||||
*/
|
||||
class ResultObservation {
|
||||
public:
|
||||
@@ -37,9 +38,7 @@ public:
|
||||
/** @brief Start an inactive token used while a Result move is constructed. */
|
||||
struct InactiveTag {};
|
||||
explicit ResultObservation(InactiveTag) noexcept {
|
||||
#if !defined(ZEROERR_NO_ASSERT)
|
||||
mustObserve_ = false;
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !defined(ZEROERR_NO_ASSERT)
|
||||
@@ -76,10 +75,11 @@ protected:
|
||||
}
|
||||
|
||||
private:
|
||||
#if !defined(ZEROERR_NO_ASSERT)
|
||||
// Do not condition these fields on ZEROERR_NO_ASSERT. Public Result<T>
|
||||
// values cross static/shared-library boundaries whose assertion policy may
|
||||
// intentionally differ from the consumer (notably Release unit tests).
|
||||
mutable bool observed_ = false;
|
||||
bool mustObserve_ = true;
|
||||
#endif
|
||||
};
|
||||
|
||||
template <class T>
|
||||
@@ -93,8 +93,9 @@ using ResultReturn = std::invoke_result_t<T>;
|
||||
* Every Result is checked in an assertion-enabled build before destruction.
|
||||
* Calling `ok`, `status`, `value`, `error`, a composition helper, `ignore`, or
|
||||
* `expect` counts as an explicit observation. In release-style builds where
|
||||
* `ZEROERR_NO_ASSERT` is defined, observation has no storage or branch cost;
|
||||
* the class remains `[[nodiscard]]` at compile time.
|
||||
* `ZEROERR_NO_ASSERT` is defined, observation checks have no branch cost. The
|
||||
* small observation token retains a stable ABI and the class remains
|
||||
* `[[nodiscard]]` at compile time.
|
||||
*
|
||||
* @tparam T Owning value type. References and void use a different form.
|
||||
*/
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
#include "common/EditorHost.h"
|
||||
#include "common/Module.h"
|
||||
#include "common/ParticlesQuery.h"
|
||||
#include "common/PixelWorldAutomation.h"
|
||||
#include "common/PhysicsQuery.h"
|
||||
#include "common/ProcgenQuery.h"
|
||||
#include "common/RenderCapture.h"
|
||||
@@ -436,6 +437,9 @@ eve::IAudioQuery* mcpAudio() { return eve::cap::query<eve::IAudioQuery>();
|
||||
eve::IEditorHost* mcpHost() { return eve::cap::query<eve::IEditorHost>(); }
|
||||
eve::IUIAutomation* mcpUI() { return eve::cap::query<eve::IUIAutomation>(); }
|
||||
eve::IEditorAutomation* mcpEditor() { return eve::cap::query<eve::IEditorAutomation>(); }
|
||||
eve::IPixelWorldAutomation* mcpPixelWorld() {
|
||||
return eve::cap::query<eve::IPixelWorldAutomation>();
|
||||
}
|
||||
|
||||
Poco::JSON::Object::Ptr renderableObservation(eve::IRenderCapture& capture, int entityId, int generation) {
|
||||
Poco::JSON::Object::Ptr output = Poco::JSON::Object::Ptr(new Poco::JSON::Object());
|
||||
@@ -695,6 +699,13 @@ std::string callTool(McpServer& mcp, const std::string& name, Poco::JSON::Object
|
||||
|
||||
if (name == "eve_status") return engineStatusJson(mcp);
|
||||
|
||||
if (name.rfind("eve_pixelworld_", 0) == 0) {
|
||||
auto* provider = mcpPixelWorld();
|
||||
if (!provider) return "{\"ok\":false,\"error\":\"pixelworld module not available\"}";
|
||||
return provider->invoke(name.substr(std::string("eve_pixelworld_").size()),
|
||||
mcpStringify(Poco::Dynamic::Var(args)));
|
||||
}
|
||||
|
||||
// ============================= Game UI =============================
|
||||
if (name == "eve_ui_tree") {
|
||||
return mcpUI() ? mcpUI()->tree(argString(args, "host")) : "error: ui module not available";
|
||||
@@ -1634,6 +1645,39 @@ std::string handleToolsList(const std::string& idJson) {
|
||||
"{\"tools\":["
|
||||
"{\"name\":\"eve_status\",\"description\":\"Runtime + debugger + MCP/DAP status JSON.\","
|
||||
"\"inputSchema\":{\"type\":\"object\",\"properties\":{}}},"
|
||||
"{\"name\":\"eve_pixelworld_worlds\",\"description\":\"List live PixelWorld simulations and status.\","
|
||||
"\"inputSchema\":{\"type\":\"object\",\"properties\":{}}},"
|
||||
"{\"name\":\"eve_pixelworld_catalog_builtin\",\"description\":\"Return the canonical versioned built-in material/reaction Catalog document.\","
|
||||
"\"inputSchema\":{\"type\":\"object\",\"properties\":{}}},"
|
||||
"{\"name\":\"eve_pixelworld_catalog_validate\",\"description\":\"Transactionally validate and canonicalize a material/reaction Catalog document.\","
|
||||
"\"inputSchema\":{\"type\":\"object\",\"properties\":{\"catalog\":{}},\"required\":[\"catalog\"]}},"
|
||||
"{\"name\":\"eve_pixelworld_catalog_apply\",\"description\":\"Hot-reload a compatible Catalog into a paused world using optimistic fingerprint concurrency.\","
|
||||
"\"inputSchema\":{\"type\":\"object\",\"properties\":{\"world\":{\"type\":\"integer\"},\"catalog\":{},"
|
||||
"\"expectedFingerprint\":{\"type\":\"string\"}},\"required\":[\"world\",\"catalog\",\"expectedFingerprint\"]}},"
|
||||
"{\"name\":\"eve_pixelworld_pause\",\"description\":\"Pause or resume one PixelWorld.\","
|
||||
"\"inputSchema\":{\"type\":\"object\",\"properties\":{\"world\":{\"type\":\"integer\"},"
|
||||
"\"paused\":{\"type\":\"boolean\"}},\"required\":[\"world\",\"paused\"]}},"
|
||||
"{\"name\":\"eve_pixelworld_step\",\"description\":\"Explicitly advance one PixelWorld by 1-1024 ticks, including while paused.\","
|
||||
"\"inputSchema\":{\"type\":\"object\",\"properties\":{\"world\":{\"type\":\"integer\"},"
|
||||
"\"count\":{\"type\":\"integer\",\"minimum\":1,\"maximum\":1024}},\"required\":[\"world\"]}},"
|
||||
"{\"name\":\"eve_pixelworld_edit\",\"description\":\"Apply a strictly sequenced paint, heat, or explosion edit.\","
|
||||
"\"inputSchema\":{\"type\":\"object\",\"properties\":{\"world\":{\"type\":\"integer\"},"
|
||||
"\"sequence\":{\"type\":\"integer\"},\"kind\":{\"type\":\"string\",\"enum\":[\"paint\",\"heat\",\"explosion\"]},"
|
||||
"\"x\":{\"type\":\"integer\"},\"y\":{\"type\":\"integer\"},\"radius\":{\"type\":\"integer\"},"
|
||||
"\"material\":{\"type\":\"integer\"},\"strength\":{\"type\":\"integer\"},"
|
||||
"\"temperatureDelta\":{\"type\":\"integer\"}},\"required\":[\"world\",\"sequence\",\"kind\"]}},"
|
||||
"{\"name\":\"eve_pixelworld_diagnostics\",\"description\":\"Inspect bounded per-Chunk activity, material and temperature diagnostics.\","
|
||||
"\"inputSchema\":{\"type\":\"object\",\"properties\":{\"world\":{\"type\":\"integer\"},"
|
||||
"\"minX\":{\"type\":\"integer\"},\"minY\":{\"type\":\"integer\"},\"maxX\":{\"type\":\"integer\"},"
|
||||
"\"maxY\":{\"type\":\"integer\"}},\"required\":[\"world\"]}},"
|
||||
"{\"name\":\"eve_pixelworld_samples\",\"description\":\"Read the latest bounded PixelWorld simulation performance samples.\","
|
||||
"\"inputSchema\":{\"type\":\"object\",\"properties\":{\"world\":{\"type\":\"integer\"},"
|
||||
"\"limit\":{\"type\":\"integer\",\"minimum\":1,\"maximum\":256}},\"required\":[\"world\"]}},"
|
||||
"{\"name\":\"eve_pixelworld_snapshot_capture\",\"description\":\"Capture a canonical PixelWorld snapshot as hex.\","
|
||||
"\"inputSchema\":{\"type\":\"object\",\"properties\":{\"world\":{\"type\":\"integer\"}},\"required\":[\"world\"]}},"
|
||||
"{\"name\":\"eve_pixelworld_snapshot_restore\",\"description\":\"Transactionally restore a canonical hex PixelWorld snapshot.\","
|
||||
"\"inputSchema\":{\"type\":\"object\",\"properties\":{\"world\":{\"type\":\"integer\"},"
|
||||
"\"snapshot\":{\"type\":\"string\"}},\"required\":[\"world\",\"snapshot\"]}},"
|
||||
"{\"name\":\"eve_ui_tree\",\"description\":\"Inspect retained game UI hosts and semantic widget trees (not "
|
||||
"only eve_host editors).\","
|
||||
"\"inputSchema\":{\"type\":\"object\",\"properties\":{\"host\":{\"type\":\"string\",\"description\":\"optional "
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <glm/glm.hpp>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include "common/Module.h"
|
||||
@@ -42,6 +43,20 @@ class GrassField;
|
||||
class Material;
|
||||
class Mesh;
|
||||
|
||||
/**
|
||||
* @brief One borrowed RGBA8 source rectangle for a batched texture update.
|
||||
* @ownership `rgba` remains owned by the caller and is never retained.
|
||||
* @lifetime The byte span must remain valid through updateTextureRegions().
|
||||
*/
|
||||
struct TextureRegionUpload {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
std::span<const std::uint8_t> rgba;
|
||||
std::size_t bytesPerRow = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Backend-owned layout facts for a mesh uploaded through Graphics.
|
||||
*
|
||||
@@ -486,6 +501,36 @@ public:
|
||||
virtual bool updateTexture(Texture *texture, int width, int height,
|
||||
const uint8_t *rgba) = 0;
|
||||
|
||||
/**
|
||||
* @brief Upload one tightly packed or row-strided RGBA8 rectangle into mip level zero.
|
||||
* @param texture Borrowed texture owned by this Graphics backend.
|
||||
* @param x Destination pixel offset from the left edge.
|
||||
* @param y Destination pixel offset from the top edge.
|
||||
* @param width Rectangle width in pixels.
|
||||
* @param height Rectangle height in pixels.
|
||||
* @param rgba Owning-external bytes borrowed only for this synchronous call.
|
||||
* @param bytesPerRow Source row stride; zero means `width * 4`.
|
||||
* @return Success after the upload is visible to subsequent draws, or a diagnostic.
|
||||
* @ownership Graphics retains neither `texture` nor `rgba`; it already owns texture storage.
|
||||
* @lifetime `texture` and `rgba` must remain valid only for this render-thread call.
|
||||
* @remarks Textures with mip chains are rejected because partial mip regeneration is undefined.
|
||||
*/
|
||||
[[nodiscard]] virtual eve::Result<void> updateTextureRegion(
|
||||
Texture *texture, int x, int y, int width, int height,
|
||||
std::span<const std::uint8_t> rgba, std::size_t bytesPerRow = 0) = 0;
|
||||
|
||||
/**
|
||||
* @brief Validate then upload multiple independent mip-zero RGBA8 regions as one batch.
|
||||
* @param texture Borrowed single-mip texture owned by this Graphics backend.
|
||||
* @param regions Borrowed descriptors and source spans, consumed synchronously.
|
||||
* @return Success after every region is accepted and uploaded, otherwise no upload occurs.
|
||||
* @ownership Graphics retains neither the texture nor region/source spans.
|
||||
* @lifetime All arguments need remain valid only through this render-thread call.
|
||||
* @remarks Vulkan guarantees one staging allocation and queue submission for the batch.
|
||||
*/
|
||||
[[nodiscard]] virtual eve::Result<void> updateTextureRegions(
|
||||
Texture *texture, std::span<const TextureRegionUpload> regions) = 0;
|
||||
|
||||
/**
|
||||
* @brief Recreate the sampler for an existing texture (keeps image / mip chain).
|
||||
* No-op when texture is null or not owned by this Graphics.
|
||||
|
||||
@@ -404,6 +404,11 @@ public:
|
||||
bool releaseTexture(Texture *texture) override;
|
||||
bool updateTexture(Texture *texture, int width, int height,
|
||||
const uint8_t *rgba) override;
|
||||
eve::Result<void> updateTextureRegion(Texture *texture, int x, int y, int width,
|
||||
int height, std::span<const std::uint8_t> rgba,
|
||||
std::size_t bytesPerRow = 0) override;
|
||||
eve::Result<void> updateTextureRegions(
|
||||
Texture *texture, std::span<const TextureRegionUpload> regions) override;
|
||||
bool replaceTexturePixels(Texture *tex, image::ImageData *data);
|
||||
bool replaceTexturePixelsRGBA(Texture *tex, int w, int h, const uint8_t *rgba);
|
||||
void drawTexturedRect(Texture *texture, float x, float y, float w, float h, const Color &color) override;
|
||||
|
||||
@@ -680,6 +680,111 @@ bool Graphics::updateTexture(Texture *tex, int width, int height, const uint8_t
|
||||
return replaceTexturePixelsRGBA(tex, width, height, rgba);
|
||||
}
|
||||
|
||||
eve::Result<void> Graphics::updateTextureRegion(Texture *tex, int x, int y, int width,
|
||||
int height,
|
||||
std::span<const std::uint8_t> rgba,
|
||||
std::size_t bytesPerRow) {
|
||||
const TextureRegionUpload upload{x, y, width, height, rgba, bytesPerRow};
|
||||
return updateTextureRegions(tex, std::span<const TextureRegionUpload>(&upload, 1));
|
||||
}
|
||||
|
||||
eve::Result<void> Graphics::updateTextureRegions(
|
||||
Texture *tex, std::span<const TextureRegionUpload> regions) {
|
||||
if (!tex)
|
||||
return eve::Result<void>::failure(eve::Diagnostic::error(
|
||||
eve::DiagnosticCode::InvalidArgument, "texture must be non-null",
|
||||
"graphics.updateTextureRegions.texture"));
|
||||
if (tex->mipmapCount != 1)
|
||||
return eve::Result<void>::failure(eve::Diagnostic::error(
|
||||
eve::DiagnosticCode::Unsupported, "partial updates require a single-mip texture",
|
||||
"graphics.updateTextureRegions.mipmaps"));
|
||||
|
||||
auto *gpu = static_cast<GpuTexture *>(tex->gpuHandle);
|
||||
const auto owned = std::find_if(ownedGpuTextures.begin(), ownedGpuTextures.end(),
|
||||
[gpu](const std::unique_ptr<GpuTexture> &candidate) {
|
||||
return candidate.get() == gpu;
|
||||
});
|
||||
if (!gpu || owned == ownedGpuTextures.end() || gpu->isCube)
|
||||
return eve::Result<void>::failure(eve::Diagnostic::error(
|
||||
eve::DiagnosticCode::InvalidArgument, "texture is not an owned 2D texture",
|
||||
"graphics.updateTextureRegions.texture"));
|
||||
if (regions.empty()) return eve::Result<void>::success();
|
||||
|
||||
std::vector<std::size_t> packedOffsets;
|
||||
packedOffsets.reserve(regions.size());
|
||||
std::size_t packedSize = 0;
|
||||
for (const TextureRegionUpload ®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<void>::failure(eve::Diagnostic::error(
|
||||
eve::DiagnosticCode::InvalidArgument, "invalid texture region",
|
||||
"graphics.updateTextureRegions.region"));
|
||||
const std::size_t packedRow = std::size_t(region.width) * 4U;
|
||||
const std::size_t stride = region.bytesPerRow == 0 ? packedRow : region.bytesPerRow;
|
||||
const std::size_t requiredBytes = stride * std::size_t(region.height - 1) + packedRow;
|
||||
if (stride < packedRow || region.rgba.size() < requiredBytes)
|
||||
return eve::Result<void>::failure(eve::Diagnostic::error(
|
||||
eve::DiagnosticCode::InvalidArgument,
|
||||
"source bytes do not cover the texture region",
|
||||
"graphics.updateTextureRegions.bytes"));
|
||||
packedOffsets.push_back(packedSize);
|
||||
packedSize += packedRow * std::size_t(region.height);
|
||||
}
|
||||
|
||||
std::vector<std::uint8_t> packed(packedSize);
|
||||
for (std::size_t index = 0; index < regions.size(); ++index) {
|
||||
const TextureRegionUpload ®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<vk::BufferImageCopy> copies;
|
||||
copies.reserve(regions.size());
|
||||
for (std::size_t index = 0; index < regions.size(); ++index) {
|
||||
const TextureRegionUpload &source = regions[index];
|
||||
vk::BufferImageCopy copy{};
|
||||
copy.bufferOffset = packedOffsets[index];
|
||||
copy.imageSubresource = {vk::ImageAspectFlagBits::eColor, 0, 0, 1};
|
||||
copy.imageOffset = vk::Offset3D{source.x, source.y, 0};
|
||||
copy.imageExtent = vk::Extent3D{std::uint32_t(source.width),
|
||||
std::uint32_t(source.height), 1};
|
||||
copies.push_back(copy);
|
||||
}
|
||||
cb.copyBufferToImage(staging.buffer, gpu->image.image(),
|
||||
vk::ImageLayout::eTransferDstOptimal, copies);
|
||||
|
||||
vk::ImageMemoryBarrier barrier{};
|
||||
barrier.srcAccessMask = vk::AccessFlagBits::eTransferWrite;
|
||||
barrier.dstAccessMask = vk::AccessFlagBits::eShaderRead;
|
||||
barrier.oldLayout = vk::ImageLayout::eTransferDstOptimal;
|
||||
barrier.newLayout = vk::ImageLayout::eShaderReadOnlyOptimal;
|
||||
barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
|
||||
barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED;
|
||||
barrier.image = gpu->image.image();
|
||||
barrier.subresourceRange = {vk::ImageAspectFlagBits::eColor, 0, 1, 0, 1};
|
||||
cb.pipelineBarrier(vk::PipelineStageFlagBits::eTransfer,
|
||||
vk::PipelineStageFlagBits::eVertexShader |
|
||||
vk::PipelineStageFlagBits::eFragmentShader |
|
||||
vk::PipelineStageFlagBits::eComputeShader,
|
||||
{}, 0, nullptr, 0, nullptr, 1, &barrier);
|
||||
gpu->image.setCurrentLayout(vk::ImageLayout::eShaderReadOnlyOptimal);
|
||||
});
|
||||
staging.release();
|
||||
return eve::Result<void>::success();
|
||||
}
|
||||
|
||||
bool Graphics::replaceTexturePixelsRGBA(Texture *tex, int w, int h, const uint8_t *rgba) {
|
||||
if (!tex || !rgba || w <= 0 || h <= 0 || !initialized) return false;
|
||||
|
||||
|
||||
@@ -2265,6 +2265,65 @@ bool Graphics::updateTexture(Texture *texture, int width, int height,
|
||||
return true;
|
||||
}
|
||||
|
||||
eve::Result<void> Graphics::updateTextureRegion(Texture *texture, int x, int y, int width,
|
||||
int height,
|
||||
std::span<const std::uint8_t> rgba,
|
||||
std::size_t bytesPerRow) {
|
||||
const TextureRegionUpload upload{x, y, width, height, rgba, bytesPerRow};
|
||||
return updateTextureRegions(texture, std::span<const TextureRegionUpload>(&upload, 1));
|
||||
}
|
||||
|
||||
eve::Result<void> Graphics::updateTextureRegions(
|
||||
Texture *texture, std::span<const TextureRegionUpload> regions) {
|
||||
auto *gpu = gpuForTexture(texture);
|
||||
if (!texture || !gpu || gpu->isCube)
|
||||
return eve::Result<void>::failure(eve::Diagnostic::error(
|
||||
eve::DiagnosticCode::InvalidArgument, "texture is not an owned 2D texture",
|
||||
"graphics.updateTextureRegions.texture"));
|
||||
if (texture->mipmapCount != 1)
|
||||
return eve::Result<void>::failure(eve::Diagnostic::error(
|
||||
eve::DiagnosticCode::Unsupported, "partial updates require a single-mip texture",
|
||||
"graphics.updateTextureRegions.mipmaps"));
|
||||
|
||||
for (const TextureRegionUpload ®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<void>::failure(eve::Diagnostic::error(
|
||||
eve::DiagnosticCode::InvalidArgument, "invalid texture region",
|
||||
"graphics.updateTextureRegions.region"));
|
||||
const std::size_t packedRow = std::size_t(region.width) * 4U;
|
||||
const std::size_t stride = region.bytesPerRow == 0 ? packedRow : region.bytesPerRow;
|
||||
const std::size_t requiredBytes = stride * std::size_t(region.height - 1) + packedRow;
|
||||
if (stride < packedRow || region.rgba.size() < requiredBytes)
|
||||
return eve::Result<void>::failure(eve::Diagnostic::error(
|
||||
eve::DiagnosticCode::InvalidArgument,
|
||||
"source bytes do not cover the texture region",
|
||||
"graphics.updateTextureRegions.bytes"));
|
||||
}
|
||||
|
||||
for (const TextureRegionUpload ®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<const wgpu::TexelCopyTextureInfo *>(&dst),
|
||||
region.rgba.data(), requiredBytes,
|
||||
reinterpret_cast<const wgpu::TexelCopyBufferLayout *>(&layout),
|
||||
reinterpret_cast<const wgpu::Extent3D *>(&extent));
|
||||
}
|
||||
return eve::Result<void>::success();
|
||||
}
|
||||
|
||||
GpuTexture *Graphics::gpuForTexture(Texture *t) const {
|
||||
return t ? static_cast<GpuTexture *>(t->gpuHandle) : nullptr;
|
||||
}
|
||||
|
||||
@@ -265,6 +265,11 @@ public:
|
||||
bool releaseTexture(Texture *texture) override;
|
||||
bool updateTexture(Texture *texture, int width, int height,
|
||||
const uint8_t *rgba) override;
|
||||
eve::Result<void> updateTextureRegion(Texture *texture, int x, int y, int width,
|
||||
int height, std::span<const std::uint8_t> rgba,
|
||||
std::size_t bytesPerRow = 0) override;
|
||||
eve::Result<void> updateTextureRegions(
|
||||
Texture *texture, std::span<const TextureRegionUpload> regions) override;
|
||||
|
||||
void drawTexturedRect(Texture *texture, float x, float y, float w, float h,
|
||||
const Color &color) override;
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
#include "pixelworld/PixelMaterial.h"
|
||||
|
||||
#include "common/Diagnostic.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <limits>
|
||||
#include <type_traits>
|
||||
#include <unordered_set>
|
||||
#include <utility>
|
||||
|
||||
namespace eve::pixelworld {
|
||||
namespace {
|
||||
|
||||
eve::Result<MaterialCatalog> invalidCatalog(std::string message, std::string path) {
|
||||
return eve::Result<MaterialCatalog>::failure(eve::Diagnostic::error(
|
||||
eve::DiagnosticCode::InvalidArgument, std::move(message), std::move(path), {}, "pixelworld.catalog"));
|
||||
}
|
||||
|
||||
void hashBytes(std::uint64_t& hash, std::string_view bytes) noexcept {
|
||||
for (const unsigned char byte : bytes) {
|
||||
hash ^= byte;
|
||||
hash *= 1099511628211ULL;
|
||||
}
|
||||
}
|
||||
|
||||
template <class T>
|
||||
void hashValue(std::uint64_t& hash, T value) noexcept {
|
||||
using Unsigned = std::make_unsigned_t<T>;
|
||||
const auto bits = static_cast<Unsigned>(value);
|
||||
for (std::size_t i = 0; i < sizeof(T); ++i) {
|
||||
hash ^= std::uint8_t(bits >> (i * 8U));
|
||||
hash *= 1099511628211ULL;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
eve::Result<MaterialCatalog> MaterialCatalog::create(std::vector<MaterialDefinition> definitions,
|
||||
std::vector<MaterialReactionRule> reactions,
|
||||
std::vector<MaterialPhaseRule> phaseRules) {
|
||||
if (definitions.empty()) return invalidCatalog("material catalog must contain air", "materials");
|
||||
if (definitions.size() > std::size_t(std::numeric_limits<std::uint16_t>::max()))
|
||||
return invalidCatalog("material catalog exceeds uint16 id capacity", "materials");
|
||||
|
||||
std::unordered_set<std::string> names;
|
||||
for (std::size_t index = 0; index < definitions.size(); ++index) {
|
||||
auto& definition = definitions[index];
|
||||
if (std::size_t(definition.id) != index)
|
||||
return invalidCatalog("material ids must be contiguous and match canonical order",
|
||||
"materials[" + std::to_string(index) + "].id");
|
||||
if (definition.name.empty() || !names.emplace(definition.name).second)
|
||||
return invalidCatalog("material names must be non-empty and unique",
|
||||
"materials[" + std::to_string(index) + "].name");
|
||||
if (definition.heatCapacity == 0)
|
||||
return invalidCatalog("material heat capacity must be positive",
|
||||
"materials[" + std::to_string(index) + "].heatCapacity");
|
||||
std::sort(definition.tags.begin(), definition.tags.end());
|
||||
if (std::any_of(definition.tags.begin(), definition.tags.end(), [](const std::string& tag) {
|
||||
return tag.empty();
|
||||
}) || std::adjacent_find(definition.tags.begin(), definition.tags.end()) !=
|
||||
definition.tags.end())
|
||||
return invalidCatalog("material tags must be non-empty and unique",
|
||||
"materials[" + std::to_string(index) + "].tags");
|
||||
}
|
||||
if (definitions[0].id != MaterialId::Air || definitions[0].state != MaterialState::Empty)
|
||||
return invalidCatalog("material zero must be empty air", "materials[0]");
|
||||
|
||||
std::unordered_set<std::string> ruleIds;
|
||||
const auto validId = [&definitions](MaterialId id) { return std::size_t(id) < definitions.size(); };
|
||||
for (std::size_t index = 0; index < reactions.size(); ++index) {
|
||||
const auto& rule = reactions[index];
|
||||
if (rule.id.empty() || !ruleIds.emplace(rule.id).second)
|
||||
return invalidCatalog("reaction ids must be non-empty and unique",
|
||||
"reactions[" + std::to_string(index) + "].id");
|
||||
if (!validId(rule.first) || !validId(rule.second) || !validId(rule.firstResult) ||
|
||||
!validId(rule.secondResult))
|
||||
return invalidCatalog("reaction references an unknown material",
|
||||
"reactions[" + std::to_string(index) + "]");
|
||||
}
|
||||
std::sort(reactions.begin(), reactions.end(), [](const auto& lhs, const auto& rhs) {
|
||||
return lhs.priority != rhs.priority ? lhs.priority > rhs.priority : lhs.id < rhs.id;
|
||||
});
|
||||
std::unordered_set<std::string> phaseIds;
|
||||
for (std::size_t index = 0; index < phaseRules.size(); ++index) {
|
||||
const auto& rule = phaseRules[index];
|
||||
if (rule.id.empty() || !phaseIds.emplace(rule.id).second)
|
||||
return invalidCatalog("phase rule ids must be non-empty and unique",
|
||||
"phaseRules[" + std::to_string(index) + "].id");
|
||||
if (!validId(rule.source) || !validId(rule.result))
|
||||
return invalidCatalog("phase rule references an unknown material",
|
||||
"phaseRules[" + std::to_string(index) + "]");
|
||||
}
|
||||
std::sort(phaseRules.begin(), phaseRules.end(), [](const auto& lhs, const auto& rhs) {
|
||||
return lhs.priority != rhs.priority ? lhs.priority > rhs.priority : lhs.id < rhs.id;
|
||||
});
|
||||
|
||||
MaterialCatalog catalog;
|
||||
catalog.definitions_ = std::move(definitions);
|
||||
catalog.reactions_ = std::move(reactions);
|
||||
catalog.phaseRules_ = std::move(phaseRules);
|
||||
catalog.reactionMask_.assign(catalog.definitions_.size(), 0);
|
||||
for (const auto& rule : catalog.reactions_) {
|
||||
catalog.reactionMask_[std::size_t(rule.first)] = 1;
|
||||
catalog.reactionMask_[std::size_t(rule.second)] = 1;
|
||||
}
|
||||
catalog.phaseMask_.assign(catalog.definitions_.size(), 0);
|
||||
for (const auto& rule : catalog.phaseRules_) catalog.phaseMask_[std::size_t(rule.source)] = 1;
|
||||
std::uint64_t hash = 1469598103934665603ULL;
|
||||
for (const auto& definition : catalog.definitions_) {
|
||||
hashValue(hash, std::uint16_t(definition.id));
|
||||
hashBytes(hash, definition.name);
|
||||
hashValue(hash, std::uint8_t(definition.state));
|
||||
hashValue(hash, definition.density);
|
||||
hashValue(hash, definition.viscosity);
|
||||
hashValue(hash, definition.thermalConductivity);
|
||||
hashValue(hash, definition.heatCapacity);
|
||||
hashValue(hash, definition.defaultTemperature);
|
||||
hashValue(hash, definition.ignitionTemperature);
|
||||
hashValue(hash, definition.meltingTemperature);
|
||||
hashValue(hash, definition.boilingTemperature);
|
||||
hashValue(hash, definition.defaultLifetime);
|
||||
hashValue(hash, std::uint8_t(definition.flammable));
|
||||
hashValue(hash, definition.blastResistance);
|
||||
hashValue(hash, definition.displayRgba);
|
||||
hashValue(hash, std::uint32_t(definition.tags.size()));
|
||||
for (const auto& tag : definition.tags) hashBytes(hash, tag);
|
||||
}
|
||||
for (const auto& rule : catalog.reactions_) {
|
||||
hashBytes(hash, rule.id);
|
||||
hashValue(hash, std::uint16_t(rule.first));
|
||||
hashValue(hash, std::uint16_t(rule.second));
|
||||
hashValue(hash, std::uint16_t(rule.firstResult));
|
||||
hashValue(hash, std::uint16_t(rule.secondResult));
|
||||
hashValue(hash, rule.minimumTemperature);
|
||||
hashValue(hash, rule.heatDelta);
|
||||
hashValue(hash, rule.priority);
|
||||
}
|
||||
for (const auto& rule : catalog.phaseRules_) {
|
||||
hashBytes(hash, rule.id);
|
||||
hashValue(hash, std::uint16_t(rule.source));
|
||||
hashValue(hash, std::uint16_t(rule.result));
|
||||
hashValue(hash, std::uint8_t(rule.direction));
|
||||
hashValue(hash, rule.threshold);
|
||||
hashValue(hash, rule.temperatureDelta);
|
||||
hashValue(hash, rule.priority);
|
||||
}
|
||||
catalog.fingerprint_ = hash;
|
||||
return eve::Result<MaterialCatalog>::success(std::move(catalog));
|
||||
}
|
||||
|
||||
MaterialCatalog MaterialCatalog::builtIn() {
|
||||
std::vector<MaterialDefinition> materials{
|
||||
{MaterialId::Air, "air", MaterialState::Empty, 0, 0, 8, 1},
|
||||
{MaterialId::Stone, "stone", MaterialState::Solid, 255, 255, 30, 180},
|
||||
{MaterialId::Sand, "sand", MaterialState::Powder, 180, 35, 18, 80},
|
||||
{MaterialId::Water, "water", MaterialState::Liquid, 100, 24, 45, 120, 32767, 0, 100},
|
||||
{MaterialId::Oil, "oil", MaterialState::Liquid, 80, 70, 12, 90, 180, 32767, 280, 0, true},
|
||||
{MaterialId::Fire, "fire", MaterialState::Energy, 1, 0, 80, 1, 32767, 32767, 32767, 30,
|
||||
false, 700},
|
||||
{MaterialId::Steam, "steam", MaterialState::Gas, 2, 5, 22, 30, 32767, 32767, 32767, 180,
|
||||
false, 110},
|
||||
{MaterialId::Wood, "wood", MaterialState::Solid, 220, 255, 8, 140, 240, 32767, 32767, 0, true},
|
||||
{MaterialId::Ice, "ice", MaterialState::Solid, 105, 255, 35, 110, 32767, 0, 32767, 0, false, -10},
|
||||
{MaterialId::Lava, "lava", MaterialState::Liquid, 210, 180, 55, 200, 32767, 900, 32767, 0, false, 1300},
|
||||
{MaterialId::Smoke, "smoke", MaterialState::Gas, 3, 8, 12, 20, 32767, 32767, 32767, 220},
|
||||
{MaterialId::Gunpowder, "gunpowder", MaterialState::Powder, 165, 40, 20, 70, 160, 32767, 32767, 0, true},
|
||||
{MaterialId::Acid, "acid", MaterialState::Liquid, 110, 45, 32, 100, 32767, 32767, 118},
|
||||
};
|
||||
std::vector<MaterialReactionRule> reactions{
|
||||
{"fire.water.boil", MaterialId::Fire, MaterialId::Water, MaterialId::Fire, MaterialId::Steam,
|
||||
-32768, 40, 200},
|
||||
{"fire.oil.ignite", MaterialId::Fire, MaterialId::Oil, MaterialId::Fire, MaterialId::Fire,
|
||||
-32768, 80, 150},
|
||||
{"fire.wood.ignite", MaterialId::Fire, MaterialId::Wood, MaterialId::Fire, MaterialId::Fire,
|
||||
-32768, 60, 140},
|
||||
{"fire.gunpowder.ignite", MaterialId::Fire, MaterialId::Gunpowder, MaterialId::Fire,
|
||||
MaterialId::Fire, -32768, 300, 180},
|
||||
{"acid.stone.dissolve", MaterialId::Acid, MaterialId::Stone, MaterialId::Water,
|
||||
MaterialId::Sand, -32768, 0, 120},
|
||||
{"lava.water.quench", MaterialId::Lava, MaterialId::Water, MaterialId::Stone,
|
||||
MaterialId::Steam, -32768, 80, 220},
|
||||
};
|
||||
materials[std::size_t(MaterialId::Air)].blastResistance = 0;
|
||||
materials[std::size_t(MaterialId::Stone)].blastResistance = 240;
|
||||
materials[std::size_t(MaterialId::Sand)].blastResistance = 8;
|
||||
materials[std::size_t(MaterialId::Water)].blastResistance = 0;
|
||||
materials[std::size_t(MaterialId::Oil)].blastResistance = 0;
|
||||
materials[std::size_t(MaterialId::Fire)].blastResistance = 0;
|
||||
materials[std::size_t(MaterialId::Steam)].blastResistance = 0;
|
||||
materials[std::size_t(MaterialId::Wood)].blastResistance = 45;
|
||||
materials[std::size_t(MaterialId::Ice)].blastResistance = 70;
|
||||
materials[std::size_t(MaterialId::Lava)].blastResistance = 0;
|
||||
materials[std::size_t(MaterialId::Smoke)].blastResistance = 0;
|
||||
materials[std::size_t(MaterialId::Gunpowder)].blastResistance = 5;
|
||||
materials[std::size_t(MaterialId::Acid)].blastResistance = 0;
|
||||
const auto decorate = [&materials](MaterialId id, std::uint32_t color,
|
||||
std::vector<std::string> tags) {
|
||||
auto& material = materials[std::size_t(id)];
|
||||
material.displayRgba = color;
|
||||
material.tags = std::move(tags);
|
||||
};
|
||||
decorate(MaterialId::Air, 0x00000000U, {"empty"});
|
||||
decorate(MaterialId::Stone, 0x4D5261FFU, {"terrain", "solid"});
|
||||
decorate(MaterialId::Sand, 0xE0B04DFFU, {"granular", "mobile"});
|
||||
decorate(MaterialId::Water, 0x1F61F2DBU, {"liquid", "mobile"});
|
||||
decorate(MaterialId::Oil, 0x423314FFU, {"flammable", "liquid", "mobile"});
|
||||
decorate(MaterialId::Fire, 0xFF3D08FFU, {"energy", "hot", "mobile"});
|
||||
decorate(MaterialId::Steam, 0xB8CCE08CU, {"gas", "mobile"});
|
||||
decorate(MaterialId::Wood, 0x6B3312FFU, {"flammable", "solid", "terrain"});
|
||||
decorate(MaterialId::Ice, 0xADDBFFF2U, {"cold", "solid", "terrain"});
|
||||
decorate(MaterialId::Lava, 0xFF2E03FFU, {"hot", "liquid", "mobile"});
|
||||
decorate(MaterialId::Smoke, 0x4D4A5294U, {"gas", "mobile"});
|
||||
decorate(MaterialId::Gunpowder, 0x3D3833FFU, {"explosive", "granular", "mobile"});
|
||||
decorate(MaterialId::Acid, 0x4DEB3DE0U, {"corrosive", "liquid", "mobile"});
|
||||
std::vector<MaterialPhaseRule> phaseRules{
|
||||
{"water.freeze", MaterialId::Water, MaterialId::Ice, TemperatureDirection::AtOrBelow, 0, 0, 100},
|
||||
{"water.boil", MaterialId::Water, MaterialId::Steam, TemperatureDirection::AtOrAbove, 100, -20, 100},
|
||||
{"ice.melt", MaterialId::Ice, MaterialId::Water, TemperatureDirection::AtOrAbove, 1, 0, 100},
|
||||
{"stone.melt", MaterialId::Stone, MaterialId::Lava, TemperatureDirection::AtOrAbove, 1200, -200, 100},
|
||||
{"lava.solidify", MaterialId::Lava, MaterialId::Stone, TemperatureDirection::AtOrBelow, 800, 100, 100},
|
||||
};
|
||||
return std::move(MaterialCatalog::create(std::move(materials), std::move(reactions), std::move(phaseRules)))
|
||||
.expect("built-in PixelWorld material catalog must be valid");
|
||||
}
|
||||
|
||||
eve::Result<MaterialId> MaterialCatalog::resolve(std::string_view name) const {
|
||||
for (const auto& definition : definitions_)
|
||||
if (definition.name == name) return eve::Result<MaterialId>::success(definition.id);
|
||||
return eve::Result<MaterialId>::failure(eve::Diagnostic::error(
|
||||
eve::DiagnosticCode::NotFound, "unknown pixel material: " + std::string(name), "material", {},
|
||||
"pixelworld.catalog"));
|
||||
}
|
||||
|
||||
const MaterialDefinition& MaterialCatalog::definition(MaterialId id) const noexcept {
|
||||
const std::size_t index = std::size_t(id);
|
||||
return definitions_[index < definitions_.size() ? index : 0];
|
||||
}
|
||||
|
||||
std::span<const MaterialDefinition> MaterialCatalog::definitions() const noexcept { return definitions_; }
|
||||
std::span<const MaterialReactionRule> MaterialCatalog::reactions() const noexcept { return reactions_; }
|
||||
std::span<const MaterialPhaseRule> MaterialCatalog::phaseRules() const noexcept { return phaseRules_; }
|
||||
bool MaterialCatalog::canReact(MaterialId id) const noexcept {
|
||||
const std::size_t index = std::size_t(id);
|
||||
return index < reactionMask_.size() && reactionMask_[index] != 0;
|
||||
}
|
||||
bool MaterialCatalog::canPhaseTransition(MaterialId id) const noexcept {
|
||||
const std::size_t index = std::size_t(id);
|
||||
return index < phaseMask_.size() && phaseMask_[index] != 0;
|
||||
}
|
||||
std::uint64_t MaterialCatalog::fingerprint() const noexcept { return fingerprint_; }
|
||||
|
||||
} // namespace eve::pixelworld
|
||||
@@ -0,0 +1,118 @@
|
||||
#pragma once
|
||||
|
||||
#include "common/Result.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <span>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace eve::pixelworld {
|
||||
|
||||
/** @brief Compact stable material identifier stored in each authoritative cell. */
|
||||
enum class MaterialId : std::uint16_t {
|
||||
Air = 0,
|
||||
Stone,
|
||||
Sand,
|
||||
Water,
|
||||
Oil,
|
||||
Fire,
|
||||
Steam,
|
||||
Wood,
|
||||
Ice,
|
||||
Lava,
|
||||
Smoke,
|
||||
Gunpowder,
|
||||
Acid,
|
||||
};
|
||||
|
||||
/** @brief Broad movement class for one pixel material. */
|
||||
enum class MaterialState : std::uint8_t { Empty, Solid, Powder, Liquid, Gas, Energy };
|
||||
|
||||
/** @brief Immutable validated properties for one catalog material. */
|
||||
struct MaterialDefinition {
|
||||
MaterialId id = MaterialId::Air;
|
||||
std::string name = "air";
|
||||
MaterialState state = MaterialState::Empty;
|
||||
std::uint16_t density = 0;
|
||||
std::uint8_t viscosity = 0;
|
||||
std::uint8_t thermalConductivity = 0;
|
||||
std::uint16_t heatCapacity = 1;
|
||||
std::int16_t ignitionTemperature = 32767;
|
||||
std::int16_t meltingTemperature = 32767;
|
||||
std::int16_t boilingTemperature = 32767;
|
||||
std::uint16_t defaultLifetime = 0;
|
||||
bool flammable = false;
|
||||
std::int16_t defaultTemperature = 20;
|
||||
std::uint16_t blastResistance = 100;
|
||||
/** @brief Canonical preview/render color encoded as 0xRRGGBBAA. */
|
||||
std::uint32_t displayRgba = 0xFF00FFFFU;
|
||||
/** @brief Canonical sorted semantic tags used by authoring and rule queries. */
|
||||
std::vector<std::string> tags;
|
||||
};
|
||||
|
||||
/** @brief Comparison direction for a deterministic temperature transition. */
|
||||
enum class TemperatureDirection : std::uint8_t { AtOrBelow, AtOrAbove };
|
||||
|
||||
/** @brief Data-driven phase transition evaluated after heat diffusion. */
|
||||
struct MaterialPhaseRule {
|
||||
std::string id;
|
||||
MaterialId source = MaterialId::Air;
|
||||
MaterialId result = MaterialId::Air;
|
||||
TemperatureDirection direction = TemperatureDirection::AtOrAbove;
|
||||
std::int16_t threshold = 0;
|
||||
std::int16_t temperatureDelta = 0;
|
||||
std::int32_t priority = 0;
|
||||
};
|
||||
|
||||
/** @brief Deterministically ordered binary material reaction. */
|
||||
struct MaterialReactionRule {
|
||||
std::string id;
|
||||
MaterialId first = MaterialId::Air;
|
||||
MaterialId second = MaterialId::Air;
|
||||
MaterialId firstResult = MaterialId::Air;
|
||||
MaterialId secondResult = MaterialId::Air;
|
||||
std::int16_t minimumTemperature = -32768;
|
||||
std::int16_t heatDelta = 0;
|
||||
std::int32_t priority = 0;
|
||||
};
|
||||
|
||||
/** @brief Owning immutable material and reaction table used by one PixelWorld. */
|
||||
class MaterialCatalog {
|
||||
public:
|
||||
/** @brief Validate and own a complete catalog candidate transactionally. */
|
||||
[[nodiscard]] static eve::Result<MaterialCatalog> create(
|
||||
std::vector<MaterialDefinition> definitions,
|
||||
std::vector<MaterialReactionRule> reactions = {},
|
||||
std::vector<MaterialPhaseRule> phaseRules = {});
|
||||
/** @brief Construct the engine's canonical built-in catalog. */
|
||||
static MaterialCatalog builtIn();
|
||||
|
||||
/** @brief Resolve a stable name or return a structured NotFound diagnostic. */
|
||||
[[nodiscard]] eve::Result<MaterialId> resolve(std::string_view name) const;
|
||||
/** @brief Resolve a validated id; invalid ids safely project to air. */
|
||||
const MaterialDefinition& definition(MaterialId id) const noexcept;
|
||||
/** @brief Borrow immutable definitions until this catalog is destroyed. */
|
||||
std::span<const MaterialDefinition> definitions() const noexcept;
|
||||
/** @brief Borrow immutable canonical-priority reaction rules. */
|
||||
std::span<const MaterialReactionRule> reactions() const noexcept;
|
||||
/** @brief Borrow immutable canonical-priority phase rules. */
|
||||
std::span<const MaterialPhaseRule> phaseRules() const noexcept;
|
||||
/** @brief Whether this material participates in at least one binary reaction. */
|
||||
bool canReact(MaterialId id) const noexcept;
|
||||
/** @brief Whether this material owns at least one phase transition rule. */
|
||||
bool canPhaseTransition(MaterialId id) const noexcept;
|
||||
/** @brief Stable deterministic fingerprint included in world snapshots. */
|
||||
std::uint64_t fingerprint() const noexcept;
|
||||
|
||||
private:
|
||||
std::vector<MaterialDefinition> definitions_;
|
||||
std::vector<MaterialReactionRule> reactions_;
|
||||
std::vector<MaterialPhaseRule> phaseRules_;
|
||||
std::vector<std::uint8_t> reactionMask_;
|
||||
std::vector<std::uint8_t> phaseMask_;
|
||||
std::uint64_t fingerprint_ = 0;
|
||||
};
|
||||
|
||||
} // namespace eve::pixelworld
|
||||
@@ -0,0 +1,296 @@
|
||||
#include "pixelworld/PixelMaterialCatalogCodec.h"
|
||||
|
||||
#include <Poco/Dynamic/Var.h>
|
||||
#include <Poco/JSON/Array.h>
|
||||
#include <Poco/JSON/Object.h>
|
||||
#include <Poco/JSON/Parser.h>
|
||||
#include <Poco/JSON/Stringifier.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <limits>
|
||||
#include <sstream>
|
||||
#include <stdexcept>
|
||||
#include <type_traits>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace eve::pixelworld {
|
||||
namespace {
|
||||
|
||||
using Object = Poco::JSON::Object;
|
||||
using Array = Poco::JSON::Array;
|
||||
|
||||
eve::Result<MaterialCatalog> malformed(std::string message, std::string path = "document") {
|
||||
return eve::Result<MaterialCatalog>::failure(eve::Diagnostic::error(
|
||||
eve::DiagnosticCode::SerializationError, std::move(message), std::move(path), {},
|
||||
"pixelworld.catalog-codec"));
|
||||
}
|
||||
|
||||
bool exactFields(const Object& object, std::initializer_list<std::string_view> allowed) {
|
||||
std::unordered_set<std::string> names;
|
||||
for (const auto name : allowed) names.emplace(name);
|
||||
if (object.size() != names.size()) return false;
|
||||
for (const auto& name : object.getNames())
|
||||
if (!names.contains(name)) return false;
|
||||
for (const auto& name : names)
|
||||
if (!object.has(name)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string stateName(MaterialState state) {
|
||||
switch (state) {
|
||||
case MaterialState::Empty: return "empty";
|
||||
case MaterialState::Solid: return "solid";
|
||||
case MaterialState::Powder: return "powder";
|
||||
case MaterialState::Liquid: return "liquid";
|
||||
case MaterialState::Gas: return "gas";
|
||||
case MaterialState::Energy: return "energy";
|
||||
}
|
||||
return "empty";
|
||||
}
|
||||
|
||||
bool parseState(const std::string& name, MaterialState& state) {
|
||||
if (name == "empty") state = MaterialState::Empty;
|
||||
else if (name == "solid") state = MaterialState::Solid;
|
||||
else if (name == "powder") state = MaterialState::Powder;
|
||||
else if (name == "liquid") state = MaterialState::Liquid;
|
||||
else if (name == "gas") state = MaterialState::Gas;
|
||||
else if (name == "energy") state = MaterialState::Energy;
|
||||
else return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
template <class T>
|
||||
bool readNumber(const Object& object, const std::string& key, T& result) {
|
||||
static_assert(std::is_integral_v<T> && !std::is_same_v<T, bool>);
|
||||
if (!object.has(key) || object.isNull(key)) return false;
|
||||
try {
|
||||
const auto wide = object.get(key).convert<std::int64_t>();
|
||||
if constexpr (std::is_unsigned_v<T>) {
|
||||
if (wide < 0 || std::uint64_t(wide) > std::numeric_limits<T>::max()) return false;
|
||||
} else if (wide < std::numeric_limits<T>::min() ||
|
||||
wide > std::numeric_limits<T>::max()) {
|
||||
return false;
|
||||
}
|
||||
result = static_cast<T>(wide);
|
||||
return true;
|
||||
} catch (...) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Object::Ptr materialJson(const MaterialDefinition& material) {
|
||||
Object::Ptr out(new Object);
|
||||
out->set("id", std::uint16_t(material.id));
|
||||
out->set("name", material.name);
|
||||
out->set("state", stateName(material.state));
|
||||
out->set("density", material.density);
|
||||
out->set("viscosity", material.viscosity);
|
||||
out->set("thermalConductivity", material.thermalConductivity);
|
||||
out->set("heatCapacity", material.heatCapacity);
|
||||
out->set("ignitionTemperature", material.ignitionTemperature);
|
||||
out->set("meltingTemperature", material.meltingTemperature);
|
||||
out->set("boilingTemperature", material.boilingTemperature);
|
||||
out->set("defaultLifetime", material.defaultLifetime);
|
||||
out->set("flammable", material.flammable);
|
||||
out->set("defaultTemperature", material.defaultTemperature);
|
||||
out->set("blastResistance", material.blastResistance);
|
||||
out->set("displayRgba", material.displayRgba);
|
||||
Array::Ptr tags(new Array);
|
||||
for (const auto& tag : material.tags) tags->add(tag);
|
||||
out->set("tags", tags);
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
eve::Result<std::string> encodeMaterialCatalogJson(const MaterialCatalog& catalog) {
|
||||
Object::Ptr root(new Object);
|
||||
root->set("schema", "eve.pixelworld.material-catalog");
|
||||
root->set("version", 1);
|
||||
Array::Ptr materials(new Array);
|
||||
for (const auto& material : catalog.definitions()) materials->add(materialJson(material));
|
||||
root->set("materials", materials);
|
||||
Array::Ptr reactions(new Array);
|
||||
for (const auto& reaction : catalog.reactions()) {
|
||||
Object::Ptr value(new Object);
|
||||
value->set("id", reaction.id);
|
||||
value->set("first", std::uint16_t(reaction.first));
|
||||
value->set("second", std::uint16_t(reaction.second));
|
||||
value->set("firstResult", std::uint16_t(reaction.firstResult));
|
||||
value->set("secondResult", std::uint16_t(reaction.secondResult));
|
||||
value->set("minimumTemperature", reaction.minimumTemperature);
|
||||
value->set("heatDelta", reaction.heatDelta);
|
||||
value->set("priority", reaction.priority);
|
||||
reactions->add(value);
|
||||
}
|
||||
root->set("reactions", reactions);
|
||||
Array::Ptr phases(new Array);
|
||||
for (const auto& phase : catalog.phaseRules()) {
|
||||
Object::Ptr value(new Object);
|
||||
value->set("id", phase.id);
|
||||
value->set("source", std::uint16_t(phase.source));
|
||||
value->set("result", std::uint16_t(phase.result));
|
||||
value->set("direction", phase.direction == TemperatureDirection::AtOrBelow
|
||||
? "at_or_below"
|
||||
: "at_or_above");
|
||||
value->set("threshold", phase.threshold);
|
||||
value->set("temperatureDelta", phase.temperatureDelta);
|
||||
value->set("priority", phase.priority);
|
||||
phases->add(value);
|
||||
}
|
||||
root->set("phaseRules", phases);
|
||||
std::ostringstream text;
|
||||
Poco::JSON::Stringifier::stringify(Poco::Dynamic::Var(root), text, 2, -1);
|
||||
return eve::Result<std::string>::success(text.str());
|
||||
}
|
||||
|
||||
eve::Result<MaterialCatalog> decodeMaterialCatalogJson(std::string_view json) {
|
||||
Object::Ptr root;
|
||||
try {
|
||||
root = Poco::JSON::Parser().parse(std::string(json)).extract<Object::Ptr>();
|
||||
} catch (...) {
|
||||
return malformed("material Catalog JSON is malformed");
|
||||
}
|
||||
if (!root || !exactFields(*root, {"schema", "version", "materials", "reactions", "phaseRules"}))
|
||||
return malformed("root contains missing or unknown fields");
|
||||
if (root->optValue<std::string>("schema", "") != "eve.pixelworld.material-catalog")
|
||||
return malformed("unknown material Catalog schema", "schema");
|
||||
int version = 0;
|
||||
if (!readNumber(*root, "version", version) || version != 1)
|
||||
return malformed("unknown material Catalog version", "version");
|
||||
Array::Ptr materials;
|
||||
Array::Ptr reactions;
|
||||
Array::Ptr phases;
|
||||
try {
|
||||
materials = root->getArray("materials");
|
||||
reactions = root->getArray("reactions");
|
||||
phases = root->getArray("phaseRules");
|
||||
} catch (...) {
|
||||
return malformed("material Catalog arrays are invalid");
|
||||
}
|
||||
if (!materials || !reactions || !phases || materials->size() > 65535U ||
|
||||
reactions->size() > 1'000'000U || phases->size() > 1'000'000U)
|
||||
return malformed("material Catalog arrays are missing or exceed their budgets");
|
||||
|
||||
std::vector<MaterialDefinition> definitions;
|
||||
definitions.reserve(materials->size());
|
||||
for (std::size_t index = 0; index < materials->size(); ++index) {
|
||||
Object::Ptr value;
|
||||
try {
|
||||
value = materials->getObject(unsigned(index));
|
||||
} catch (...) {
|
||||
return malformed("material entry is not an object",
|
||||
"materials[" + std::to_string(index) + "]");
|
||||
}
|
||||
if (!value || !exactFields(*value, {"id", "name", "state", "density", "viscosity",
|
||||
"thermalConductivity", "heatCapacity", "ignitionTemperature",
|
||||
"meltingTemperature", "boilingTemperature", "defaultLifetime",
|
||||
"flammable", "defaultTemperature", "blastResistance",
|
||||
"displayRgba", "tags"}))
|
||||
return malformed("material contains missing or unknown fields",
|
||||
"materials[" + std::to_string(index) + "]");
|
||||
MaterialDefinition material;
|
||||
std::uint16_t id = 0;
|
||||
if (!readNumber(*value, "id", id) || !readNumber(*value, "density", material.density) ||
|
||||
!readNumber(*value, "viscosity", material.viscosity) ||
|
||||
!readNumber(*value, "thermalConductivity", material.thermalConductivity) ||
|
||||
!readNumber(*value, "heatCapacity", material.heatCapacity) ||
|
||||
!readNumber(*value, "ignitionTemperature", material.ignitionTemperature) ||
|
||||
!readNumber(*value, "meltingTemperature", material.meltingTemperature) ||
|
||||
!readNumber(*value, "boilingTemperature", material.boilingTemperature) ||
|
||||
!readNumber(*value, "defaultLifetime", material.defaultLifetime) ||
|
||||
!readNumber(*value, "defaultTemperature", material.defaultTemperature) ||
|
||||
!readNumber(*value, "blastResistance", material.blastResistance) ||
|
||||
!readNumber(*value, "displayRgba", material.displayRgba))
|
||||
return malformed("material contains an invalid number",
|
||||
"materials[" + std::to_string(index) + "]");
|
||||
material.id = MaterialId(id);
|
||||
material.name = value->optValue<std::string>("name", "");
|
||||
if (!parseState(value->optValue<std::string>("state", ""), material.state))
|
||||
return malformed("material state is unknown",
|
||||
"materials[" + std::to_string(index) + "].state");
|
||||
if (!value->has("flammable") || !value->has("tags"))
|
||||
return malformed("material fields are missing",
|
||||
"materials[" + std::to_string(index) + "]");
|
||||
try {
|
||||
material.flammable = value->getValue<bool>("flammable");
|
||||
Array::Ptr tags = value->getArray("tags");
|
||||
if (!tags || tags->size() > 1024U) throw std::runtime_error("tags");
|
||||
for (std::size_t tag = 0; tag < tags->size(); ++tag)
|
||||
material.tags.push_back(tags->getElement<std::string>(unsigned(tag)));
|
||||
} catch (...) {
|
||||
return malformed("material tags or flammable value are invalid",
|
||||
"materials[" + std::to_string(index) + "]");
|
||||
}
|
||||
definitions.push_back(std::move(material));
|
||||
}
|
||||
|
||||
std::vector<MaterialReactionRule> reactionRules;
|
||||
reactionRules.reserve(reactions->size());
|
||||
for (std::size_t index = 0; index < reactions->size(); ++index) {
|
||||
Object::Ptr value;
|
||||
try {
|
||||
value = reactions->getObject(unsigned(index));
|
||||
} catch (...) {
|
||||
return malformed("reaction entry is not an object",
|
||||
"reactions[" + std::to_string(index) + "]");
|
||||
}
|
||||
if (!value || !exactFields(*value, {"id", "first", "second", "firstResult", "secondResult",
|
||||
"minimumTemperature", "heatDelta", "priority"}))
|
||||
return malformed("reaction contains missing or unknown fields",
|
||||
"reactions[" + std::to_string(index) + "]");
|
||||
MaterialReactionRule rule;
|
||||
std::uint16_t first = 0, second = 0, firstResult = 0, secondResult = 0;
|
||||
rule.id = value->optValue<std::string>("id", "");
|
||||
if (!readNumber(*value, "first", first) || !readNumber(*value, "second", second) ||
|
||||
!readNumber(*value, "firstResult", firstResult) ||
|
||||
!readNumber(*value, "secondResult", secondResult) ||
|
||||
!readNumber(*value, "minimumTemperature", rule.minimumTemperature) ||
|
||||
!readNumber(*value, "heatDelta", rule.heatDelta) ||
|
||||
!readNumber(*value, "priority", rule.priority))
|
||||
return malformed("reaction contains an invalid number",
|
||||
"reactions[" + std::to_string(index) + "]");
|
||||
rule.first = MaterialId(first);
|
||||
rule.second = MaterialId(second);
|
||||
rule.firstResult = MaterialId(firstResult);
|
||||
rule.secondResult = MaterialId(secondResult);
|
||||
reactionRules.push_back(std::move(rule));
|
||||
}
|
||||
|
||||
std::vector<MaterialPhaseRule> phaseRules;
|
||||
phaseRules.reserve(phases->size());
|
||||
for (std::size_t index = 0; index < phases->size(); ++index) {
|
||||
Object::Ptr value;
|
||||
try {
|
||||
value = phases->getObject(unsigned(index));
|
||||
} catch (...) {
|
||||
return malformed("phase rule entry is not an object",
|
||||
"phaseRules[" + std::to_string(index) + "]");
|
||||
}
|
||||
if (!value || !exactFields(*value, {"id", "source", "result", "direction", "threshold",
|
||||
"temperatureDelta", "priority"}))
|
||||
return malformed("phase rule contains missing or unknown fields",
|
||||
"phaseRules[" + std::to_string(index) + "]");
|
||||
MaterialPhaseRule rule;
|
||||
std::uint16_t source = 0, result = 0;
|
||||
rule.id = value->optValue<std::string>("id", "");
|
||||
const std::string direction = value->optValue<std::string>("direction", "");
|
||||
if (direction == "at_or_below") rule.direction = TemperatureDirection::AtOrBelow;
|
||||
else if (direction == "at_or_above") rule.direction = TemperatureDirection::AtOrAbove;
|
||||
else return malformed("phase direction is unknown",
|
||||
"phaseRules[" + std::to_string(index) + "].direction");
|
||||
if (!readNumber(*value, "source", source) || !readNumber(*value, "result", result) ||
|
||||
!readNumber(*value, "threshold", rule.threshold) ||
|
||||
!readNumber(*value, "temperatureDelta", rule.temperatureDelta) ||
|
||||
!readNumber(*value, "priority", rule.priority))
|
||||
return malformed("phase rule contains an invalid number",
|
||||
"phaseRules[" + std::to_string(index) + "]");
|
||||
rule.source = MaterialId(source);
|
||||
rule.result = MaterialId(result);
|
||||
phaseRules.push_back(std::move(rule));
|
||||
}
|
||||
return MaterialCatalog::create(std::move(definitions), std::move(reactionRules),
|
||||
std::move(phaseRules));
|
||||
}
|
||||
|
||||
} // namespace eve::pixelworld
|
||||
@@ -0,0 +1,21 @@
|
||||
#pragma once
|
||||
|
||||
#include "common/Result.h"
|
||||
#include "pixelworld/PixelMaterial.h"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace eve::pixelworld {
|
||||
|
||||
/** @brief Encode schema `eve.pixelworld.material-catalog` version 1 canonically. */
|
||||
[[nodiscard]] eve::Result<std::string> encodeMaterialCatalogJson(const MaterialCatalog& catalog);
|
||||
|
||||
/**
|
||||
* @brief Decode and validate schema `eve.pixelworld.material-catalog` version 1.
|
||||
* @remarks Unknown fields, versions, invalid references and malformed values are rejected
|
||||
* before a MaterialCatalog is returned; no live world is mutated by this operation.
|
||||
*/
|
||||
[[nodiscard]] eve::Result<MaterialCatalog> decodeMaterialCatalogJson(std::string_view json);
|
||||
|
||||
} // namespace eve::pixelworld
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,361 @@
|
||||
#pragma once
|
||||
|
||||
#include "common/Result.h"
|
||||
#include "common/Time.h"
|
||||
#include "pixelworld/PixelMaterial.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <span>
|
||||
#include <vector>
|
||||
|
||||
namespace eve::pixelworld {
|
||||
|
||||
class PixelWorldControlService;
|
||||
|
||||
inline constexpr int kPixelChunkSize = 64;
|
||||
inline constexpr std::uint8_t kPixelSleepHysteresisTicks = 3;
|
||||
|
||||
/** @brief Compact authoritative state for one simulated world pixel. */
|
||||
struct PixelCell {
|
||||
MaterialId material = MaterialId::Air;
|
||||
std::int16_t temperature = 20;
|
||||
std::uint8_t lifetime = 0;
|
||||
/** @brief Canonical sub-degree thermal energy in [0, material heatCapacity). */
|
||||
std::uint16_t thermalRemainder = 0;
|
||||
|
||||
friend bool operator==(const PixelCell&, const PixelCell&) = default;
|
||||
};
|
||||
|
||||
/** @brief Counters produced by one deterministic simulation step. */
|
||||
struct StepStats {
|
||||
eve::SimulationTick tick = eve::SimulationTick::zero();
|
||||
std::uint32_t chunksVisited = 0;
|
||||
std::uint32_t cellsVisited = 0;
|
||||
std::uint32_t cellsChanged = 0;
|
||||
std::uint32_t cellsMoved = 0;
|
||||
std::uint32_t temperatureTransfers = 0;
|
||||
std::uint64_t thermalEnergyTransferred = 0;
|
||||
std::uint64_t thermalEnergyClamped = 0;
|
||||
std::uint32_t phaseChanges = 0;
|
||||
std::uint32_t reactions = 0;
|
||||
std::uint32_t parallelTasks = 0;
|
||||
std::uint32_t chunksReclaimed = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Synchronous executor for owning PixelWorld candidate buffers.
|
||||
*
|
||||
* Implementations may schedule indices on worker threads but must join before
|
||||
* returning. PixelWorld controls the callback, which reads immutable phase state
|
||||
* and writes only its index-owned buffer; implementations must not retain it.
|
||||
*/
|
||||
class PixelWorkScheduler {
|
||||
public:
|
||||
virtual ~PixelWorkScheduler() = default;
|
||||
/** @brief Execute every index in [0, workItems) exactly once and synchronously join. */
|
||||
virtual void parallelFor(std::size_t workItems,
|
||||
const std::function<void(std::size_t)>& body) = 0;
|
||||
/** @brief Observable worker capacity used for diagnostics only. */
|
||||
[[nodiscard]] virtual std::size_t workerCount() const noexcept = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Owning, immutable projection of one authoritative simulation chunk.
|
||||
*
|
||||
* The snapshot remains valid independently of subsequent world mutation. Cells
|
||||
* are row-major and contain `kPixelChunkSize * kPixelChunkSize` entries unless
|
||||
* `removed` is true, in which case cells is empty and consumers must erase projection.
|
||||
*/
|
||||
struct PixelChunkSnapshot {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
std::uint64_t revision = 0;
|
||||
bool removed = false;
|
||||
std::vector<PixelCell> cells;
|
||||
|
||||
friend bool operator==(const PixelChunkSnapshot&, const PixelChunkSnapshot&) = default;
|
||||
};
|
||||
|
||||
/** @brief Owning authoritative Chunk correction with source world metadata. */
|
||||
struct PixelChunkBatch {
|
||||
std::uint64_t catalogFingerprint = 0;
|
||||
std::uint64_t sourceSeed = 0;
|
||||
std::uint64_t sourceRevision = 0;
|
||||
eve::SimulationTick sourceTick = eve::SimulationTick::zero();
|
||||
std::uint64_t sourceLastEditSequence = 0;
|
||||
/** @brief Replace the replica projection instead of applying an incremental correction. */
|
||||
bool fullResync = false;
|
||||
std::vector<PixelChunkSnapshot> chunks;
|
||||
|
||||
friend bool operator==(const PixelChunkBatch&, const PixelChunkBatch&) = default;
|
||||
};
|
||||
|
||||
/** @brief Inclusive finite rectangle expressed in Chunk coordinates. */
|
||||
struct PixelChunkRegion {
|
||||
int minX = 0;
|
||||
int minY = 0;
|
||||
int maxX = -1;
|
||||
int maxY = -1;
|
||||
|
||||
friend bool operator==(const PixelChunkRegion&, const PixelChunkRegion&) = default;
|
||||
};
|
||||
|
||||
/** @brief Read-only per-Chunk simulation diagnostics for tooling and overlays. */
|
||||
struct PixelChunkDiagnostic {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
std::uint64_t revision = 0;
|
||||
std::uint32_t nonAirCells = 0;
|
||||
std::uint32_t mobileCells = 0;
|
||||
std::int16_t minimumTemperature = 20;
|
||||
std::int16_t maximumTemperature = 20;
|
||||
std::uint8_t idleTicks = 0;
|
||||
bool active = false;
|
||||
};
|
||||
|
||||
/** @brief Receipt for one all-or-nothing authoritative Chunk batch application. */
|
||||
struct PixelChunkApplyReceipt {
|
||||
std::uint64_t revisionBefore = 0;
|
||||
std::uint64_t revisionAfter = 0;
|
||||
std::uint32_t chunksReplaced = 0;
|
||||
std::uint32_t chunksRemoved = 0;
|
||||
std::uint64_t worldEpoch = 0;
|
||||
};
|
||||
|
||||
/** @brief Receipt for one transactional compatible material Catalog hot reload. */
|
||||
struct PixelCatalogReloadReceipt {
|
||||
std::uint64_t fingerprintBefore = 0;
|
||||
std::uint64_t fingerprintAfter = 0;
|
||||
std::uint64_t revisionBefore = 0;
|
||||
std::uint64_t revisionAfter = 0;
|
||||
std::uint64_t worldEpoch = 0;
|
||||
std::uint32_t chunksRebuilt = 0;
|
||||
bool replayHistoryInvalidated = false;
|
||||
};
|
||||
|
||||
/** @brief Canonical deterministic edit operation admitted at a Tick boundary. */
|
||||
enum class PixelEditKind : std::uint8_t { PaintCircle, HeatCircle, Explosion };
|
||||
|
||||
/**
|
||||
* @brief Owning replayable edit command. Sequence must be exactly previous + 1.
|
||||
* @remarks `material` is used by PaintCircle; `strength` by Explosion; and
|
||||
* `temperatureDelta` by HeatCircle/Explosion.
|
||||
*/
|
||||
struct PixelEditCommand {
|
||||
std::uint64_t sequence = 0;
|
||||
PixelEditKind kind = PixelEditKind::PaintCircle;
|
||||
int centerX = 0;
|
||||
int centerY = 0;
|
||||
int radius = 0;
|
||||
MaterialId material = MaterialId::Air;
|
||||
int strength = 0;
|
||||
std::int16_t temperatureDelta = 0;
|
||||
};
|
||||
|
||||
/** @brief Deterministic receipt for one atomically accepted edit command. */
|
||||
struct PixelEditReceipt {
|
||||
std::uint64_t sequence = 0;
|
||||
std::uint64_t revisionBefore = 0;
|
||||
std::uint64_t revisionAfter = 0;
|
||||
std::uint32_t cellsChanged = 0;
|
||||
std::uint32_t cellsRemoved = 0;
|
||||
std::uint32_t cellsHeated = 0;
|
||||
};
|
||||
|
||||
/** @brief Finite inclusive world-space rectangle used by structural queries. */
|
||||
struct PixelRegion {
|
||||
int minX = 0;
|
||||
int minY = 0;
|
||||
int maxX = -1;
|
||||
int maxY = -1;
|
||||
};
|
||||
|
||||
/** @brief Runtime identity of the world epoch from which a fragment was detached. */
|
||||
struct PixelWorldLink {
|
||||
std::uint64_t world = 0;
|
||||
std::uint64_t epoch = 0;
|
||||
friend bool operator==(const PixelWorldLink&, const PixelWorldLink&) = default;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Owning material bitmap detached atomically from a PixelWorld.
|
||||
* @remarks `cells` is row-major in the tight `width` by `height` bounds. Air
|
||||
* preserves holes. The artifact remains readable after its source world dies;
|
||||
* its link is accepted for rasterization only by the same live world epoch.
|
||||
*/
|
||||
struct PixelFragment {
|
||||
PixelWorldLink source;
|
||||
std::uint64_t id = 0;
|
||||
int originX = 0;
|
||||
int originY = 0;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
std::uint32_t solidCellCount = 0;
|
||||
std::vector<PixelCell> cells;
|
||||
};
|
||||
|
||||
/** @brief Receipt for one all-or-nothing fragment rasterization. */
|
||||
struct PixelFragmentRasterReceipt {
|
||||
std::uint64_t fragmentId = 0;
|
||||
std::uint64_t revisionBefore = 0;
|
||||
std::uint64_t revisionAfter = 0;
|
||||
std::uint32_t cellsPlaced = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Sparse, chunked, deterministic 2D falling-material world.
|
||||
*
|
||||
* PixelWorld is the sole authority for material state. It is simulation-thread
|
||||
* affine and invokes no callbacks. Rendering and physics consume snapshots or
|
||||
* queries through adapters; neither owns a mutable copy of the world.
|
||||
*/
|
||||
class PixelWorld {
|
||||
public:
|
||||
/** @brief Construct an empty world with an explicit deterministic seed. */
|
||||
explicit PixelWorld(std::uint64_t seed = 1);
|
||||
/** @brief Construct an empty world owning a validated immutable material catalog. */
|
||||
PixelWorld(std::uint64_t seed, MaterialCatalog catalog);
|
||||
~PixelWorld();
|
||||
PixelWorld(PixelWorld&&) noexcept;
|
||||
PixelWorld& operator=(PixelWorld&&) noexcept;
|
||||
PixelWorld(const PixelWorld&) = delete;
|
||||
PixelWorld& operator=(const PixelWorld&) = delete;
|
||||
|
||||
/** @brief Return the cell at world coordinates; absent chunks read as air. */
|
||||
PixelCell getCell(int x, int y) const noexcept;
|
||||
/** @brief Convenience query returning the numeric material id. */
|
||||
int getMaterial(int x, int y) const noexcept;
|
||||
/** @brief Query whether a validated material id participates in structural solid collision. */
|
||||
bool isSolidMaterial(MaterialId material) const noexcept;
|
||||
/** @brief Query the Catalog-owned preview/render color encoded as 0xRRGGBBAA. */
|
||||
std::uint32_t materialDisplayRgba(MaterialId material) const noexcept;
|
||||
/**
|
||||
* @brief Transactionally hot-reload a compatible Catalog while paused.
|
||||
* @remarks Material count, ids and names must remain stable so existing cells keep their
|
||||
* meaning. Success rebuilds Chunk metadata, advances epoch, and invalidates prior replay
|
||||
* history because rule semantics changed. Failure leaves all observable state unchanged.
|
||||
*/
|
||||
[[nodiscard]] eve::Result<PixelCatalogReloadReceipt> reloadMaterialCatalog(
|
||||
MaterialCatalog catalog, std::uint64_t expectedFingerprint);
|
||||
/** @brief Set one cell and wake its chunk and neighbors. Air never allocates a missing chunk. */
|
||||
void setCell(int x, int y, PixelCell cell);
|
||||
/** @brief Set one cell from a stable built-in material name. */
|
||||
void setMaterial(int x, int y, std::string_view material);
|
||||
/** @brief Canonical checked name-based setter; unknown names leave the world unchanged. */
|
||||
[[nodiscard]] eve::Result<void> setMaterialChecked(int x, int y, std::string_view material);
|
||||
/** @brief Paint a filled material circle and return the number of changed cells. */
|
||||
std::size_t paintCircle(int centerX, int centerY, int radius, std::string_view material);
|
||||
/** @brief Canonical checked circle edit; unknown names leave the world unchanged. */
|
||||
[[nodiscard]] eve::Result<std::size_t> paintCircleChecked(int centerX, int centerY, int radius,
|
||||
std::string_view material);
|
||||
/**
|
||||
* @brief Validate and atomically apply one strictly sequenced edit command.
|
||||
* @remarks Simulation-thread affine; invokes no callbacks. Rejection leaves
|
||||
* material cells, revision and last edit sequence unchanged.
|
||||
*/
|
||||
[[nodiscard]] eve::Result<PixelEditReceipt> applyEdit(const PixelEditCommand& command);
|
||||
/** @brief Script/demo explosion facade that emits the next command sequence. */
|
||||
PixelEditReceipt explode(int centerX, int centerY, int radius, int strength,
|
||||
std::int16_t temperatureDelta);
|
||||
/**
|
||||
* @brief Atomically detach unsupported solid components inside a finite region.
|
||||
* @param region Inclusive scan bounds; area is limited to prevent accidental unbounded work.
|
||||
* @param supportY Components touching this y coordinate or below remain authoritative terrain.
|
||||
* @param minimumCells Components smaller than this remain terrain.
|
||||
* @return Owning fragments in canonical top-left order. A component touching solid terrain
|
||||
* outside `region` is treated as supported. Failure leaves the world unchanged.
|
||||
*/
|
||||
[[nodiscard]] eve::Result<std::vector<PixelFragment>> extractUnsupportedFragments(
|
||||
PixelRegion region, int supportY, std::uint32_t minimumCells = 1);
|
||||
/**
|
||||
* @brief Transactionally rasterize a detached fragment at a new world-space origin.
|
||||
* @remarks Rejects stale/wrong-world links and any overlap with non-air cells. The fragment
|
||||
* is borrowed for the call and is never retained or mutated.
|
||||
*/
|
||||
[[nodiscard]] eve::Result<PixelFragmentRasterReceipt> rasterizeFragment(
|
||||
const PixelFragment& fragment, int originX, int originY);
|
||||
/** @brief Current runtime link used for stale fragment detection. */
|
||||
PixelWorldLink worldLink() const noexcept;
|
||||
/** @brief Remove all chunks and reset the simulation tick. */
|
||||
void clear() noexcept;
|
||||
|
||||
/**
|
||||
* @brief Advance exactly one scheduler-owned tick.
|
||||
* @return Deterministic counters for the completed step.
|
||||
*/
|
||||
[[nodiscard]] eve::Result<StepStats> advance(eve::SimulationTick tick);
|
||||
/**
|
||||
* @brief Advance one deterministic tick using a synchronous candidate scheduler.
|
||||
* @remarks Simulation-thread affine. Worker callbacks receive owning output slots,
|
||||
* never mutate chunks, and are joined before canonical commit.
|
||||
*/
|
||||
[[nodiscard]] eve::Result<StepStats> advanceScheduled(eve::SimulationTick tick,
|
||||
PixelWorkScheduler& scheduler);
|
||||
/** @brief Script/demo facade that advances the next integral tick. */
|
||||
StepStats step();
|
||||
|
||||
/** @brief Pause or resume ordinary simulation entry points without changing state. */
|
||||
void setPaused(bool paused) noexcept;
|
||||
/** @brief Whether ordinary simulation entry points are currently paused. */
|
||||
bool isPaused() const noexcept;
|
||||
|
||||
std::uint64_t seed() const noexcept;
|
||||
std::uint64_t revision() const noexcept;
|
||||
std::uint64_t tickValue() const noexcept;
|
||||
std::uint64_t lastEditSequence() const noexcept;
|
||||
int chunkCount() const noexcept;
|
||||
int activeChunkCount() const noexcept;
|
||||
/** @brief Fingerprint of the immutable catalog that gives cell ids their meaning. */
|
||||
std::uint64_t materialCatalogFingerprint() const noexcept;
|
||||
|
||||
/**
|
||||
* @brief Copy chunks changed after `sinceRevision` in canonical y/x order.
|
||||
* @remarks Simulation-thread affine. Returned snapshots own their cells and
|
||||
* remain valid after mutation, restore, or destruction of this world.
|
||||
*/
|
||||
std::vector<PixelChunkSnapshot> snapshotChangedChunks(std::uint64_t sinceRevision) const;
|
||||
|
||||
/**
|
||||
* @brief Copy present Chunks and retained tombstones in one finite Chunk-coordinate region.
|
||||
* @param region Inclusive bounds limited to 65536 Chunk coordinates.
|
||||
* @param sinceRevision Only projections newer than this revision are returned.
|
||||
* @return Canonical y/x-ordered owning projections, or bounded-input failure.
|
||||
*/
|
||||
[[nodiscard]] eve::Result<std::vector<PixelChunkSnapshot>> snapshotChunksInRegion(
|
||||
PixelChunkRegion region, std::uint64_t sinceRevision = 0) const;
|
||||
|
||||
/** @brief Query allocated Chunk diagnostics in a bounded inclusive region. */
|
||||
[[nodiscard]] eve::Result<std::vector<PixelChunkDiagnostic>> chunkDiagnostics(
|
||||
PixelChunkRegion region) const;
|
||||
|
||||
/**
|
||||
* @brief Transactionally apply a canonical authoritative Chunk correction batch.
|
||||
* @param batch Owning source metadata and unique y/x-ordered Chunk snapshots.
|
||||
* @param expectedRevision Current local revision used to reject stale writers.
|
||||
* @return Commit receipt; any validation failure leaves all world state unchanged.
|
||||
* @remarks The source revision, Tick and edit sequence may advance but never rewind.
|
||||
* A successful correction invalidates outstanding fragment links by advancing epoch.
|
||||
*/
|
||||
[[nodiscard]] eve::Result<PixelChunkApplyReceipt> applyChunkBatch(
|
||||
const PixelChunkBatch& batch, std::uint64_t expectedRevision);
|
||||
|
||||
/** @brief Serialize schema `eve.pixelworld`, version 3, in canonical Chunk order. */
|
||||
[[nodiscard]] eve::Result<std::vector<std::byte>> saveSnapshot() const;
|
||||
/**
|
||||
* @brief Transactionally restore a version 1, 2 or 3 PixelWorld snapshot.
|
||||
* @remarks Older built-in-Catalog versions migrate explicitly. Unknown versions and malformed
|
||||
* input are rejected without changing this world; restore advances epoch on success.
|
||||
*/
|
||||
[[nodiscard]] eve::Result<void> restoreSnapshot(std::span<const std::byte> bytes);
|
||||
|
||||
private:
|
||||
friend class PixelWorldControlService;
|
||||
eve::Result<StepStats> advanceImpl(eve::SimulationTick tick, PixelWorkScheduler* scheduler);
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
|
||||
} // namespace eve::pixelworld
|
||||
@@ -0,0 +1,274 @@
|
||||
#include "common/Capability.h"
|
||||
#include "common/PixelWorldAutomation.h"
|
||||
#include "pixelworld/PixelWorldControl.h"
|
||||
#include "pixelworld/PixelMaterialCatalogCodec.h"
|
||||
|
||||
#include <Poco/Dynamic/Var.h>
|
||||
#include <Poco/JSON/Array.h>
|
||||
#include <Poco/JSON/Object.h>
|
||||
#include <Poco/JSON/Parser.h>
|
||||
#include <Poco/JSON/Stringifier.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <iomanip>
|
||||
#include <sstream>
|
||||
|
||||
namespace eve::pixelworld {
|
||||
namespace {
|
||||
|
||||
using Object = Poco::JSON::Object;
|
||||
|
||||
std::string stringify(Object::Ptr object) {
|
||||
std::ostringstream out;
|
||||
Poco::JSON::Stringifier::stringify(Poco::Dynamic::Var(object), out);
|
||||
return out.str();
|
||||
}
|
||||
|
||||
std::string stringify(const Poco::Dynamic::Var& value) {
|
||||
std::ostringstream out;
|
||||
Poco::JSON::Stringifier::stringify(value, out);
|
||||
return out.str();
|
||||
}
|
||||
|
||||
Object::Ptr response(bool ok) {
|
||||
Object::Ptr out(new Object);
|
||||
out->set("ok", ok);
|
||||
return out;
|
||||
}
|
||||
|
||||
template <class T>
|
||||
std::string failure(const eve::Result<T>& result) {
|
||||
Object::Ptr out = response(false);
|
||||
out->set("status", std::string(eve::statusCodeName(result.status().code())));
|
||||
if (result.status().hasDiagnostics())
|
||||
out->set("error", result.status().diagnostics().front().message());
|
||||
return stringify(out);
|
||||
}
|
||||
|
||||
Object::Ptr parse(const std::string& json) {
|
||||
try {
|
||||
return Poco::JSON::Parser().parse(json).extract<Object::Ptr>();
|
||||
} catch (...) {
|
||||
return Object::Ptr(new Object);
|
||||
}
|
||||
}
|
||||
|
||||
std::string hex(std::span<const std::byte> bytes) {
|
||||
std::ostringstream out;
|
||||
out << std::hex << std::setfill('0');
|
||||
for (const std::byte value : bytes) out << std::setw(2) << std::to_integer<unsigned>(value);
|
||||
return out.str();
|
||||
}
|
||||
|
||||
eve::Result<std::vector<std::byte>> unhex(const std::string& value) {
|
||||
if ((value.size() & 1U) != 0)
|
||||
return eve::Result<std::vector<std::byte>>::failure(eve::Diagnostic::error(
|
||||
eve::DiagnosticCode::InvalidArgument, "snapshot hex length must be even", "snapshot", {},
|
||||
"pixelworld.automation"));
|
||||
std::vector<std::byte> bytes;
|
||||
bytes.reserve(value.size() / 2);
|
||||
for (std::size_t index = 0; index < value.size(); index += 2) {
|
||||
unsigned parsed = 0;
|
||||
std::istringstream in(value.substr(index, 2));
|
||||
in >> std::hex >> parsed;
|
||||
if (!in || !in.eof())
|
||||
return eve::Result<std::vector<std::byte>>::failure(eve::Diagnostic::error(
|
||||
eve::DiagnosticCode::InvalidArgument, "snapshot contains non-hex data", "snapshot", {},
|
||||
"pixelworld.automation"));
|
||||
bytes.push_back(std::byte(parsed));
|
||||
}
|
||||
return eve::Result<std::vector<std::byte>>::success(std::move(bytes));
|
||||
}
|
||||
|
||||
class PixelWorldAutomation final : public eve::IPixelWorldAutomation {
|
||||
public:
|
||||
std::string invoke(const std::string& operation, const std::string& requestJson) override {
|
||||
auto args = parse(requestJson);
|
||||
auto& control = pixelWorldControlService();
|
||||
if (operation == "worlds") {
|
||||
Poco::JSON::Array::Ptr items(new Poco::JSON::Array);
|
||||
for (const auto& world : control.worlds()) {
|
||||
Object::Ptr item(new Object);
|
||||
item->set("world", world.link.world);
|
||||
item->set("epoch", world.link.epoch);
|
||||
item->set("seed", world.seed);
|
||||
item->set("revision", world.revision);
|
||||
item->set("tick", world.tick);
|
||||
item->set("lastEditSequence", world.lastEditSequence);
|
||||
item->set("chunks", world.chunks);
|
||||
item->set("activeChunks", world.activeChunks);
|
||||
item->set("paused", world.paused);
|
||||
items->add(item);
|
||||
}
|
||||
auto out = response(true);
|
||||
out->set("worlds", items);
|
||||
return stringify(out);
|
||||
}
|
||||
if (operation == "catalog_builtin") {
|
||||
const MaterialCatalog catalog = MaterialCatalog::builtIn();
|
||||
auto encoded = encodeMaterialCatalogJson(catalog);
|
||||
if (!encoded.ok()) return failure(encoded);
|
||||
auto out = response(true);
|
||||
out->set("fingerprint", std::to_string(catalog.fingerprint()));
|
||||
out->set("catalog", encoded.value());
|
||||
return stringify(out);
|
||||
}
|
||||
if (operation == "catalog_validate") {
|
||||
std::string document;
|
||||
if (args->has("catalog")) {
|
||||
const auto value = args->get("catalog");
|
||||
document = value.isString() ? value.convert<std::string>() : stringify(value);
|
||||
}
|
||||
auto decoded = decodeMaterialCatalogJson(document);
|
||||
if (!decoded.ok()) return failure(decoded);
|
||||
auto canonical = encodeMaterialCatalogJson(decoded.value());
|
||||
if (!canonical.ok()) return failure(canonical);
|
||||
auto out = response(true);
|
||||
out->set("fingerprint", std::to_string(decoded.value().fingerprint()));
|
||||
out->set("materialCount", decoded.value().definitions().size());
|
||||
out->set("reactionCount", decoded.value().reactions().size());
|
||||
out->set("phaseRuleCount", decoded.value().phaseRules().size());
|
||||
out->set("canonicalCatalog", canonical.value());
|
||||
return stringify(out);
|
||||
}
|
||||
if (operation == "catalog_apply") {
|
||||
std::string document;
|
||||
if (args->has("catalog")) {
|
||||
const auto value = args->get("catalog");
|
||||
document = value.isString() ? value.convert<std::string>() : stringify(value);
|
||||
}
|
||||
std::uint64_t expected = 0;
|
||||
try {
|
||||
expected = std::stoull(args->optValue<std::string>("expectedFingerprint", "0"));
|
||||
} catch (...) {
|
||||
auto out = response(false);
|
||||
out->set("error", "expectedFingerprint must be an unsigned decimal string");
|
||||
return stringify(out);
|
||||
}
|
||||
auto result = control.reloadMaterialCatalog(
|
||||
args->optValue<std::uint64_t>("world", 0), document, expected);
|
||||
if (!result.ok()) return failure(result);
|
||||
auto out = response(true);
|
||||
out->set("fingerprintBefore", std::to_string(result.value().fingerprintBefore));
|
||||
out->set("fingerprintAfter", std::to_string(result.value().fingerprintAfter));
|
||||
out->set("revisionAfter", result.value().revisionAfter);
|
||||
out->set("worldEpoch", result.value().worldEpoch);
|
||||
out->set("chunksRebuilt", result.value().chunksRebuilt);
|
||||
out->set("replayHistoryInvalidated", result.value().replayHistoryInvalidated);
|
||||
return stringify(out);
|
||||
}
|
||||
const auto worldId = args->optValue<std::uint64_t>("world", 0);
|
||||
if (operation == "pause") {
|
||||
auto result = control.setPaused(worldId, args->optValue<bool>("paused", true));
|
||||
if (!result.ok()) return failure(result);
|
||||
auto out = response(true);
|
||||
out->set("paused", control.world(worldId).value().paused);
|
||||
return stringify(out);
|
||||
}
|
||||
if (operation == "step") {
|
||||
auto result = control.step(worldId, args->optValue<std::uint32_t>("count", 1));
|
||||
if (!result.ok()) return failure(result);
|
||||
const auto& receipt = result.value();
|
||||
auto out = response(true);
|
||||
out->set("steps", receipt.steps);
|
||||
out->set("firstTick", receipt.firstTick);
|
||||
out->set("lastTick", receipt.lastTick);
|
||||
out->set("elapsedMicroseconds", receipt.elapsedMicroseconds);
|
||||
out->set("cellsChanged", receipt.finalStep.cellsChanged);
|
||||
out->set("cellsMoved", receipt.finalStep.cellsMoved);
|
||||
return stringify(out);
|
||||
}
|
||||
if (operation == "edit") {
|
||||
PixelEditCommand command;
|
||||
command.sequence = args->optValue<std::uint64_t>("sequence", 0);
|
||||
const std::string kind = args->optValue<std::string>("kind", "paint");
|
||||
command.kind = kind == "heat" ? PixelEditKind::HeatCircle
|
||||
: kind == "explosion" ? PixelEditKind::Explosion
|
||||
: PixelEditKind::PaintCircle;
|
||||
command.centerX = args->optValue<int>("x", 0);
|
||||
command.centerY = args->optValue<int>("y", 0);
|
||||
command.radius = args->optValue<int>("radius", 0);
|
||||
command.material = MaterialId(args->optValue<unsigned>("material", 0));
|
||||
command.strength = args->optValue<int>("strength", 0);
|
||||
command.temperatureDelta = std::int16_t(std::clamp(
|
||||
args->optValue<int>("temperatureDelta", 0), -32768, 32767));
|
||||
auto result = control.applyEdit(worldId, command);
|
||||
if (!result.ok()) return failure(result);
|
||||
auto out = response(true);
|
||||
out->set("revisionBefore", result.value().revisionBefore);
|
||||
out->set("revisionAfter", result.value().revisionAfter);
|
||||
out->set("cellsChanged", result.value().cellsChanged);
|
||||
return stringify(out);
|
||||
}
|
||||
if (operation == "diagnostics") {
|
||||
PixelChunkRegion region{args->optValue<int>("minX", 0), args->optValue<int>("minY", 0),
|
||||
args->optValue<int>("maxX", 0), args->optValue<int>("maxY", 0)};
|
||||
auto result = control.chunkDiagnostics(worldId, region);
|
||||
if (!result.ok()) return failure(result);
|
||||
Poco::JSON::Array::Ptr chunks(new Poco::JSON::Array);
|
||||
for (const auto& diagnostic : result.value()) {
|
||||
Object::Ptr item(new Object);
|
||||
item->set("x", diagnostic.x);
|
||||
item->set("y", diagnostic.y);
|
||||
item->set("revision", diagnostic.revision);
|
||||
item->set("nonAirCells", diagnostic.nonAirCells);
|
||||
item->set("mobileCells", diagnostic.mobileCells);
|
||||
item->set("minimumTemperature", diagnostic.minimumTemperature);
|
||||
item->set("maximumTemperature", diagnostic.maximumTemperature);
|
||||
item->set("idleTicks", diagnostic.idleTicks);
|
||||
item->set("active", diagnostic.active);
|
||||
chunks->add(item);
|
||||
}
|
||||
auto out = response(true);
|
||||
out->set("chunks", chunks);
|
||||
return stringify(out);
|
||||
}
|
||||
if (operation == "samples") {
|
||||
auto result = control.performanceSamples(
|
||||
worldId, args->optValue<std::uint32_t>("limit", 120));
|
||||
if (!result.ok()) return failure(result);
|
||||
Poco::JSON::Array::Ptr samples(new Poco::JSON::Array);
|
||||
for (const auto& sample : result.value()) {
|
||||
Object::Ptr item(new Object);
|
||||
item->set("tick", sample.tick);
|
||||
item->set("elapsedMicroseconds", sample.elapsedMicroseconds);
|
||||
item->set("chunksVisited", sample.stats.chunksVisited);
|
||||
item->set("cellsVisited", sample.stats.cellsVisited);
|
||||
item->set("cellsChanged", sample.stats.cellsChanged);
|
||||
item->set("cellsMoved", sample.stats.cellsMoved);
|
||||
item->set("parallelTasks", sample.stats.parallelTasks);
|
||||
samples->add(item);
|
||||
}
|
||||
auto out = response(true);
|
||||
out->set("samples", samples);
|
||||
return stringify(out);
|
||||
}
|
||||
if (operation == "snapshot_capture") {
|
||||
auto result = control.captureSnapshot(worldId);
|
||||
if (!result.ok()) return failure(result);
|
||||
auto out = response(true);
|
||||
out->set("encoding", "hex");
|
||||
out->set("snapshot", hex(result.value()));
|
||||
return stringify(out);
|
||||
}
|
||||
if (operation == "snapshot_restore") {
|
||||
auto decoded = unhex(args->optValue<std::string>("snapshot", ""));
|
||||
if (!decoded.ok()) return failure(decoded);
|
||||
auto result = control.restoreSnapshot(worldId, decoded.value());
|
||||
if (!result.ok()) return failure(result);
|
||||
return stringify(response(true));
|
||||
}
|
||||
auto out = response(false);
|
||||
out->set("error", "unknown PixelWorld automation operation");
|
||||
return stringify(out);
|
||||
}
|
||||
};
|
||||
|
||||
PixelWorldAutomation automation;
|
||||
|
||||
} // namespace
|
||||
|
||||
void registerPixelWorldAutomation() { eve::cap::provide<eve::IPixelWorldAutomation>(&automation); }
|
||||
void unregisterPixelWorldAutomation() { eve::cap::revoke<eve::IPixelWorldAutomation>(&automation); }
|
||||
|
||||
} // namespace eve::pixelworld
|
||||
@@ -0,0 +1,176 @@
|
||||
#include "pixelworld/PixelWorldControl.h"
|
||||
|
||||
#include "pixelworld/PixelMaterialCatalogCodec.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <deque>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace eve::pixelworld {
|
||||
namespace {
|
||||
|
||||
template <class T>
|
||||
eve::Result<T> missingWorld() {
|
||||
return eve::Result<T>::failure(eve::Diagnostic::error(
|
||||
eve::DiagnosticCode::NotFound, "PixelWorld is not live", "world", {},
|
||||
"pixelworld.control"));
|
||||
}
|
||||
|
||||
PixelWorldSummary summarize(const PixelWorld& world) {
|
||||
return {world.worldLink(), world.seed(), world.revision(), world.tickValue(),
|
||||
world.lastEditSequence(), std::uint32_t(world.chunkCount()),
|
||||
std::uint32_t(world.activeChunkCount()), world.isPaused()};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
struct PixelWorldControlService::Impl {
|
||||
std::unordered_map<std::uint64_t, PixelWorld*> worlds;
|
||||
std::unordered_map<std::uint64_t, std::deque<PixelWorldPerformanceSample>> samples;
|
||||
};
|
||||
|
||||
PixelWorldControlService::Impl& PixelWorldControlService::impl() const {
|
||||
static auto* state = new Impl;
|
||||
return *state;
|
||||
}
|
||||
|
||||
PixelWorldControlService& pixelWorldControlService() {
|
||||
static auto* service = new PixelWorldControlService;
|
||||
return *service;
|
||||
}
|
||||
|
||||
void PixelWorldControlService::registerWorld(PixelWorld& world) {
|
||||
impl().worlds.insert_or_assign(world.worldLink().world, &world);
|
||||
}
|
||||
|
||||
void PixelWorldControlService::unregisterWorld(const PixelWorld& world) noexcept {
|
||||
const auto found = impl().worlds.find(world.worldLink().world);
|
||||
if (found != impl().worlds.end() && found->second == &world) {
|
||||
impl().worlds.erase(found);
|
||||
impl().samples.erase(world.worldLink().world);
|
||||
}
|
||||
}
|
||||
|
||||
void PixelWorldControlService::rebindWorld(const PixelWorld* previous,
|
||||
PixelWorld& replacement) noexcept {
|
||||
auto& worlds = impl().worlds;
|
||||
const auto found = worlds.find(replacement.worldLink().world);
|
||||
if (found != worlds.end() && found->second == previous) found->second = &replacement;
|
||||
}
|
||||
|
||||
PixelWorld* PixelWorldControlService::find(std::uint64_t worldId) const noexcept {
|
||||
const auto found = impl().worlds.find(worldId);
|
||||
return found == impl().worlds.end() ? nullptr : found->second;
|
||||
}
|
||||
|
||||
void PixelWorldControlService::recordStep(std::uint64_t worldId, const StepStats& stats,
|
||||
std::uint64_t elapsedMicroseconds) {
|
||||
auto& samples = impl().samples[worldId];
|
||||
samples.push_back({stats.tick.value(), elapsedMicroseconds, stats});
|
||||
if (samples.size() > 256) samples.pop_front();
|
||||
}
|
||||
|
||||
std::vector<PixelWorldSummary> PixelWorldControlService::worlds() const {
|
||||
std::vector<PixelWorldSummary> result;
|
||||
result.reserve(impl().worlds.size());
|
||||
for (const auto& [id, world] : impl().worlds) result.push_back(summarize(*world));
|
||||
std::sort(result.begin(), result.end(), [](const auto& a, const auto& b) {
|
||||
return a.link.world < b.link.world;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
eve::Result<PixelWorldSummary> PixelWorldControlService::world(std::uint64_t worldId) const {
|
||||
const PixelWorld* value = find(worldId);
|
||||
return value ? eve::Result<PixelWorldSummary>::success(summarize(*value))
|
||||
: missingWorld<PixelWorldSummary>();
|
||||
}
|
||||
|
||||
eve::Result<void> PixelWorldControlService::setPaused(std::uint64_t worldId, bool paused) {
|
||||
PixelWorld* value = find(worldId);
|
||||
if (!value) return missingWorld<void>();
|
||||
value->setPaused(paused);
|
||||
return eve::Result<void>::success();
|
||||
}
|
||||
|
||||
eve::Result<PixelWorldStepReceipt> PixelWorldControlService::step(std::uint64_t worldId,
|
||||
std::uint32_t count) {
|
||||
PixelWorld* value = find(worldId);
|
||||
if (!value) return missingWorld<PixelWorldStepReceipt>();
|
||||
if (count == 0 || count > 1024)
|
||||
return eve::Result<PixelWorldStepReceipt>::failure(eve::Diagnostic::error(
|
||||
eve::DiagnosticCode::InvalidArgument, "step count must be in [1, 1024]", "count", {},
|
||||
"pixelworld.control"));
|
||||
PixelWorldStepReceipt receipt;
|
||||
receipt.world = worldId;
|
||||
receipt.steps = count;
|
||||
receipt.firstTick = value->tickValue() + 1;
|
||||
const auto start = std::chrono::steady_clock::now();
|
||||
for (std::uint32_t index = 0; index < count; ++index) {
|
||||
auto advanced = value->advanceImpl(eve::SimulationTick(value->tickValue() + 1), nullptr);
|
||||
if (!advanced.ok()) return eve::Result<PixelWorldStepReceipt>::failure(advanced.status());
|
||||
receipt.finalStep = advanced.value();
|
||||
}
|
||||
receipt.lastTick = value->tickValue();
|
||||
receipt.elapsedMicroseconds = std::uint64_t(std::chrono::duration_cast<std::chrono::microseconds>(
|
||||
std::chrono::steady_clock::now() - start)
|
||||
.count());
|
||||
return eve::Result<PixelWorldStepReceipt>::success(receipt);
|
||||
}
|
||||
|
||||
eve::Result<PixelEditReceipt> PixelWorldControlService::applyEdit(
|
||||
std::uint64_t worldId, const PixelEditCommand& command) {
|
||||
PixelWorld* value = find(worldId);
|
||||
return value ? value->applyEdit(command) : missingWorld<PixelEditReceipt>();
|
||||
}
|
||||
|
||||
eve::Result<PixelCatalogReloadReceipt> PixelWorldControlService::reloadMaterialCatalog(
|
||||
std::uint64_t worldId, std::string_view catalogJson,
|
||||
std::uint64_t expectedFingerprint) {
|
||||
PixelWorld* value = find(worldId);
|
||||
if (!value) return missingWorld<PixelCatalogReloadReceipt>();
|
||||
auto catalog = decodeMaterialCatalogJson(catalogJson);
|
||||
if (!catalog.ok())
|
||||
return eve::Result<PixelCatalogReloadReceipt>::failure(catalog.status());
|
||||
return value->reloadMaterialCatalog(std::move(catalog).value(), expectedFingerprint);
|
||||
}
|
||||
|
||||
eve::Result<std::vector<PixelChunkDiagnostic>> PixelWorldControlService::chunkDiagnostics(
|
||||
std::uint64_t worldId, PixelChunkRegion region) const {
|
||||
const PixelWorld* value = find(worldId);
|
||||
return value ? value->chunkDiagnostics(region)
|
||||
: missingWorld<std::vector<PixelChunkDiagnostic>>();
|
||||
}
|
||||
|
||||
eve::Result<std::vector<PixelWorldPerformanceSample>>
|
||||
PixelWorldControlService::performanceSamples(std::uint64_t worldId, std::uint32_t limit) const {
|
||||
if (!find(worldId)) return missingWorld<std::vector<PixelWorldPerformanceSample>>();
|
||||
if (limit == 0 || limit > 256)
|
||||
return eve::Result<std::vector<PixelWorldPerformanceSample>>::failure(eve::Diagnostic::error(
|
||||
eve::DiagnosticCode::InvalidArgument, "sample limit must be in [1, 256]", "limit", {},
|
||||
"pixelworld.control"));
|
||||
const auto found = impl().samples.find(worldId);
|
||||
if (found == impl().samples.end())
|
||||
return eve::Result<std::vector<PixelWorldPerformanceSample>>::success({});
|
||||
const auto& samples = found->second;
|
||||
const std::size_t begin = samples.size() > limit ? samples.size() - limit : 0;
|
||||
std::vector<PixelWorldPerformanceSample> result(samples.begin() + std::ptrdiff_t(begin),
|
||||
samples.end());
|
||||
return eve::Result<std::vector<PixelWorldPerformanceSample>>::success(
|
||||
std::move(result));
|
||||
}
|
||||
|
||||
eve::Result<std::vector<std::byte>> PixelWorldControlService::captureSnapshot(
|
||||
std::uint64_t worldId) const {
|
||||
const PixelWorld* value = find(worldId);
|
||||
return value ? value->saveSnapshot() : missingWorld<std::vector<std::byte>>();
|
||||
}
|
||||
|
||||
eve::Result<void> PixelWorldControlService::restoreSnapshot(
|
||||
std::uint64_t worldId, std::span<const std::byte> bytes) {
|
||||
PixelWorld* value = find(worldId);
|
||||
return value ? value->restoreSnapshot(bytes) : missingWorld<void>();
|
||||
}
|
||||
|
||||
} // namespace eve::pixelworld
|
||||
@@ -0,0 +1,91 @@
|
||||
#pragma once
|
||||
|
||||
#include "pixelworld/PixelWorld.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <span>
|
||||
#include <vector>
|
||||
|
||||
namespace eve::pixelworld {
|
||||
|
||||
/** @brief Stable summary of one live PixelWorld registered for local tooling. */
|
||||
struct PixelWorldSummary {
|
||||
PixelWorldLink link;
|
||||
std::uint64_t seed = 0;
|
||||
std::uint64_t revision = 0;
|
||||
std::uint64_t tick = 0;
|
||||
std::uint64_t lastEditSequence = 0;
|
||||
std::uint32_t chunks = 0;
|
||||
std::uint32_t activeChunks = 0;
|
||||
bool paused = false;
|
||||
};
|
||||
|
||||
/** @brief Receipt for an explicit bounded tooling step. */
|
||||
struct PixelWorldStepReceipt {
|
||||
std::uint64_t world = 0;
|
||||
std::uint32_t steps = 0;
|
||||
std::uint64_t firstTick = 0;
|
||||
std::uint64_t lastTick = 0;
|
||||
std::uint64_t elapsedMicroseconds = 0;
|
||||
StepStats finalStep;
|
||||
};
|
||||
|
||||
/** @brief One bounded process-local simulation performance sample. */
|
||||
struct PixelWorldPerformanceSample {
|
||||
std::uint64_t tick = 0;
|
||||
std::uint64_t elapsedMicroseconds = 0;
|
||||
StepStats stats;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Main-thread-affine control surface shared by editor, CLI and MCP adapters.
|
||||
* @remarks The service borrows live worlds only while they are registered by their
|
||||
* constructors. PixelWorld destruction and move rebinding prevent stale retention.
|
||||
*/
|
||||
class PixelWorldControlService {
|
||||
public:
|
||||
/** @brief Return canonical world-id-ordered summaries of all live worlds. */
|
||||
[[nodiscard]] std::vector<PixelWorldSummary> worlds() const;
|
||||
/** @brief Query one live world summary. */
|
||||
[[nodiscard]] eve::Result<PixelWorldSummary> world(std::uint64_t worldId) const;
|
||||
/** @brief Pause or resume one live world. */
|
||||
[[nodiscard]] eve::Result<void> setPaused(std::uint64_t worldId, bool paused);
|
||||
/** @brief Advance a live world explicitly even while paused; count is limited to 1024. */
|
||||
[[nodiscard]] eve::Result<PixelWorldStepReceipt> step(std::uint64_t worldId,
|
||||
std::uint32_t count = 1);
|
||||
/** @brief Apply the world's canonical strictly sequenced edit operation. */
|
||||
[[nodiscard]] eve::Result<PixelEditReceipt> applyEdit(std::uint64_t worldId,
|
||||
const PixelEditCommand& command);
|
||||
/** @brief Validate JSON and transactionally hot-reload a compatible paused world Catalog. */
|
||||
[[nodiscard]] eve::Result<PixelCatalogReloadReceipt> reloadMaterialCatalog(
|
||||
std::uint64_t worldId, std::string_view catalogJson,
|
||||
std::uint64_t expectedFingerprint);
|
||||
/** @brief Copy a bounded read-only Chunk diagnostic region. */
|
||||
[[nodiscard]] eve::Result<std::vector<PixelChunkDiagnostic>> chunkDiagnostics(
|
||||
std::uint64_t worldId, PixelChunkRegion region) const;
|
||||
/** @brief Copy up to the latest 256 simulation performance samples. */
|
||||
[[nodiscard]] eve::Result<std::vector<PixelWorldPerformanceSample>> performanceSamples(
|
||||
std::uint64_t worldId, std::uint32_t limit = 120) const;
|
||||
/** @brief Capture an owning canonical world snapshot. */
|
||||
[[nodiscard]] eve::Result<std::vector<std::byte>> captureSnapshot(std::uint64_t worldId) const;
|
||||
/** @brief Transactionally restore a captured world snapshot. */
|
||||
[[nodiscard]] eve::Result<void> restoreSnapshot(std::uint64_t worldId,
|
||||
std::span<const std::byte> bytes);
|
||||
|
||||
private:
|
||||
friend class PixelWorld;
|
||||
void registerWorld(PixelWorld& world);
|
||||
void unregisterWorld(const PixelWorld& world) noexcept;
|
||||
void rebindWorld(const PixelWorld* previous, PixelWorld& replacement) noexcept;
|
||||
void recordStep(std::uint64_t worldId, const StepStats& stats,
|
||||
std::uint64_t elapsedMicroseconds);
|
||||
[[nodiscard]] PixelWorld* find(std::uint64_t worldId) const noexcept;
|
||||
|
||||
struct Impl;
|
||||
Impl& impl() const;
|
||||
};
|
||||
|
||||
/** @brief Process-lifetime PixelWorld tooling registry. */
|
||||
PixelWorldControlService& pixelWorldControlService();
|
||||
|
||||
} // namespace eve::pixelworld
|
||||
@@ -0,0 +1,201 @@
|
||||
#include "pixelworld/PixelWorldGeneration.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <limits>
|
||||
#include <string>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
|
||||
namespace eve::pixelworld {
|
||||
namespace {
|
||||
|
||||
std::uint64_t mix(std::uint64_t value) noexcept {
|
||||
value += 0x9E3779B97F4A7C15ULL;
|
||||
value = (value ^ (value >> 30U)) * 0xBF58476D1CE4E5B9ULL;
|
||||
value = (value ^ (value >> 27U)) * 0x94D049BB133111EBULL;
|
||||
return value ^ (value >> 31U);
|
||||
}
|
||||
|
||||
std::uint64_t coordinateHash(std::uint64_t seed, int x, int y, std::uint64_t stream) noexcept {
|
||||
return mix(seed ^ (std::uint64_t(std::uint32_t(x)) << 32U) ^
|
||||
std::uint64_t(std::uint32_t(y)) ^ stream);
|
||||
}
|
||||
|
||||
int floorDivGeneration(int value, int divisor) noexcept {
|
||||
return value >= 0 ? value / divisor : -((-value + divisor - 1) / divisor);
|
||||
}
|
||||
|
||||
int interpolatedNoise(std::uint64_t seed, int coordinate, int spacing,
|
||||
std::uint64_t stream) noexcept {
|
||||
const int lattice = floorDivGeneration(coordinate, spacing);
|
||||
const int offset = coordinate - lattice * spacing;
|
||||
const int first = int(coordinateHash(seed, lattice, 0, stream) % 20001ULL) - 10000;
|
||||
const int second = int(coordinateHash(seed, lattice + 1, 0, stream) % 20001ULL) - 10000;
|
||||
return (first * (spacing - offset) + second * offset) / spacing;
|
||||
}
|
||||
|
||||
PixelBiome biomeAt(std::uint64_t seed, int worldX) noexcept {
|
||||
const int band = floorDivGeneration(worldX, 192);
|
||||
return PixelBiome(coordinateHash(seed, band, 0, 0x42494F4D45ULL) % 4ULL);
|
||||
}
|
||||
|
||||
PixelCell defaultCell(const MaterialCatalog& catalog, MaterialId material) {
|
||||
PixelCell cell;
|
||||
cell.material = material;
|
||||
const auto& definition = catalog.definition(material);
|
||||
cell.temperature = definition.defaultTemperature;
|
||||
cell.lifetime = std::uint8_t(std::min<std::uint16_t>(
|
||||
definition.defaultLifetime, std::numeric_limits<std::uint8_t>::max()));
|
||||
return cell;
|
||||
}
|
||||
|
||||
MaterialId terrainMaterial(PixelBiome biome, int depth, std::uint64_t feature) noexcept {
|
||||
if (biome == PixelBiome::Desert && depth < 10) return MaterialId::Sand;
|
||||
if (biome == PixelBiome::Fungal && depth < 4) return MaterialId::Wood;
|
||||
if (biome == PixelBiome::Volcanic && depth > 18 && feature % 97ULL == 0)
|
||||
return MaterialId::Lava;
|
||||
return MaterialId::Stone;
|
||||
}
|
||||
|
||||
eve::Result<PixelWorldGenerationOutput> invalidGeneration(std::string message,
|
||||
std::string path) {
|
||||
return eve::Result<PixelWorldGenerationOutput>::failure(eve::Diagnostic::error(
|
||||
eve::DiagnosticCode::InvalidArgument, std::move(message), std::move(path), {},
|
||||
"pixelworld.generation"));
|
||||
}
|
||||
|
||||
void hashByte(std::uint64_t& hash, std::uint8_t value) noexcept {
|
||||
hash ^= value;
|
||||
hash *= 1099511628211ULL;
|
||||
}
|
||||
|
||||
template <class T>
|
||||
void hashValue(std::uint64_t& hash, T value) noexcept {
|
||||
using Unsigned = std::make_unsigned_t<T>;
|
||||
const Unsigned bits = static_cast<Unsigned>(value);
|
||||
for (std::size_t index = 0; index < sizeof(T); ++index)
|
||||
hashByte(hash, std::uint8_t(bits >> (index * 8U)));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
eve::Result<PixelWorldGenerationOutput> generatePixelWorld(
|
||||
const PixelWorldGenerationRequest& request, const MaterialCatalog& catalog,
|
||||
std::uint64_t sourceRevision, eve::SimulationTick sourceTick,
|
||||
std::uint64_t sourceLastEditSequence) {
|
||||
if (request.schemaVersion != PixelWorldGenerationRequest::kSchemaVersion)
|
||||
return invalidGeneration("unsupported PixelWorld generation schema version", "schemaVersion");
|
||||
if (request.region.minX > request.region.maxX || request.region.minY > request.region.maxY)
|
||||
return invalidGeneration("generation region bounds are inverted", "region");
|
||||
const std::int64_t width = std::int64_t(request.region.maxX) - request.region.minX + 1;
|
||||
const std::int64_t height = std::int64_t(request.region.maxY) - request.region.minY + 1;
|
||||
if (width * height > 65'536)
|
||||
return invalidGeneration("generation region exceeds 65536 Chunks", "region");
|
||||
if (sourceRevision == 0)
|
||||
return invalidGeneration("source revision must be positive", "sourceRevision");
|
||||
if (catalog.definitions().size() <= std::size_t(MaterialId::Lava))
|
||||
return invalidGeneration("generation Catalog must provide the built-in terrain material ids",
|
||||
"catalog");
|
||||
if (request.terrainAmplitude < 0 || request.terrainAmplitude > 4096)
|
||||
return invalidGeneration("terrain amplitude must be in [0, 4096]", "terrainAmplitude");
|
||||
if (request.caveThreshold > 10'000)
|
||||
return invalidGeneration("cave threshold must be in [0, 10000]", "caveThreshold");
|
||||
std::uint64_t stampCells = 0;
|
||||
for (std::size_t index = 0; index < request.stamps.size(); ++index) {
|
||||
const auto& stamp = request.stamps[index];
|
||||
if (stamp.width <= 0 || stamp.height <= 0 ||
|
||||
std::uint64_t(stamp.width) * std::uint64_t(stamp.height) != stamp.cells.size())
|
||||
return invalidGeneration("stamp dimensions must match its row-major cells",
|
||||
"stamps[" + std::to_string(index) + "]");
|
||||
stampCells += stamp.cells.size();
|
||||
if (stampCells > 4'194'304)
|
||||
return invalidGeneration("material stamps exceed the cell budget", "stamps");
|
||||
for (const PixelCell cell : stamp.cells)
|
||||
if (std::size_t(cell.material) >= catalog.definitions().size())
|
||||
return invalidGeneration("stamp references an unknown material",
|
||||
"stamps[" + std::to_string(index) + "].cells");
|
||||
}
|
||||
|
||||
PixelWorldGenerationOutput output;
|
||||
output.batch.catalogFingerprint = catalog.fingerprint();
|
||||
output.batch.sourceSeed = request.seed;
|
||||
output.batch.sourceRevision = sourceRevision;
|
||||
output.batch.sourceTick = sourceTick;
|
||||
output.batch.sourceLastEditSequence = sourceLastEditSequence;
|
||||
output.batch.chunks.reserve(std::size_t(width * height));
|
||||
output.chunks.reserve(std::size_t(width * height));
|
||||
std::uint64_t hash = 1469598103934665603ULL;
|
||||
|
||||
for (int chunkY = request.region.minY; chunkY <= request.region.maxY; ++chunkY)
|
||||
for (int chunkX = request.region.minX; chunkX <= request.region.maxX; ++chunkX) {
|
||||
PixelChunkSnapshot snapshot;
|
||||
snapshot.x = chunkX;
|
||||
snapshot.y = chunkY;
|
||||
snapshot.revision = sourceRevision;
|
||||
snapshot.cells.resize(std::size_t(kPixelChunkSize * kPixelChunkSize));
|
||||
PixelGeneratedChunkSummary summary;
|
||||
summary.x = chunkX;
|
||||
summary.y = chunkY;
|
||||
summary.biome = biomeAt(request.seed, chunkX * kPixelChunkSize + kPixelChunkSize / 2);
|
||||
for (int localY = 0; localY < kPixelChunkSize; ++localY)
|
||||
for (int localX = 0; localX < kPixelChunkSize; ++localX) {
|
||||
const int worldX = chunkX * kPixelChunkSize + localX;
|
||||
const int worldY = chunkY * kPixelChunkSize + localY;
|
||||
const PixelBiome biome = biomeAt(request.seed, worldX);
|
||||
const int surface = request.surfaceY +
|
||||
interpolatedNoise(request.seed, worldX, 32, 0x53555246414345ULL) *
|
||||
request.terrainAmplitude / 10000;
|
||||
PixelCell cell;
|
||||
if (worldY >= surface) {
|
||||
const int depth = worldY - surface;
|
||||
const std::uint64_t caveNoise = coordinateHash(
|
||||
request.seed, floorDivGeneration(worldX, 5),
|
||||
floorDivGeneration(worldY, 5), 0x43415645ULL) % 10001ULL;
|
||||
const bool cave = depth > 5 && caveNoise < request.caveThreshold;
|
||||
if (cave) {
|
||||
++summary.caveCells;
|
||||
if (worldY >= request.waterLevel &&
|
||||
coordinateHash(request.seed, worldX, worldY, 0x5741544552ULL) % 5ULL == 0)
|
||||
cell = defaultCell(catalog, MaterialId::Water);
|
||||
} else {
|
||||
const std::uint64_t feature = coordinateHash(
|
||||
request.seed, worldX, worldY, 0x46454154555245ULL);
|
||||
cell = defaultCell(catalog, terrainMaterial(biome, depth, feature));
|
||||
++summary.solidCells;
|
||||
}
|
||||
}
|
||||
snapshot.cells[std::size_t(localY * kPixelChunkSize + localX)] = cell;
|
||||
}
|
||||
|
||||
const int chunkMinX = chunkX * kPixelChunkSize;
|
||||
const int chunkMinY = chunkY * kPixelChunkSize;
|
||||
for (const PixelMaterialStamp& stamp : request.stamps)
|
||||
for (int stampY = 0; stampY < stamp.height; ++stampY)
|
||||
for (int stampX = 0; stampX < stamp.width; ++stampX) {
|
||||
const int worldX = stamp.originX + stampX;
|
||||
const int worldY = stamp.originY + stampY;
|
||||
if (worldX < chunkMinX || worldX >= chunkMinX + kPixelChunkSize ||
|
||||
worldY < chunkMinY || worldY >= chunkMinY + kPixelChunkSize)
|
||||
continue;
|
||||
snapshot.cells[std::size_t(worldY - chunkMinY) * kPixelChunkSize +
|
||||
std::size_t(worldX - chunkMinX)] =
|
||||
stamp.cells[std::size_t(stampY) * std::size_t(stamp.width) + stampX];
|
||||
++summary.stampedCells;
|
||||
}
|
||||
|
||||
hashValue(hash, std::int32_t(chunkX));
|
||||
hashValue(hash, std::int32_t(chunkY));
|
||||
for (const PixelCell cell : snapshot.cells) {
|
||||
hashValue(hash, std::uint16_t(cell.material));
|
||||
hashValue(hash, cell.temperature);
|
||||
hashValue(hash, cell.lifetime);
|
||||
hashValue(hash, cell.thermalRemainder);
|
||||
}
|
||||
output.batch.chunks.push_back(std::move(snapshot));
|
||||
output.chunks.push_back(summary);
|
||||
}
|
||||
output.contentHash = hash;
|
||||
return eve::Result<PixelWorldGenerationOutput>::success(std::move(output));
|
||||
}
|
||||
|
||||
} // namespace eve::pixelworld
|
||||
@@ -0,0 +1,73 @@
|
||||
#pragma once
|
||||
|
||||
#include "pixelworld/PixelWorld.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
namespace eve::pixelworld {
|
||||
|
||||
/** @brief Stable coarse biome selected from deterministic world coordinates. */
|
||||
enum class PixelBiome : std::uint8_t { Forest, Desert, Fungal, Volcanic };
|
||||
|
||||
/** @brief Owning row-major material stamp applied after terrain and cave generation. */
|
||||
struct PixelMaterialStamp {
|
||||
int originX = 0;
|
||||
int originY = 0;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
std::vector<PixelCell> cells;
|
||||
};
|
||||
|
||||
/** @brief Versioned bounded request for deterministic biome and cave generation. */
|
||||
struct PixelWorldGenerationRequest {
|
||||
static constexpr std::uint32_t kSchemaVersion = 1;
|
||||
std::uint32_t schemaVersion = kSchemaVersion;
|
||||
std::uint64_t seed = 1;
|
||||
PixelChunkRegion region;
|
||||
int surfaceY = 0;
|
||||
int terrainAmplitude = 24;
|
||||
int waterLevel = 36;
|
||||
/** @brief Cave admission threshold in [0, 10000]; larger values carve more cells. */
|
||||
std::uint32_t caveThreshold = 3600;
|
||||
std::vector<PixelMaterialStamp> stamps;
|
||||
};
|
||||
|
||||
/** @brief Deterministic diagnostics for one generated Chunk. */
|
||||
struct PixelGeneratedChunkSummary {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
PixelBiome biome = PixelBiome::Forest;
|
||||
std::uint32_t solidCells = 0;
|
||||
std::uint32_t caveCells = 0;
|
||||
std::uint32_t stampedCells = 0;
|
||||
|
||||
friend bool operator==(const PixelGeneratedChunkSummary&,
|
||||
const PixelGeneratedChunkSummary&) = default;
|
||||
};
|
||||
|
||||
/** @brief Owning generated correction plus reproducibility evidence. */
|
||||
struct PixelWorldGenerationOutput {
|
||||
PixelChunkBatch batch;
|
||||
std::vector<PixelGeneratedChunkSummary> chunks;
|
||||
std::uint64_t contentHash = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Generate a seamless canonical Chunk batch without mutating a PixelWorld.
|
||||
* @param request Versioned finite generation request and optional owning stamps.
|
||||
* @param catalog Catalog whose ids/default cells give generated materials meaning.
|
||||
* @param sourceRevision Positive authority revision assigned to every output Chunk.
|
||||
* @param sourceTick Authority Tick carried by the correction batch.
|
||||
* @param sourceLastEditSequence Authority edit sequence carried by the correction batch.
|
||||
* @return Owning canonical batch and summaries, or a structured bounded-input error.
|
||||
* @remarks Integer coordinate hashing makes output bit-exact across worker count and backend.
|
||||
* The caller may transactionally admit `output.batch` through `PixelWorld::applyChunkBatch`.
|
||||
* Unknown schema versions are rejected and no world state is observed or changed.
|
||||
*/
|
||||
[[nodiscard]] eve::Result<PixelWorldGenerationOutput> generatePixelWorld(
|
||||
const PixelWorldGenerationRequest& request, const MaterialCatalog& catalog,
|
||||
std::uint64_t sourceRevision, eve::SimulationTick sourceTick = eve::SimulationTick::zero(),
|
||||
std::uint64_t sourceLastEditSequence = 0);
|
||||
|
||||
} // namespace eve::pixelworld
|
||||
@@ -0,0 +1,204 @@
|
||||
#include "pixelworld/PixelWorldGenerationCodec.h"
|
||||
|
||||
#include <Poco/Dynamic/Var.h>
|
||||
#include <Poco/JSON/Array.h>
|
||||
#include <Poco/JSON/Object.h>
|
||||
#include <Poco/JSON/Parser.h>
|
||||
#include <Poco/JSON/Stringifier.h>
|
||||
|
||||
#include <charconv>
|
||||
#include <limits>
|
||||
#include <sstream>
|
||||
#include <type_traits>
|
||||
#include <unordered_set>
|
||||
|
||||
namespace eve::pixelworld {
|
||||
namespace {
|
||||
|
||||
using Object = Poco::JSON::Object;
|
||||
using Array = Poco::JSON::Array;
|
||||
|
||||
eve::Result<PixelWorldGenerationRequest> malformed(std::string message,
|
||||
std::string path = "document") {
|
||||
return eve::Result<PixelWorldGenerationRequest>::failure(eve::Diagnostic::error(
|
||||
eve::DiagnosticCode::SerializationError, std::move(message), std::move(path), {},
|
||||
"pixelworld.generation-codec"));
|
||||
}
|
||||
|
||||
bool exactFields(const Object& object, std::initializer_list<std::string_view> allowed) {
|
||||
std::unordered_set<std::string> names;
|
||||
for (const auto name : allowed) names.emplace(name);
|
||||
if (object.size() != names.size()) return false;
|
||||
for (const auto& name : object.getNames())
|
||||
if (!names.contains(name)) return false;
|
||||
for (const auto& name : names)
|
||||
if (!object.has(name)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
template <class T>
|
||||
bool readNumber(const Object& object, const std::string& key, T& result) {
|
||||
static_assert(std::is_integral_v<T> && !std::is_same_v<T, bool>);
|
||||
if (!object.has(key) || object.isNull(key)) return false;
|
||||
try {
|
||||
const auto wide = object.get(key).convert<std::int64_t>();
|
||||
if constexpr (std::is_unsigned_v<T>) {
|
||||
if (wide < 0 || std::uint64_t(wide) > std::numeric_limits<T>::max()) return false;
|
||||
} else if (wide < std::numeric_limits<T>::min() ||
|
||||
wide > std::numeric_limits<T>::max()) {
|
||||
return false;
|
||||
}
|
||||
result = static_cast<T>(wide);
|
||||
return true;
|
||||
} catch (...) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool readSeed(const Object& object, std::uint64_t& seed) {
|
||||
if (!object.has("seed") || object.isNull("seed")) return false;
|
||||
try {
|
||||
const std::string text = object.getValue<std::string>("seed");
|
||||
const char* begin = text.data();
|
||||
const char* end = begin + text.size();
|
||||
const auto parsed = std::from_chars(begin, end, seed);
|
||||
return parsed.ec == std::errc{} && parsed.ptr == end;
|
||||
} catch (...) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Object::Ptr cellJson(const PixelCell& cell) {
|
||||
Object::Ptr out(new Object);
|
||||
out->set("material", std::uint16_t(cell.material));
|
||||
out->set("temperature", cell.temperature);
|
||||
out->set("lifetime", cell.lifetime);
|
||||
out->set("thermalRemainder", cell.thermalRemainder);
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
eve::Result<std::string> encodePixelWorldGenerationRequestJson(
|
||||
const PixelWorldGenerationRequest& request) {
|
||||
Object::Ptr root(new Object);
|
||||
root->set("schema", "eve.pixelworld.generation-request");
|
||||
root->set("version", request.schemaVersion);
|
||||
root->set("seed", std::to_string(request.seed));
|
||||
Object::Ptr region(new Object);
|
||||
region->set("minX", request.region.minX);
|
||||
region->set("minY", request.region.minY);
|
||||
region->set("maxX", request.region.maxX);
|
||||
region->set("maxY", request.region.maxY);
|
||||
root->set("region", region);
|
||||
root->set("surfaceY", request.surfaceY);
|
||||
root->set("terrainAmplitude", request.terrainAmplitude);
|
||||
root->set("waterLevel", request.waterLevel);
|
||||
root->set("caveThreshold", request.caveThreshold);
|
||||
Array::Ptr stamps(new Array);
|
||||
for (const PixelMaterialStamp& stamp : request.stamps) {
|
||||
Object::Ptr value(new Object);
|
||||
value->set("originX", stamp.originX);
|
||||
value->set("originY", stamp.originY);
|
||||
value->set("width", stamp.width);
|
||||
value->set("height", stamp.height);
|
||||
Array::Ptr cells(new Array);
|
||||
for (const PixelCell& cell : stamp.cells) cells->add(cellJson(cell));
|
||||
value->set("cells", cells);
|
||||
stamps->add(value);
|
||||
}
|
||||
root->set("stamps", stamps);
|
||||
std::ostringstream text;
|
||||
Poco::JSON::Stringifier::stringify(Poco::Dynamic::Var(root), text, 2, -1);
|
||||
return eve::Result<std::string>::success(text.str());
|
||||
}
|
||||
|
||||
eve::Result<PixelWorldGenerationRequest> decodePixelWorldGenerationRequestJson(
|
||||
std::string_view json) {
|
||||
Object::Ptr root;
|
||||
try {
|
||||
root = Poco::JSON::Parser().parse(std::string(json)).extract<Object::Ptr>();
|
||||
} catch (...) {
|
||||
return malformed("generation request JSON is malformed");
|
||||
}
|
||||
if (!root || !exactFields(*root, {"schema", "version", "seed", "region", "surfaceY",
|
||||
"terrainAmplitude", "waterLevel", "caveThreshold", "stamps"}))
|
||||
return malformed("root contains missing or unknown fields");
|
||||
if (root->optValue<std::string>("schema", "") != "eve.pixelworld.generation-request")
|
||||
return malformed("unknown generation request schema", "schema");
|
||||
|
||||
PixelWorldGenerationRequest request;
|
||||
if (!readNumber(*root, "version", request.schemaVersion) ||
|
||||
request.schemaVersion != PixelWorldGenerationRequest::kSchemaVersion)
|
||||
return malformed("unknown generation request version", "version");
|
||||
if (!readSeed(*root, request.seed)) return malformed("seed is not a canonical uint64", "seed");
|
||||
Object::Ptr region;
|
||||
Array::Ptr stamps;
|
||||
try {
|
||||
region = root->getObject("region");
|
||||
stamps = root->getArray("stamps");
|
||||
} catch (...) {
|
||||
return malformed("region or stamps has an invalid type");
|
||||
}
|
||||
if (!region || !exactFields(*region, {"minX", "minY", "maxX", "maxY"}) || !stamps ||
|
||||
stamps->size() > 65'536U)
|
||||
return malformed("region fields or stamp budget are invalid");
|
||||
if (!readNumber(*region, "minX", request.region.minX) ||
|
||||
!readNumber(*region, "minY", request.region.minY) ||
|
||||
!readNumber(*region, "maxX", request.region.maxX) ||
|
||||
!readNumber(*region, "maxY", request.region.maxY) ||
|
||||
!readNumber(*root, "surfaceY", request.surfaceY) ||
|
||||
!readNumber(*root, "terrainAmplitude", request.terrainAmplitude) ||
|
||||
!readNumber(*root, "waterLevel", request.waterLevel) ||
|
||||
!readNumber(*root, "caveThreshold", request.caveThreshold))
|
||||
return malformed("generation request contains an invalid number");
|
||||
|
||||
std::uint64_t totalCells = 0;
|
||||
request.stamps.reserve(stamps->size());
|
||||
for (std::size_t index = 0; index < stamps->size(); ++index) {
|
||||
Object::Ptr value;
|
||||
Array::Ptr cells;
|
||||
try {
|
||||
value = stamps->getObject(unsigned(index));
|
||||
if (value) cells = value->getArray("cells");
|
||||
} catch (...) {
|
||||
return malformed("stamp is not an object", "stamps[" + std::to_string(index) + "]");
|
||||
}
|
||||
if (!value || !exactFields(*value, {"originX", "originY", "width", "height", "cells"}) ||
|
||||
!cells)
|
||||
return malformed("stamp contains missing or unknown fields",
|
||||
"stamps[" + std::to_string(index) + "]");
|
||||
PixelMaterialStamp stamp;
|
||||
if (!readNumber(*value, "originX", stamp.originX) ||
|
||||
!readNumber(*value, "originY", stamp.originY) ||
|
||||
!readNumber(*value, "width", stamp.width) ||
|
||||
!readNumber(*value, "height", stamp.height) || stamp.width <= 0 || stamp.height <= 0 ||
|
||||
std::uint64_t(stamp.width) * std::uint64_t(stamp.height) != cells->size())
|
||||
return malformed("stamp dimensions are invalid", "stamps[" + std::to_string(index) + "]");
|
||||
totalCells += cells->size();
|
||||
if (totalCells > 4'194'304ULL) return malformed("stamp cell budget is exceeded", "stamps");
|
||||
stamp.cells.reserve(cells->size());
|
||||
for (std::size_t cellIndex = 0; cellIndex < cells->size(); ++cellIndex) {
|
||||
Object::Ptr item;
|
||||
try {
|
||||
item = cells->getObject(unsigned(cellIndex));
|
||||
} catch (...) {
|
||||
return malformed("stamp cell is not an object", "stamps.cells");
|
||||
}
|
||||
PixelCell cell;
|
||||
std::uint16_t material = 0;
|
||||
if (!item || !exactFields(*item, {"material", "temperature", "lifetime", "thermalRemainder"}) ||
|
||||
!readNumber(*item, "material", material) ||
|
||||
!readNumber(*item, "temperature", cell.temperature) ||
|
||||
!readNumber(*item, "lifetime", cell.lifetime) ||
|
||||
!readNumber(*item, "thermalRemainder", cell.thermalRemainder))
|
||||
return malformed("stamp cell contains invalid fields", "stamps.cells");
|
||||
cell.material = MaterialId(material);
|
||||
stamp.cells.push_back(cell);
|
||||
}
|
||||
request.stamps.push_back(std::move(stamp));
|
||||
}
|
||||
return eve::Result<PixelWorldGenerationRequest>::success(std::move(request));
|
||||
}
|
||||
|
||||
} // namespace eve::pixelworld
|
||||
@@ -0,0 +1,23 @@
|
||||
#pragma once
|
||||
|
||||
#include "common/Result.h"
|
||||
#include "pixelworld/PixelWorldGeneration.h"
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace eve::pixelworld {
|
||||
|
||||
/** @brief Encode schema `eve.pixelworld.generation-request` version 1 canonically. */
|
||||
[[nodiscard]] eve::Result<std::string> encodePixelWorldGenerationRequestJson(
|
||||
const PixelWorldGenerationRequest& request);
|
||||
|
||||
/**
|
||||
* @brief Decode schema `eve.pixelworld.generation-request` version 1.
|
||||
* @remarks Unknown fields and versions are rejected. The returned request owns every
|
||||
* stamp cell, and decoding cannot mutate a live PixelWorld.
|
||||
*/
|
||||
[[nodiscard]] eve::Result<PixelWorldGenerationRequest>
|
||||
decodePixelWorldGenerationRequestJson(std::string_view json);
|
||||
|
||||
} // namespace eve::pixelworld
|
||||
@@ -0,0 +1,62 @@
|
||||
#include "pixelworld/PixelWorldModule.h"
|
||||
|
||||
#include <simplesquirrel/simplesquirrel.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace eve::pixelworld {
|
||||
|
||||
void registerPixelWorldAutomation();
|
||||
void unregisterPixelWorldAutomation();
|
||||
|
||||
Module_IMPL(PixelWorldModule, new PixelWorldModule());
|
||||
|
||||
PixelWorldModule::PixelWorldModule() { registerPixelWorldAutomation(); }
|
||||
PixelWorldModule::~PixelWorldModule() { unregisterPixelWorldAutomation(); }
|
||||
|
||||
PixelWorld* PixelWorldModule::newWorld(std::uint64_t seed) { return new PixelWorld(seed); }
|
||||
|
||||
void PixelWorldModule::expose(ssq::Table& table) {
|
||||
auto module = table.addClass(name, PixelWorldModule::create, false);
|
||||
expose(module);
|
||||
|
||||
auto world = table.addClass<PixelWorld>(
|
||||
"PixelWorld", std::function<PixelWorld*()>([]() -> PixelWorld* { return nullptr; }), true);
|
||||
world.addFunc("getMaterial", [](PixelWorld* self, int x, int y) { return self->getMaterial(x, y); });
|
||||
world.addFunc("setMaterial", [](PixelWorld* self, int x, int y, const std::string& material) {
|
||||
self->setMaterial(x, y, material);
|
||||
});
|
||||
world.addFunc("paintCircle", [](PixelWorld* self, int x, int y, int radius, const std::string& material) {
|
||||
return int(self->paintCircle(x, y, radius, material));
|
||||
});
|
||||
world.addFunc("step", [](PixelWorld* self) {
|
||||
return int(self->step().cellsMoved);
|
||||
});
|
||||
world.addFunc("clear", [](PixelWorld* self) { self->clear(); });
|
||||
world.addFunc("getSeed", [](PixelWorld* self) { return std::int64_t(self->seed()); });
|
||||
world.addFunc("getRevision", [](PixelWorld* self) { return std::int64_t(self->revision()); });
|
||||
world.addFunc("getTick", [](PixelWorld* self) { return std::int64_t(self->tickValue()); });
|
||||
world.addFunc("getLastEditSequence", [](PixelWorld* self) {
|
||||
return std::int64_t(self->lastEditSequence());
|
||||
});
|
||||
world.addFunc("getChunkCount", [](PixelWorld* self) { return self->chunkCount(); });
|
||||
world.addFunc("getActiveChunkCount", [](PixelWorld* self) { return self->activeChunkCount(); });
|
||||
world.addFunc("getChangedChunkCountSince", [](PixelWorld* self, int revision) {
|
||||
return int(self->snapshotChangedChunks(revision < 0 ? 0U : std::uint64_t(revision)).size());
|
||||
});
|
||||
world.addFunc("explode", [](PixelWorld* self, int x, int y, int radius, int strength, int heat) {
|
||||
return int(self->explode(x, y, radius, strength,
|
||||
std::int16_t(std::clamp(heat, -32768, 32767))).cellsRemoved);
|
||||
});
|
||||
}
|
||||
|
||||
void PixelWorldModule::expose(ssq::Class& cls) {
|
||||
cls.addFunc("newWorld", [](PixelWorldModule* self, int seed) {
|
||||
return self->newWorld(std::uint64_t(std::uint32_t(seed)));
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace eve::pixelworld
|
||||
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
#include "common/Module.h"
|
||||
#include "pixelworld/PixelWorld.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace eve::pixelworld {
|
||||
|
||||
/** @brief Script-facing owner/factory for deterministic pixel material worlds. */
|
||||
class PixelWorldModule : public Module {
|
||||
public:
|
||||
Module_REG(PixelWorldModule);
|
||||
|
||||
PixelWorldModule();
|
||||
~PixelWorldModule() override;
|
||||
|
||||
/** @brief Create a caller-owned world using the supplied deterministic seed. */
|
||||
[[nodiscard("retain and delete the returned PixelWorld")]] PixelWorld* newWorld(std::uint64_t seed = 1);
|
||||
};
|
||||
|
||||
} // namespace eve::pixelworld
|
||||
@@ -0,0 +1,618 @@
|
||||
#include "pixelworld_editor/PixelWorldCatalogPanel.h"
|
||||
|
||||
#include "pixelworld/PixelMaterialCatalogCodec.h"
|
||||
#include "ui/UIHost.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <charconv>
|
||||
#include <iomanip>
|
||||
#include <limits>
|
||||
#include <sstream>
|
||||
#include <utility>
|
||||
|
||||
namespace eve::pixelworld_editor {
|
||||
namespace {
|
||||
|
||||
using namespace eve::pixelworld;
|
||||
|
||||
constexpr const char* kCatalogPanelHostName = "eve_pixelworld_catalog";
|
||||
|
||||
template <class T>
|
||||
eve::Result<T> fail(eve::DiagnosticCode code, std::string message, std::string path) {
|
||||
return eve::Result<T>::failure(eve::Diagnostic::error(
|
||||
code, std::move(message), std::move(path), {}, "pixelworld.catalog-panel"));
|
||||
}
|
||||
|
||||
std::vector<MaterialDefinition> definitions(const MaterialCatalog& catalog) {
|
||||
return {catalog.definitions().begin(), catalog.definitions().end()};
|
||||
}
|
||||
|
||||
std::vector<MaterialReactionRule> reactions(const MaterialCatalog& catalog) {
|
||||
return {catalog.reactions().begin(), catalog.reactions().end()};
|
||||
}
|
||||
|
||||
std::vector<MaterialPhaseRule> phases(const MaterialCatalog& catalog) {
|
||||
return {catalog.phaseRules().begin(), catalog.phaseRules().end()};
|
||||
}
|
||||
|
||||
std::string stateName(MaterialState state) {
|
||||
constexpr std::array names{"Empty", "Solid", "Powder", "Liquid", "Gas", "Energy"};
|
||||
const auto index = static_cast<std::size_t>(state);
|
||||
return index < names.size() ? names[index] : "Unknown";
|
||||
}
|
||||
|
||||
template <class Integer>
|
||||
std::optional<Integer> parseInteger(std::string_view text) {
|
||||
Integer value{};
|
||||
const auto parsed = std::from_chars(text.data(), text.data() + text.size(), value);
|
||||
if (parsed.ec != std::errc{} || parsed.ptr != text.data() + text.size()) return std::nullopt;
|
||||
return value;
|
||||
}
|
||||
|
||||
std::optional<std::uint32_t> parseRgba(std::string_view text) {
|
||||
if (!text.empty() && text.front() == '#') text.remove_prefix(1);
|
||||
if (text.size() != 8) return std::nullopt;
|
||||
std::uint32_t value = 0;
|
||||
const auto parsed = std::from_chars(text.data(), text.data() + text.size(), value, 16);
|
||||
if (parsed.ec != std::errc{} || parsed.ptr != text.data() + text.size()) return std::nullopt;
|
||||
return value;
|
||||
}
|
||||
|
||||
std::string rgbaText(std::uint32_t rgba) {
|
||||
std::ostringstream output;
|
||||
output << '#' << std::uppercase << std::hex << std::setw(8) << std::setfill('0') << rgba;
|
||||
return output.str();
|
||||
}
|
||||
|
||||
std::vector<std::string> splitTags(std::string_view value) {
|
||||
std::vector<std::string> result;
|
||||
while (!value.empty()) {
|
||||
const auto comma = value.find(',');
|
||||
std::string tag(value.substr(0, comma));
|
||||
const auto first = tag.find_first_not_of(" \t");
|
||||
const auto last = tag.find_last_not_of(" \t");
|
||||
if (first != std::string::npos) result.push_back(tag.substr(first, last - first + 1));
|
||||
if (comma == std::string_view::npos) break;
|
||||
value.remove_prefix(comma + 1);
|
||||
}
|
||||
std::sort(result.begin(), result.end());
|
||||
result.erase(std::unique(result.begin(), result.end()), result.end());
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string joinTags(const std::vector<std::string>& tags) {
|
||||
std::string result;
|
||||
for (const auto& tag : tags) {
|
||||
if (!result.empty()) result += ", ";
|
||||
result += tag;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
template <class Integer, class Setter>
|
||||
eve::ui::WidgetDesc integerField(std::string label, std::string id, Integer value,
|
||||
Setter setter) {
|
||||
return eve::ui::inputText(std::move(label), std::to_string(value), std::move(id),
|
||||
[setter = std::move(setter)](const std::string& text) mutable {
|
||||
if (const auto parsed = parseInteger<Integer>(text)) setter(*parsed);
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
PixelWorldCatalogPanel::PixelWorldCatalogPanel(MaterialCatalog catalog)
|
||||
: draft_(std::move(catalog)) {
|
||||
if (!draft_.reactions().empty()) selectedReaction_ = 0;
|
||||
if (!draft_.phaseRules().empty()) selectedPhase_ = 0;
|
||||
}
|
||||
|
||||
PixelWorldCatalogPanel::~PixelWorldCatalogPanel() {
|
||||
if (auto host = eve::ui::UIHost::resolve(host_)) {
|
||||
host->get().setTree(eve::ui::window("", {}));
|
||||
host->get().setVisible(false);
|
||||
}
|
||||
}
|
||||
|
||||
PixelWorldCatalogPanel::PixelWorldCatalogPanel(PixelWorldCatalogPanel&& other)
|
||||
: draft_(std::move(other.draft_)),
|
||||
revision_(other.revision_),
|
||||
selectedMaterial_(other.selectedMaterial_),
|
||||
selectedReaction_(other.selectedReaction_),
|
||||
selectedPhase_(other.selectedPhase_),
|
||||
statusText_(std::move(other.statusText_)),
|
||||
host_(other.host_) {
|
||||
other.host_ = {};
|
||||
if (host_.table) (void)refresh();
|
||||
}
|
||||
|
||||
PixelWorldCatalogPanel& PixelWorldCatalogPanel::operator=(PixelWorldCatalogPanel&& other) {
|
||||
if (this == &other) return *this;
|
||||
if (auto host = eve::ui::UIHost::resolve(host_)) {
|
||||
host->get().setTree(eve::ui::window("", {}));
|
||||
host->get().setVisible(false);
|
||||
}
|
||||
draft_ = std::move(other.draft_);
|
||||
revision_ = other.revision_;
|
||||
selectedMaterial_ = other.selectedMaterial_;
|
||||
selectedReaction_ = other.selectedReaction_;
|
||||
selectedPhase_ = other.selectedPhase_;
|
||||
statusText_ = std::move(other.statusText_);
|
||||
host_ = other.host_;
|
||||
other.host_ = {};
|
||||
if (host_.table) (void)refresh();
|
||||
return *this;
|
||||
}
|
||||
|
||||
eve::Result<PixelWorldCatalogPanel> PixelWorldCatalogPanel::create(std::string_view catalogJson) {
|
||||
auto decoded = decodeMaterialCatalogJson(catalogJson);
|
||||
if (!decoded.ok())
|
||||
return eve::Result<PixelWorldCatalogPanel>::failure(decoded.status());
|
||||
return eve::Result<PixelWorldCatalogPanel>::success(
|
||||
PixelWorldCatalogPanel(std::move(decoded).takeValue()));
|
||||
}
|
||||
|
||||
PixelWorldCatalogPanel PixelWorldCatalogPanel::builtIn() {
|
||||
return PixelWorldCatalogPanel(MaterialCatalog::builtIn());
|
||||
}
|
||||
|
||||
std::vector<MaterialCard> PixelWorldCatalogPanel::materialCards() const {
|
||||
std::vector<MaterialCard> result;
|
||||
result.reserve(draft_.definitions().size());
|
||||
for (std::size_t index = 0; index < draft_.definitions().size(); ++index) {
|
||||
const auto& definition = draft_.definitions()[index];
|
||||
result.push_back({definition.id, definition.name, definition.state,
|
||||
definition.displayRgba, index == selectedMaterial_,
|
||||
draft_.canReact(definition.id),
|
||||
draft_.canPhaseTransition(definition.id)});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
eve::Result<void> PixelWorldCatalogPanel::selectMaterial(std::size_t index) {
|
||||
if (index >= draft_.definitions().size())
|
||||
return fail<void>(eve::DiagnosticCode::InvalidArgument,
|
||||
"material selection is outside the Catalog", "materialIndex");
|
||||
selectedMaterial_ = index;
|
||||
return eve::Result<void>::success();
|
||||
}
|
||||
|
||||
eve::Result<void> PixelWorldCatalogPanel::selectReaction(std::size_t index) {
|
||||
if (index >= draft_.reactions().size())
|
||||
return fail<void>(eve::DiagnosticCode::InvalidArgument,
|
||||
"reaction selection is outside the Catalog", "reactionIndex");
|
||||
selectedReaction_ = index;
|
||||
return eve::Result<void>::success();
|
||||
}
|
||||
|
||||
eve::Result<void> PixelWorldCatalogPanel::selectPhase(std::size_t index) {
|
||||
if (index >= draft_.phaseRules().size())
|
||||
return fail<void>(eve::DiagnosticCode::InvalidArgument,
|
||||
"phase selection is outside the Catalog", "phaseIndex");
|
||||
selectedPhase_ = index;
|
||||
return eve::Result<void>::success();
|
||||
}
|
||||
|
||||
eve::Result<CatalogDraftReceipt> PixelWorldCatalogPanel::commitCandidate(
|
||||
std::vector<MaterialDefinition> candidateDefinitions,
|
||||
std::vector<MaterialReactionRule> candidateReactions,
|
||||
std::vector<MaterialPhaseRule> candidatePhases) {
|
||||
auto candidate = MaterialCatalog::create(std::move(candidateDefinitions),
|
||||
std::move(candidateReactions),
|
||||
std::move(candidatePhases));
|
||||
if (!candidate.ok())
|
||||
return eve::Result<CatalogDraftReceipt>::failure(candidate.status());
|
||||
CatalogDraftReceipt receipt{revision_, revision_ + 1, candidate.value().fingerprint()};
|
||||
draft_ = std::move(candidate).takeValue();
|
||||
revision_ = receipt.revisionAfter;
|
||||
if (selectedMaterial_ >= draft_.definitions().size()) selectedMaterial_ = 0;
|
||||
if (draft_.reactions().empty()) selectedReaction_.reset();
|
||||
else if (!selectedReaction_ || *selectedReaction_ >= draft_.reactions().size())
|
||||
selectedReaction_ = draft_.reactions().size() - 1;
|
||||
if (draft_.phaseRules().empty()) selectedPhase_.reset();
|
||||
else if (!selectedPhase_ || *selectedPhase_ >= draft_.phaseRules().size())
|
||||
selectedPhase_ = draft_.phaseRules().size() - 1;
|
||||
return eve::Result<CatalogDraftReceipt>::success(receipt);
|
||||
}
|
||||
|
||||
eve::Result<CatalogDraftReceipt> PixelWorldCatalogPanel::replaceMaterial(
|
||||
MaterialDefinition definition) {
|
||||
const auto index = static_cast<std::size_t>(definition.id);
|
||||
if (index >= draft_.definitions().size())
|
||||
return fail<CatalogDraftReceipt>(eve::DiagnosticCode::InvalidArgument,
|
||||
"material id is outside the Catalog", "material.id");
|
||||
auto candidateDefinitions = definitions(draft_);
|
||||
candidateDefinitions[index] = std::move(definition);
|
||||
return commitCandidate(std::move(candidateDefinitions), reactions(draft_), phases(draft_));
|
||||
}
|
||||
|
||||
eve::Result<CatalogDraftReceipt> PixelWorldCatalogPanel::replaceReaction(
|
||||
std::size_t index, MaterialReactionRule rule) {
|
||||
if (index >= draft_.reactions().size())
|
||||
return fail<CatalogDraftReceipt>(eve::DiagnosticCode::InvalidArgument,
|
||||
"reaction index is outside the Catalog", "reactionIndex");
|
||||
auto candidate = reactions(draft_);
|
||||
candidate[index] = std::move(rule);
|
||||
return commitCandidate(definitions(draft_), std::move(candidate), phases(draft_));
|
||||
}
|
||||
|
||||
eve::Result<CatalogDraftReceipt> PixelWorldCatalogPanel::addReaction(
|
||||
MaterialReactionRule rule) {
|
||||
const std::string selectedId = rule.id;
|
||||
auto candidate = reactions(draft_);
|
||||
candidate.push_back(std::move(rule));
|
||||
auto committed = commitCandidate(definitions(draft_), std::move(candidate), phases(draft_));
|
||||
if (committed.ok()) {
|
||||
for (std::size_t index = 0; index < draft_.reactions().size(); ++index)
|
||||
if (draft_.reactions()[index].id == selectedId) selectedReaction_ = index;
|
||||
}
|
||||
return committed;
|
||||
}
|
||||
|
||||
eve::Result<CatalogDraftReceipt> PixelWorldCatalogPanel::removeReaction(std::size_t index) {
|
||||
if (index >= draft_.reactions().size())
|
||||
return fail<CatalogDraftReceipt>(eve::DiagnosticCode::InvalidArgument,
|
||||
"reaction index is outside the Catalog", "reactionIndex");
|
||||
auto candidate = reactions(draft_);
|
||||
candidate.erase(candidate.begin() + static_cast<std::ptrdiff_t>(index));
|
||||
return commitCandidate(definitions(draft_), std::move(candidate), phases(draft_));
|
||||
}
|
||||
|
||||
eve::Result<CatalogDraftReceipt> PixelWorldCatalogPanel::replacePhase(
|
||||
std::size_t index, MaterialPhaseRule rule) {
|
||||
if (index >= draft_.phaseRules().size())
|
||||
return fail<CatalogDraftReceipt>(eve::DiagnosticCode::InvalidArgument,
|
||||
"phase index is outside the Catalog", "phaseIndex");
|
||||
auto candidate = phases(draft_);
|
||||
candidate[index] = std::move(rule);
|
||||
return commitCandidate(definitions(draft_), reactions(draft_), std::move(candidate));
|
||||
}
|
||||
|
||||
eve::Result<CatalogDraftReceipt> PixelWorldCatalogPanel::addPhase(MaterialPhaseRule rule) {
|
||||
const std::string selectedId = rule.id;
|
||||
auto candidate = phases(draft_);
|
||||
candidate.push_back(std::move(rule));
|
||||
auto committed = commitCandidate(definitions(draft_), reactions(draft_), std::move(candidate));
|
||||
if (committed.ok()) {
|
||||
for (std::size_t index = 0; index < draft_.phaseRules().size(); ++index)
|
||||
if (draft_.phaseRules()[index].id == selectedId) selectedPhase_ = index;
|
||||
}
|
||||
return committed;
|
||||
}
|
||||
|
||||
eve::Result<CatalogDraftReceipt> PixelWorldCatalogPanel::removePhase(std::size_t index) {
|
||||
if (index >= draft_.phaseRules().size())
|
||||
return fail<CatalogDraftReceipt>(eve::DiagnosticCode::InvalidArgument,
|
||||
"phase index is outside the Catalog", "phaseIndex");
|
||||
auto candidate = phases(draft_);
|
||||
candidate.erase(candidate.begin() + static_cast<std::ptrdiff_t>(index));
|
||||
return commitCandidate(definitions(draft_), reactions(draft_), std::move(candidate));
|
||||
}
|
||||
|
||||
eve::Result<std::string> PixelWorldCatalogPanel::document() const {
|
||||
return encodeMaterialCatalogJson(draft_);
|
||||
}
|
||||
|
||||
eve::Result<CatalogDraftReceipt> PixelWorldCatalogPanel::loadDocument(
|
||||
std::string_view catalogJson) {
|
||||
auto decoded = decodeMaterialCatalogJson(catalogJson);
|
||||
if (!decoded.ok())
|
||||
return eve::Result<CatalogDraftReceipt>::failure(decoded.status());
|
||||
return commitCandidate(definitions(decoded.value()), reactions(decoded.value()),
|
||||
phases(decoded.value()));
|
||||
}
|
||||
|
||||
eve::Result<PixelCatalogReloadReceipt> PixelWorldCatalogPanel::apply(
|
||||
PixelWorldControlService& control, std::uint64_t worldId,
|
||||
std::uint64_t expectedFingerprint) const {
|
||||
auto encoded = document();
|
||||
if (!encoded.ok())
|
||||
return eve::Result<PixelCatalogReloadReceipt>::failure(encoded.status());
|
||||
return control.reloadMaterialCatalog(worldId, encoded.value(), expectedFingerprint);
|
||||
}
|
||||
|
||||
eve::Result<eve::ui::UIHostHandle> PixelWorldCatalogPanel::open() {
|
||||
auto host = eve::ui::UIHost::resolve(host_);
|
||||
if (!host) {
|
||||
host_ = eve::ui::UIHost::createHost(kCatalogPanelHostName);
|
||||
host = eve::ui::UIHost::resolve(host_);
|
||||
}
|
||||
if (!host) {
|
||||
return fail<eve::ui::UIHostHandle>(
|
||||
eve::DiagnosticCode::PreconditionViolation,
|
||||
"pixelworld catalog panel requires an active UI ECS world", "uiHost");
|
||||
}
|
||||
host->get().setVisible(true);
|
||||
host->get().setLayer(90);
|
||||
auto meta = host->get().meta();
|
||||
meta->hasPos = true;
|
||||
meta->posX = 12.0F;
|
||||
meta->posY = 12.0F;
|
||||
meta->hasSize = true;
|
||||
meta->sizeX = 820.0F;
|
||||
meta->sizeY = 540.0F;
|
||||
auto refreshed = refresh();
|
||||
if (!refreshed) {
|
||||
return eve::Result<eve::ui::UIHostHandle>::failure(refreshed.status());
|
||||
}
|
||||
return eve::Result<eve::ui::UIHostHandle>::success(host_);
|
||||
}
|
||||
|
||||
void PixelWorldCatalogPanel::close() {
|
||||
if (auto host = eve::ui::UIHost::resolve(host_)) host->get().setVisible(false);
|
||||
}
|
||||
|
||||
bool PixelWorldCatalogPanel::isOpen() const {
|
||||
auto host = eve::ui::UIHost::resolve(host_);
|
||||
return host && host->get().meta()->visible;
|
||||
}
|
||||
|
||||
eve::Result<void> PixelWorldCatalogPanel::refresh() {
|
||||
auto host = eve::ui::UIHost::resolve(host_);
|
||||
if (!host)
|
||||
return fail<void>(eve::DiagnosticCode::PreconditionViolation,
|
||||
"pixelworld catalog panel is not mounted", "uiHost");
|
||||
host->get().setTree(buildWidgetTree());
|
||||
return eve::Result<void>::success();
|
||||
}
|
||||
|
||||
void PixelWorldCatalogPanel::observe(eve::Result<CatalogDraftReceipt> result) {
|
||||
if (result.ok()) statusText_ = "Catalog draft validated at revision " +
|
||||
std::to_string(result.value().revisionAfter);
|
||||
else statusText_ = result.status().describe();
|
||||
if (isOpen()) (void)refresh();
|
||||
}
|
||||
|
||||
void PixelWorldCatalogPanel::observe(eve::Result<void> result) {
|
||||
if (result.ok()) statusText_ = "Selection updated";
|
||||
else statusText_ = result.status().describe();
|
||||
if (isOpen()) (void)refresh();
|
||||
}
|
||||
|
||||
eve::ui::WidgetDesc PixelWorldCatalogPanel::buildWidgetTree() {
|
||||
using namespace eve::ui;
|
||||
std::vector<WidgetDesc> materialRows;
|
||||
for (std::size_t index = 0; index < draft_.definitions().size(); ++index) {
|
||||
const auto& material = draft_.definitions()[index];
|
||||
const float red = float((material.displayRgba >> 24U) & 0xFFU) / 255.0f;
|
||||
const float green = float((material.displayRgba >> 16U) & 0xFFU) / 255.0f;
|
||||
const float blue = float((material.displayRgba >> 8U) & 0xFFU) / 255.0f;
|
||||
WidgetDesc swatch = image("material-swatch/" + std::to_string(index), 18.0f, 18.0f)
|
||||
.withTint(red, green, blue, 1.0f)
|
||||
.withCornerRadius(3.0f);
|
||||
WidgetDesc choose = button(material.name + " · " + stateName(material.state),
|
||||
"material/" + std::to_string(index), [this, index] {
|
||||
observe(selectMaterial(index));
|
||||
});
|
||||
choose.checked = index == selectedMaterial_;
|
||||
materialRows.push_back(row({std::move(swatch), std::move(choose)},
|
||||
"material-row/" + std::to_string(index)));
|
||||
}
|
||||
|
||||
const MaterialDefinition selected = draft_.definitions()[selectedMaterial_];
|
||||
auto mutateMaterial = [this, id = selected.id](auto mutation) {
|
||||
MaterialDefinition value = draft_.definition(id);
|
||||
mutation(value);
|
||||
observe(replaceMaterial(std::move(value)));
|
||||
};
|
||||
std::vector<std::string> stateOptions{"Empty", "Solid", "Powder", "Liquid", "Gas", "Energy"};
|
||||
const float red = float((selected.displayRgba >> 24U) & 0xFFU) / 255.0f;
|
||||
const float green = float((selected.displayRgba >> 16U) & 0xFFU) / 255.0f;
|
||||
const float blue = float((selected.displayRgba >> 8U) & 0xFFU) / 255.0f;
|
||||
const float alpha = float(selected.displayRgba & 0xFFU) / 255.0f;
|
||||
|
||||
std::vector<WidgetDesc> materialInspector{
|
||||
sectionHeader("Material · " + selected.name, "material-heading"),
|
||||
image("material-preview", 96.0f, 56.0f).withTint(red, green, blue, alpha).withCornerRadius(6.0f),
|
||||
inputText("Name", selected.name, "material-name", [mutateMaterial](const std::string& value) mutable {
|
||||
mutateMaterial([&](auto& material) { material.name = value; });
|
||||
}),
|
||||
combo("State", stateOptions, static_cast<int>(selected.state), "material-state",
|
||||
[mutateMaterial](float value) mutable {
|
||||
mutateMaterial([&](auto& material) {
|
||||
material.state = static_cast<MaterialState>(std::clamp(int(value), 0, 5));
|
||||
});
|
||||
}),
|
||||
inputText("RGBA", rgbaText(selected.displayRgba), "material-rgba",
|
||||
[mutateMaterial](const std::string& value) mutable {
|
||||
if (const auto parsed = parseRgba(value))
|
||||
mutateMaterial([&](auto& material) { material.displayRgba = *parsed; });
|
||||
}),
|
||||
checkbox("Flammable", selected.flammable, "material-flammable",
|
||||
[mutateMaterial](bool value) mutable {
|
||||
mutateMaterial([&](auto& material) { material.flammable = value; });
|
||||
}),
|
||||
inputText("Tags", joinTags(selected.tags), "material-tags",
|
||||
[mutateMaterial](const std::string& value) mutable {
|
||||
mutateMaterial([&](auto& material) { material.tags = splitTags(value); });
|
||||
}),
|
||||
integerField("Density", "material-density", selected.density,
|
||||
[mutateMaterial](std::uint16_t value) mutable {
|
||||
mutateMaterial([&](auto& material) { material.density = value; });
|
||||
}),
|
||||
integerField("Viscosity", "material-viscosity", selected.viscosity,
|
||||
[mutateMaterial](std::uint8_t value) mutable {
|
||||
mutateMaterial([&](auto& material) { material.viscosity = value; });
|
||||
}),
|
||||
integerField("Thermal conductivity", "material-conductivity", selected.thermalConductivity,
|
||||
[mutateMaterial](std::uint8_t value) mutable {
|
||||
mutateMaterial([&](auto& material) { material.thermalConductivity = value; });
|
||||
}),
|
||||
integerField("Heat capacity", "material-capacity", selected.heatCapacity,
|
||||
[mutateMaterial](std::uint16_t value) mutable {
|
||||
mutateMaterial([&](auto& material) { material.heatCapacity = value; });
|
||||
}),
|
||||
integerField("Ignition temperature", "material-ignition", selected.ignitionTemperature,
|
||||
[mutateMaterial](std::int16_t value) mutable {
|
||||
mutateMaterial([&](auto& material) { material.ignitionTemperature = value; });
|
||||
}),
|
||||
integerField("Melting temperature", "material-melting", selected.meltingTemperature,
|
||||
[mutateMaterial](std::int16_t value) mutable {
|
||||
mutateMaterial([&](auto& material) { material.meltingTemperature = value; });
|
||||
}),
|
||||
integerField("Boiling temperature", "material-boiling", selected.boilingTemperature,
|
||||
[mutateMaterial](std::int16_t value) mutable {
|
||||
mutateMaterial([&](auto& material) { material.boilingTemperature = value; });
|
||||
}),
|
||||
integerField("Default temperature", "material-default-temperature", selected.defaultTemperature,
|
||||
[mutateMaterial](std::int16_t value) mutable {
|
||||
mutateMaterial([&](auto& material) { material.defaultTemperature = value; });
|
||||
}),
|
||||
integerField("Default lifetime", "material-lifetime", selected.defaultLifetime,
|
||||
[mutateMaterial](std::uint16_t value) mutable {
|
||||
mutateMaterial([&](auto& material) { material.defaultLifetime = value; });
|
||||
}),
|
||||
integerField("Blast resistance", "material-blast", selected.blastResistance,
|
||||
[mutateMaterial](std::uint16_t value) mutable {
|
||||
mutateMaterial([&](auto& material) { material.blastResistance = value; });
|
||||
})};
|
||||
|
||||
std::vector<std::string> materialNames;
|
||||
for (const auto& material : draft_.definitions()) materialNames.push_back(material.name);
|
||||
std::vector<std::string> reactionNames;
|
||||
for (const auto& reaction : draft_.reactions()) reactionNames.push_back(reaction.id);
|
||||
std::vector<WidgetDesc> reactionEditor{sectionHeader("Binary reactions", "reaction-heading")};
|
||||
if (!reactionNames.empty()) {
|
||||
const auto reactionIndex = selectedReaction_.value_or(0);
|
||||
const MaterialReactionRule selectedRule = draft_.reactions()[reactionIndex];
|
||||
auto mutateReaction = [this, reactionIndex](auto mutation) {
|
||||
MaterialReactionRule value = draft_.reactions()[reactionIndex];
|
||||
mutation(value);
|
||||
observe(replaceReaction(reactionIndex, std::move(value)));
|
||||
};
|
||||
reactionEditor.push_back(combo("Rule", reactionNames, int(reactionIndex), "reaction-select",
|
||||
[this](float value) {
|
||||
observe(selectReaction(std::size_t(std::max(0, int(value)))));
|
||||
}));
|
||||
reactionEditor.push_back(inputText("ID", selectedRule.id, "reaction-id",
|
||||
[mutateReaction](const std::string& value) mutable {
|
||||
mutateReaction([&](auto& rule) { rule.id = value; });
|
||||
}));
|
||||
auto materialCombo = [&](std::string label, std::string id, MaterialId value, auto assign) {
|
||||
return combo(std::move(label), materialNames, int(value), std::move(id),
|
||||
[mutateReaction, assign](float index) mutable {
|
||||
mutateReaction([&](auto& rule) {
|
||||
assign(rule, static_cast<MaterialId>(std::max(0, int(index))));
|
||||
});
|
||||
});
|
||||
};
|
||||
reactionEditor.push_back(materialCombo("First", "reaction-first", selectedRule.first,
|
||||
[](auto& rule, auto value) { rule.first = value; }));
|
||||
reactionEditor.push_back(materialCombo("Second", "reaction-second", selectedRule.second,
|
||||
[](auto& rule, auto value) { rule.second = value; }));
|
||||
reactionEditor.push_back(materialCombo("First result", "reaction-first-result",
|
||||
selectedRule.firstResult,
|
||||
[](auto& rule, auto value) { rule.firstResult = value; }));
|
||||
reactionEditor.push_back(materialCombo("Second result", "reaction-second-result",
|
||||
selectedRule.secondResult,
|
||||
[](auto& rule, auto value) { rule.secondResult = value; }));
|
||||
reactionEditor.push_back(integerField("Minimum temperature", "reaction-min-temperature",
|
||||
selectedRule.minimumTemperature,
|
||||
[mutateReaction](std::int16_t value) mutable {
|
||||
mutateReaction([&](auto& rule) {
|
||||
rule.minimumTemperature = value;
|
||||
});
|
||||
}));
|
||||
reactionEditor.push_back(integerField("Heat delta", "reaction-heat", selectedRule.heatDelta,
|
||||
[mutateReaction](std::int16_t value) mutable {
|
||||
mutateReaction([&](auto& rule) { rule.heatDelta = value; });
|
||||
}));
|
||||
reactionEditor.push_back(integerField("Priority", "reaction-priority", selectedRule.priority,
|
||||
[mutateReaction](std::int32_t value) mutable {
|
||||
mutateReaction([&](auto& rule) { rule.priority = value; });
|
||||
}));
|
||||
reactionEditor.push_back(button("Delete reaction", "reaction-delete", [this, reactionIndex] {
|
||||
observe(removeReaction(reactionIndex));
|
||||
}));
|
||||
}
|
||||
reactionEditor.push_back(button("Add reaction", "reaction-add", [this] {
|
||||
MaterialReactionRule rule;
|
||||
rule.id = "reaction-" + std::to_string(revision_ + 1);
|
||||
observe(addReaction(std::move(rule)));
|
||||
}));
|
||||
|
||||
std::vector<std::string> phaseNames;
|
||||
for (const auto& phase : draft_.phaseRules()) phaseNames.push_back(phase.id);
|
||||
std::vector<WidgetDesc> phaseEditor{sectionHeader("Phase transitions", "phase-heading")};
|
||||
if (!phaseNames.empty()) {
|
||||
const auto phaseIndex = selectedPhase_.value_or(0);
|
||||
const MaterialPhaseRule selectedRule = draft_.phaseRules()[phaseIndex];
|
||||
auto mutatePhase = [this, phaseIndex](auto mutation) {
|
||||
MaterialPhaseRule value = draft_.phaseRules()[phaseIndex];
|
||||
mutation(value);
|
||||
observe(replacePhase(phaseIndex, std::move(value)));
|
||||
};
|
||||
phaseEditor.push_back(combo("Rule", phaseNames, int(phaseIndex), "phase-select",
|
||||
[this](float value) {
|
||||
observe(selectPhase(std::size_t(std::max(0, int(value)))));
|
||||
}));
|
||||
phaseEditor.push_back(inputText("ID", selectedRule.id, "phase-id",
|
||||
[mutatePhase](const std::string& value) mutable {
|
||||
mutatePhase([&](auto& rule) { rule.id = value; });
|
||||
}));
|
||||
phaseEditor.push_back(combo("Source", materialNames, int(selectedRule.source), "phase-source",
|
||||
[mutatePhase](float value) mutable {
|
||||
mutatePhase([&](auto& rule) {
|
||||
rule.source = static_cast<MaterialId>(std::max(0, int(value)));
|
||||
});
|
||||
}));
|
||||
phaseEditor.push_back(combo("Result", materialNames, int(selectedRule.result), "phase-result",
|
||||
[mutatePhase](float value) mutable {
|
||||
mutatePhase([&](auto& rule) {
|
||||
rule.result = static_cast<MaterialId>(std::max(0, int(value)));
|
||||
});
|
||||
}));
|
||||
phaseEditor.push_back(combo("Direction", {"At or below", "At or above"},
|
||||
int(selectedRule.direction), "phase-direction",
|
||||
[mutatePhase](float value) mutable {
|
||||
mutatePhase([&](auto& rule) {
|
||||
rule.direction = value < 0.5f ? TemperatureDirection::AtOrBelow
|
||||
: TemperatureDirection::AtOrAbove;
|
||||
});
|
||||
}));
|
||||
phaseEditor.push_back(integerField("Threshold", "phase-threshold", selectedRule.threshold,
|
||||
[mutatePhase](std::int16_t value) mutable {
|
||||
mutatePhase([&](auto& rule) { rule.threshold = value; });
|
||||
}));
|
||||
phaseEditor.push_back(integerField("Temperature delta", "phase-temperature-delta",
|
||||
selectedRule.temperatureDelta,
|
||||
[mutatePhase](std::int16_t value) mutable {
|
||||
mutatePhase([&](auto& rule) {
|
||||
rule.temperatureDelta = value;
|
||||
});
|
||||
}));
|
||||
phaseEditor.push_back(integerField("Priority", "phase-priority", selectedRule.priority,
|
||||
[mutatePhase](std::int32_t value) mutable {
|
||||
mutatePhase([&](auto& rule) { rule.priority = value; });
|
||||
}));
|
||||
phaseEditor.push_back(button("Delete transition", "phase-delete", [this, phaseIndex] {
|
||||
observe(removePhase(phaseIndex));
|
||||
}));
|
||||
}
|
||||
phaseEditor.push_back(button("Add transition", "phase-add", [this] {
|
||||
MaterialPhaseRule rule;
|
||||
rule.id = "phase-" + std::to_string(revision_ + 1);
|
||||
observe(addPhase(std::move(rule)));
|
||||
}));
|
||||
|
||||
WidgetDesc browser = sidebar({searchField("Filter materials", {}, "material-filter"),
|
||||
scrollList("materials", std::move(materialRows), 0.0f, 28.0f)},
|
||||
"catalog-browser", 260.0f);
|
||||
WidgetDesc inspector = child(
|
||||
"catalog-inspector",
|
||||
{collapsingHeader("Material properties", std::move(materialInspector), "material-card", true),
|
||||
collapsingHeader("Reactions", std::move(reactionEditor), "reaction-section", false),
|
||||
collapsingHeader("Phase transitions", std::move(phaseEditor), "phase-section", false)});
|
||||
WidgetDesc content = splitPane(FlexDirection::Row, std::move(browser), std::move(inspector),
|
||||
0.28f, "catalog-split");
|
||||
return window("PixelWorld Material & Reaction Catalog",
|
||||
{toolbar({badge("Validated", "catalog-valid"), spacer(),
|
||||
text("Fingerprint " + std::to_string(draft_.fingerprint()),
|
||||
"catalog-fingerprint")},
|
||||
"catalog-toolbar"),
|
||||
std::move(content), statusBar({text(statusText_, "catalog-status")},
|
||||
"catalog-statusbar")},
|
||||
"pixelworld-catalog-editor");
|
||||
}
|
||||
|
||||
} // namespace eve::pixelworld_editor
|
||||
@@ -0,0 +1,141 @@
|
||||
#pragma once
|
||||
|
||||
#include "common/Result.h"
|
||||
#include "pixelworld/PixelMaterial.h"
|
||||
#include "pixelworld/PixelWorldControl.h"
|
||||
#include "ui/Widget.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace eve::pixelworld_editor {
|
||||
|
||||
/** @brief One renderer-neutral material card projected by the Catalog panel. */
|
||||
struct MaterialCard {
|
||||
eve::pixelworld::MaterialId id = eve::pixelworld::MaterialId::Air;
|
||||
std::string name;
|
||||
eve::pixelworld::MaterialState state = eve::pixelworld::MaterialState::Empty;
|
||||
std::uint32_t displayRgba = 0;
|
||||
bool selected = false;
|
||||
bool reacts = false;
|
||||
bool transitions = false;
|
||||
};
|
||||
|
||||
/** @brief Draft mutation receipt used for UI reconciliation and optimistic publication. */
|
||||
struct CatalogDraftReceipt {
|
||||
std::uint64_t revisionBefore = 0;
|
||||
std::uint64_t revisionAfter = 0;
|
||||
std::uint64_t fingerprint = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Dedicated visual material/reaction Catalog editor with transactional draft admission.
|
||||
*
|
||||
* The panel owns an immutable validated draft and never owns or retains a PixelWorld pointer.
|
||||
* Every edit reconstructs a complete candidate Catalog and publishes it only after validation.
|
||||
* `apply()` borrows the control service synchronously and reaches the runtime authority through
|
||||
* its generation-aware world registry. All methods are owner-thread-only and invoke no unknown
|
||||
* callbacks except callbacks explicitly embedded in a returned WidgetDesc tree. The panel must
|
||||
* outlive any mounted tree because those callbacks borrow `this`.
|
||||
*/
|
||||
class PixelWorldCatalogPanel {
|
||||
public:
|
||||
~PixelWorldCatalogPanel();
|
||||
PixelWorldCatalogPanel(const PixelWorldCatalogPanel&) = delete;
|
||||
PixelWorldCatalogPanel& operator=(const PixelWorldCatalogPanel&) = delete;
|
||||
PixelWorldCatalogPanel(PixelWorldCatalogPanel&& other);
|
||||
PixelWorldCatalogPanel& operator=(PixelWorldCatalogPanel&& other);
|
||||
|
||||
/** @brief Decode and validate a versioned Catalog document into a fresh panel draft. */
|
||||
[[nodiscard]] static eve::Result<PixelWorldCatalogPanel> create(std::string_view catalogJson);
|
||||
/** @brief Create a panel over the canonical built-in Catalog. */
|
||||
static PixelWorldCatalogPanel builtIn();
|
||||
|
||||
/** @brief Current validated immutable draft. */
|
||||
const eve::pixelworld::MaterialCatalog& draft() const noexcept { return draft_; }
|
||||
/** @brief Monotonic local draft revision, advanced only by accepted mutations. */
|
||||
std::uint64_t revision() const noexcept { return revision_; }
|
||||
/** @brief Selected material index, always valid for the current nonempty draft. */
|
||||
std::size_t selectedMaterialIndex() const noexcept { return selectedMaterial_; }
|
||||
/** @brief Selected reaction index when at least one binary rule exists. */
|
||||
std::optional<std::size_t> selectedReactionIndex() const noexcept { return selectedReaction_; }
|
||||
/** @brief Selected phase-rule index when at least one transition exists. */
|
||||
std::optional<std::size_t> selectedPhaseIndex() const noexcept { return selectedPhase_; }
|
||||
|
||||
/** @brief Project deterministic cards for the material browser. */
|
||||
std::vector<MaterialCard> materialCards() const;
|
||||
/** @brief Select a material by canonical index without mutating the Catalog. */
|
||||
[[nodiscard]] eve::Result<void> selectMaterial(std::size_t index);
|
||||
/** @brief Select a binary reaction rule by canonical index. */
|
||||
[[nodiscard]] eve::Result<void> selectReaction(std::size_t index);
|
||||
/** @brief Select a phase-transition rule by canonical index. */
|
||||
[[nodiscard]] eve::Result<void> selectPhase(std::size_t index);
|
||||
|
||||
/** @brief Replace one material definition after validating the entire reconstructed Catalog. */
|
||||
[[nodiscard]] eve::Result<CatalogDraftReceipt> replaceMaterial(
|
||||
eve::pixelworld::MaterialDefinition definition);
|
||||
/** @brief Replace one binary rule after validating the entire reconstructed Catalog. */
|
||||
[[nodiscard]] eve::Result<CatalogDraftReceipt> replaceReaction(
|
||||
std::size_t index, eve::pixelworld::MaterialReactionRule rule);
|
||||
/** @brief Add a binary rule and select its canonical post-validation position. */
|
||||
[[nodiscard]] eve::Result<CatalogDraftReceipt> addReaction(
|
||||
eve::pixelworld::MaterialReactionRule rule);
|
||||
/** @brief Remove one binary rule transactionally. */
|
||||
[[nodiscard]] eve::Result<CatalogDraftReceipt> removeReaction(std::size_t index);
|
||||
/** @brief Replace one phase rule after validating the entire reconstructed Catalog. */
|
||||
[[nodiscard]] eve::Result<CatalogDraftReceipt> replacePhase(
|
||||
std::size_t index, eve::pixelworld::MaterialPhaseRule rule);
|
||||
/** @brief Add a phase rule and select its canonical post-validation position. */
|
||||
[[nodiscard]] eve::Result<CatalogDraftReceipt> addPhase(
|
||||
eve::pixelworld::MaterialPhaseRule rule);
|
||||
/** @brief Remove one phase rule transactionally. */
|
||||
[[nodiscard]] eve::Result<CatalogDraftReceipt> removePhase(std::size_t index);
|
||||
|
||||
/** @brief Encode the current validated draft using the canonical versioned JSON codec. */
|
||||
[[nodiscard]] eve::Result<std::string> document() const;
|
||||
/** @brief Replace the draft from a validated document; failure leaves all panel state intact. */
|
||||
[[nodiscard]] eve::Result<CatalogDraftReceipt> loadDocument(std::string_view catalogJson);
|
||||
/** @brief Apply the draft through the canonical paused-world authority path. */
|
||||
[[nodiscard]] eve::Result<eve::pixelworld::PixelCatalogReloadReceipt> apply(
|
||||
eve::pixelworld::PixelWorldControlService& control, std::uint64_t worldId,
|
||||
std::uint64_t expectedFingerprint) const;
|
||||
|
||||
/** @brief Mount or reveal the panel in the UI ECS world and publish its current tree. */
|
||||
[[nodiscard]] eve::Result<eve::ui::UIHostHandle> open();
|
||||
/** @brief Hide the mounted panel without discarding its validated draft. */
|
||||
void close();
|
||||
/** @brief True while the panel has a live, visible UI host. */
|
||||
bool isOpen() const;
|
||||
/** @brief Reconcile the mounted host with the latest draft and selection state. */
|
||||
[[nodiscard]] eve::Result<void> refresh();
|
||||
/** @brief Mounted UI host handle, or an empty handle before open(). */
|
||||
eve::ui::UIHostHandle host() const noexcept { return host_; }
|
||||
|
||||
/** @brief Build a dedicated material browser, property inspector and rule editor visual tree. */
|
||||
eve::ui::WidgetDesc buildWidgetTree();
|
||||
/** @brief Last callback diagnostic rendered in the panel status bar. */
|
||||
const std::string& statusText() const noexcept { return statusText_; }
|
||||
|
||||
private:
|
||||
explicit PixelWorldCatalogPanel(eve::pixelworld::MaterialCatalog catalog);
|
||||
[[nodiscard]] eve::Result<CatalogDraftReceipt> commitCandidate(
|
||||
std::vector<eve::pixelworld::MaterialDefinition> definitions,
|
||||
std::vector<eve::pixelworld::MaterialReactionRule> reactions,
|
||||
std::vector<eve::pixelworld::MaterialPhaseRule> phases);
|
||||
void observe(eve::Result<CatalogDraftReceipt> result);
|
||||
void observe(eve::Result<void> result);
|
||||
|
||||
eve::pixelworld::MaterialCatalog draft_;
|
||||
std::uint64_t revision_ = 1;
|
||||
std::size_t selectedMaterial_ = 0;
|
||||
std::optional<std::size_t> selectedReaction_;
|
||||
std::optional<std::size_t> selectedPhase_;
|
||||
std::string statusText_ = "Catalog draft is valid";
|
||||
eve::ui::UIHostHandle host_{};
|
||||
};
|
||||
|
||||
} // namespace eve::pixelworld_editor
|
||||
@@ -0,0 +1,42 @@
|
||||
#include "pixelworld_editor/PixelWorldEditorModule.h"
|
||||
|
||||
#include <simplesquirrel/simplesquirrel.hpp>
|
||||
|
||||
#include <stdexcept>
|
||||
|
||||
namespace eve::pixelworld_editor {
|
||||
|
||||
Module_IMPL(PixelWorldEditorModule, new PixelWorldEditorModule());
|
||||
|
||||
eve::Result<void> PixelWorldEditorModule::openCatalog() {
|
||||
if (!panel_) panel_ = std::make_unique<PixelWorldCatalogPanel>(PixelWorldCatalogPanel::builtIn());
|
||||
auto opened = panel_->open();
|
||||
if (!opened) return eve::Result<void>::failure(opened.status());
|
||||
return eve::Result<void>::success();
|
||||
}
|
||||
|
||||
void PixelWorldEditorModule::closeCatalog() {
|
||||
if (panel_) panel_->close();
|
||||
}
|
||||
|
||||
bool PixelWorldEditorModule::isCatalogOpen() const {
|
||||
return panel_ && panel_->isOpen();
|
||||
}
|
||||
|
||||
void PixelWorldEditorModule::expose(ssq::Table& table) {
|
||||
auto module = table.addClass(name, PixelWorldEditorModule::create, false);
|
||||
expose(module);
|
||||
}
|
||||
|
||||
void PixelWorldEditorModule::expose(ssq::Class& cls) {
|
||||
cls.addFunc("openCatalog", [](PixelWorldEditorModule* self) {
|
||||
auto result = self->openCatalog();
|
||||
if (!result) throw std::runtime_error(result.status().describe());
|
||||
});
|
||||
cls.addFunc("closeCatalog", [](PixelWorldEditorModule* self) { self->closeCatalog(); });
|
||||
cls.addFunc("isCatalogOpen", [](PixelWorldEditorModule* self) {
|
||||
return self->isCatalogOpen();
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace eve::pixelworld_editor
|
||||
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
|
||||
#include "common/Module.h"
|
||||
#include "pixelworld_editor/PixelWorldCatalogPanel.h"
|
||||
|
||||
#include <memory>
|
||||
|
||||
namespace eve::pixelworld_editor {
|
||||
|
||||
/** @brief Script-facing owner for the dedicated PixelWorld Catalog panel. */
|
||||
class PixelWorldEditorModule final : public eve::Module {
|
||||
public:
|
||||
Module_REG(PixelWorldEditorModule);
|
||||
|
||||
/** @brief Destroy the panel before the module and its script VM are released. */
|
||||
~PixelWorldEditorModule() override = default;
|
||||
|
||||
/** @brief Open the built-in Catalog draft panel, creating it on first use. */
|
||||
[[nodiscard]] eve::Result<void> openCatalog();
|
||||
/** @brief Hide the Catalog panel while preserving its draft. */
|
||||
void closeCatalog();
|
||||
/** @brief Report whether the Catalog panel is currently visible. */
|
||||
bool isCatalogOpen() const;
|
||||
|
||||
private:
|
||||
std::unique_ptr<PixelWorldCatalogPanel> panel_;
|
||||
};
|
||||
|
||||
} // namespace eve::pixelworld_editor
|
||||
@@ -0,0 +1,248 @@
|
||||
#include "pixelworld_graphics/PixelWorldGraphics.h"
|
||||
|
||||
#include "common/Exception.h"
|
||||
#include "graphics/Graphics.h"
|
||||
#include "pixelworld/PixelWorld.h"
|
||||
#include "pixelworld/PixelWorldControl.h"
|
||||
|
||||
#include <simplesquirrel/simplesquirrel.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <functional>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace eve::pixelworld_graphics {
|
||||
|
||||
namespace {
|
||||
|
||||
using Rgba = std::array<std::uint8_t, 4>;
|
||||
|
||||
Rgba color(const pixelworld::PixelWorld& world, pixelworld::MaterialId material) noexcept {
|
||||
const std::uint32_t rgba = world.materialDisplayRgba(material);
|
||||
return {std::uint8_t(rgba >> 24U), std::uint8_t(rgba >> 16U),
|
||||
std::uint8_t(rgba >> 8U), std::uint8_t(rgba)};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
struct PixelWorldAtlasRenderer::Impl {
|
||||
int originX = 0;
|
||||
int originY = 0;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
std::vector<std::uint8_t> rgba;
|
||||
graphics::Texture* texture = nullptr;
|
||||
std::uint64_t renderedRevision = 0;
|
||||
std::uint64_t totalUploadedChunks = 0;
|
||||
std::uint64_t uploadCount = 0;
|
||||
bool initialized = false;
|
||||
};
|
||||
|
||||
PixelWorldAtlasRenderer::PixelWorldAtlasRenderer(int originX, int originY, int width, int height)
|
||||
: impl_(std::make_unique<Impl>()) {
|
||||
if (width <= 0 || height <= 0 || width > 8192 || height > 8192)
|
||||
throw eve::Exception("PixelWorld atlas size must be within 1..8192");
|
||||
impl_->originX = originX;
|
||||
impl_->originY = originY;
|
||||
impl_->width = width;
|
||||
impl_->height = height;
|
||||
impl_->rgba.resize(std::size_t(width) * std::size_t(height) * 4U, 0);
|
||||
}
|
||||
|
||||
PixelWorldAtlasRenderer::~PixelWorldAtlasRenderer() = default;
|
||||
PixelWorldAtlasRenderer::PixelWorldAtlasRenderer(PixelWorldAtlasRenderer&&) noexcept = default;
|
||||
PixelWorldAtlasRenderer& PixelWorldAtlasRenderer::operator=(PixelWorldAtlasRenderer&&) noexcept = default;
|
||||
|
||||
int PixelWorldAtlasRenderer::sync(pixelworld::PixelWorld* world, graphics::Graphics* graphics) {
|
||||
if (!world || !graphics) throw eve::Exception("PixelWorldAtlasRenderer::sync requires world and graphics");
|
||||
|
||||
const std::uint64_t worldRevision = world->revision();
|
||||
const bool rolledBack = impl_->initialized && worldRevision < impl_->renderedRevision;
|
||||
if (rolledBack) {
|
||||
std::fill(impl_->rgba.begin(), impl_->rgba.end(), std::uint8_t(0));
|
||||
impl_->renderedRevision = 0;
|
||||
}
|
||||
|
||||
auto chunks = world->snapshotChangedChunks(impl_->initialized && !rolledBack
|
||||
? impl_->renderedRevision
|
||||
: 0);
|
||||
if (impl_->initialized && !rolledBack && chunks.empty()) {
|
||||
impl_->renderedRevision = worldRevision;
|
||||
return 0;
|
||||
}
|
||||
|
||||
struct DirtyRect {
|
||||
int x;
|
||||
int y;
|
||||
int width;
|
||||
int height;
|
||||
};
|
||||
std::vector<DirtyRect> dirtyRects;
|
||||
dirtyRects.reserve(chunks.size());
|
||||
for (const auto& chunk : chunks) {
|
||||
const int chunkOriginX = chunk.x * pixelworld::kPixelChunkSize;
|
||||
const int chunkOriginY = chunk.y * pixelworld::kPixelChunkSize;
|
||||
const int minX = std::max(chunkOriginX, impl_->originX);
|
||||
const int minY = std::max(chunkOriginY, impl_->originY);
|
||||
const int maxX = std::min(chunkOriginX + pixelworld::kPixelChunkSize,
|
||||
impl_->originX + impl_->width);
|
||||
const int maxY = std::min(chunkOriginY + pixelworld::kPixelChunkSize,
|
||||
impl_->originY + impl_->height);
|
||||
if (minX >= maxX || minY >= maxY) continue;
|
||||
for (int worldY = minY; worldY < maxY; ++worldY) {
|
||||
for (int worldX = minX; worldX < maxX; ++worldX) {
|
||||
const int chunkX = worldX - chunkOriginX;
|
||||
const int chunkY = worldY - chunkOriginY;
|
||||
const Rgba pixel = chunk.removed
|
||||
? Rgba{0, 0, 0, 0}
|
||||
: color(*world, chunk.cells[std::size_t(chunkY) *
|
||||
pixelworld::kPixelChunkSize +
|
||||
std::size_t(chunkX)]
|
||||
.material);
|
||||
const std::size_t atlasOffset =
|
||||
(std::size_t(worldY - impl_->originY) * std::size_t(impl_->width) +
|
||||
std::size_t(worldX - impl_->originX)) * 4U;
|
||||
std::copy(pixel.begin(), pixel.end(), impl_->rgba.begin() + std::ptrdiff_t(atlasOffset));
|
||||
}
|
||||
}
|
||||
dirtyRects.push_back({minX - impl_->originX, minY - impl_->originY,
|
||||
maxX - minX, maxY - minY});
|
||||
}
|
||||
|
||||
const bool creatingTexture = !impl_->texture;
|
||||
if (creatingTexture) {
|
||||
impl_->texture = graphics->newTexture(impl_->width, impl_->height, impl_->rgba.data(), false, false);
|
||||
graphics->setTextureSampler(impl_->texture, "nearest", "none", 1.0F, 0.0F);
|
||||
} else if (rolledBack) {
|
||||
graphics->updateTextureRegion(impl_->texture, 0, 0, impl_->width, impl_->height,
|
||||
impl_->rgba).expect("PixelWorld atlas reset upload failed");
|
||||
} else if (!dirtyRects.empty()) {
|
||||
const std::size_t atlasStride = std::size_t(impl_->width) * 4U;
|
||||
std::vector<graphics::TextureRegionUpload> uploads;
|
||||
uploads.reserve(dirtyRects.size());
|
||||
for (const DirtyRect rect : dirtyRects) {
|
||||
const std::size_t offset =
|
||||
std::size_t(rect.y) * atlasStride + std::size_t(rect.x) * 4U;
|
||||
uploads.push_back({rect.x, rect.y, rect.width, rect.height,
|
||||
std::span<const std::uint8_t>(impl_->rgba).subspan(offset),
|
||||
atlasStride});
|
||||
}
|
||||
graphics->updateTextureRegions(impl_->texture, uploads)
|
||||
.expect("PixelWorld dirty chunk upload failed");
|
||||
}
|
||||
impl_->initialized = true;
|
||||
impl_->renderedRevision = worldRevision;
|
||||
impl_->totalUploadedChunks += dirtyRects.size();
|
||||
if (creatingTexture || rolledBack || !dirtyRects.empty()) ++impl_->uploadCount;
|
||||
return int(dirtyRects.size());
|
||||
}
|
||||
|
||||
int PixelWorldAtlasRenderer::drawDiagnostics(pixelworld::PixelWorld* world,
|
||||
graphics::Graphics* graphics, float scale) {
|
||||
if (!world || !graphics || scale <= 0.0F)
|
||||
throw eve::Exception("PixelWorldAtlasRenderer::drawDiagnostics requires world, graphics and positive scale");
|
||||
const int minChunkX = impl_->originX >= 0
|
||||
? impl_->originX / pixelworld::kPixelChunkSize
|
||||
: -((-impl_->originX + pixelworld::kPixelChunkSize - 1) /
|
||||
pixelworld::kPixelChunkSize);
|
||||
const int minChunkY = impl_->originY >= 0
|
||||
? impl_->originY / pixelworld::kPixelChunkSize
|
||||
: -((-impl_->originY + pixelworld::kPixelChunkSize - 1) /
|
||||
pixelworld::kPixelChunkSize);
|
||||
const int lastX = impl_->originX + impl_->width - 1;
|
||||
const int lastY = impl_->originY + impl_->height - 1;
|
||||
const int maxChunkX = lastX >= 0 ? lastX / pixelworld::kPixelChunkSize
|
||||
: -((-lastX + pixelworld::kPixelChunkSize - 1) /
|
||||
pixelworld::kPixelChunkSize);
|
||||
const int maxChunkY = lastY >= 0 ? lastY / pixelworld::kPixelChunkSize
|
||||
: -((-lastY + pixelworld::kPixelChunkSize - 1) /
|
||||
pixelworld::kPixelChunkSize);
|
||||
const auto diagnostics = world->chunkDiagnostics({minChunkX, minChunkY, maxChunkX, maxChunkY})
|
||||
.expect("PixelWorld diagnostic overlay region is bounded");
|
||||
for (const auto& chunk : diagnostics) {
|
||||
const float x = float(chunk.x * pixelworld::kPixelChunkSize - impl_->originX) * scale;
|
||||
const float y = float(chunk.y * pixelworld::kPixelChunkSize - impl_->originY) * scale;
|
||||
const float size = float(pixelworld::kPixelChunkSize) * scale;
|
||||
const float heat = std::clamp((float(chunk.maximumTemperature) - 20.0F) / 1000.0F,
|
||||
0.0F, 1.0F);
|
||||
const float red = heat;
|
||||
const float green = chunk.active ? 0.95F : 0.25F;
|
||||
const float blue = chunk.active ? 0.25F : 0.95F;
|
||||
graphics->drawSolidRectRGBA(x, y, size, 1.0F, red, green, blue, 0.9F);
|
||||
graphics->drawSolidRectRGBA(x, y + size - 1.0F, size, 1.0F, red, green, blue, 0.9F);
|
||||
graphics->drawSolidRectRGBA(x, y, 1.0F, size, red, green, blue, 0.9F);
|
||||
graphics->drawSolidRectRGBA(x + size - 1.0F, y, 1.0F, size, red, green, blue, 0.9F);
|
||||
}
|
||||
|
||||
const auto samples = pixelworld::pixelWorldControlService()
|
||||
.performanceSamples(world->worldLink().world, 60)
|
||||
.expect("live PixelWorld has performance samples");
|
||||
constexpr float graphWidth = 124.0F;
|
||||
constexpr float graphHeight = 42.0F;
|
||||
const float graphX = float(impl_->width) * scale - graphWidth - 8.0F;
|
||||
const float graphY = 8.0F;
|
||||
graphics->drawSolidRectRGBA(graphX, graphY, graphWidth, graphHeight,
|
||||
0.015F, 0.02F, 0.035F, 0.82F);
|
||||
std::uint64_t maximum = 1;
|
||||
for (const auto& sample : samples) maximum = std::max(maximum, sample.elapsedMicroseconds);
|
||||
const float barWidth = 2.0F;
|
||||
const std::size_t visible = std::min<std::size_t>(samples.size(), 60);
|
||||
for (std::size_t index = 0; index < visible; ++index) {
|
||||
const auto& sample = samples[samples.size() - visible + index];
|
||||
const float height = std::max(1.0F, float(sample.elapsedMicroseconds) / float(maximum) *
|
||||
(graphHeight - 6.0F));
|
||||
graphics->drawSolidRectRGBA(graphX + 2.0F + float(index) * barWidth,
|
||||
graphY + graphHeight - 2.0F - height,
|
||||
1.0F, height, 0.25F, 0.9F, 1.0F, 0.95F);
|
||||
}
|
||||
return int(diagnostics.size());
|
||||
}
|
||||
|
||||
graphics::Texture* PixelWorldAtlasRenderer::texture() const noexcept { return impl_->texture; }
|
||||
int PixelWorldAtlasRenderer::originX() const noexcept { return impl_->originX; }
|
||||
int PixelWorldAtlasRenderer::originY() const noexcept { return impl_->originY; }
|
||||
int PixelWorldAtlasRenderer::width() const noexcept { return impl_->width; }
|
||||
int PixelWorldAtlasRenderer::height() const noexcept { return impl_->height; }
|
||||
std::uint64_t PixelWorldAtlasRenderer::renderedRevision() const noexcept { return impl_->renderedRevision; }
|
||||
std::uint64_t PixelWorldAtlasRenderer::totalUploadedChunks() const noexcept { return impl_->totalUploadedChunks; }
|
||||
std::uint64_t PixelWorldAtlasRenderer::uploadCount() const noexcept { return impl_->uploadCount; }
|
||||
|
||||
Module_IMPL(PixelWorldGraphics, new PixelWorldGraphics());
|
||||
|
||||
PixelWorldAtlasRenderer* PixelWorldGraphics::newRenderer(int originX, int originY, int width, int height) {
|
||||
return new PixelWorldAtlasRenderer(originX, originY, width, height);
|
||||
}
|
||||
|
||||
void PixelWorldGraphics::expose(ssq::Table& table) {
|
||||
auto module = table.addClass(name, PixelWorldGraphics::create, false);
|
||||
module.addFunc("newRenderer", &PixelWorldGraphics::newRenderer);
|
||||
auto renderer = table.addClass<PixelWorldAtlasRenderer>(
|
||||
"PixelWorldAtlasRenderer",
|
||||
std::function<PixelWorldAtlasRenderer*()>([]() -> PixelWorldAtlasRenderer* { return nullptr; }),
|
||||
true);
|
||||
renderer.addFunc("sync", &PixelWorldAtlasRenderer::sync);
|
||||
renderer.addFunc("drawDiagnostics", &PixelWorldAtlasRenderer::drawDiagnostics);
|
||||
renderer.addFunc("getTexture", [](PixelWorldAtlasRenderer* self) { return self->texture(); });
|
||||
renderer.addFunc("getOriginX", [](PixelWorldAtlasRenderer* self) { return self->originX(); });
|
||||
renderer.addFunc("getOriginY", [](PixelWorldAtlasRenderer* self) { return self->originY(); });
|
||||
renderer.addFunc("getWidth", [](PixelWorldAtlasRenderer* self) { return self->width(); });
|
||||
renderer.addFunc("getHeight", [](PixelWorldAtlasRenderer* self) { return self->height(); });
|
||||
renderer.addFunc("getRenderedRevision", [](PixelWorldAtlasRenderer* self) {
|
||||
return std::int64_t(self->renderedRevision());
|
||||
});
|
||||
renderer.addFunc("getTotalUploadedChunks", [](PixelWorldAtlasRenderer* self) {
|
||||
return std::int64_t(self->totalUploadedChunks());
|
||||
});
|
||||
renderer.addFunc("getUploadCount", [](PixelWorldAtlasRenderer* self) {
|
||||
return std::int64_t(self->uploadCount());
|
||||
});
|
||||
}
|
||||
|
||||
void PixelWorldGraphics::expose(ssq::Class& cls) {
|
||||
cls.addFunc("newRenderer", &PixelWorldGraphics::newRenderer);
|
||||
}
|
||||
|
||||
} // namespace eve::pixelworld_graphics
|
||||
@@ -0,0 +1,92 @@
|
||||
#pragma once
|
||||
|
||||
#include "common/Module.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
|
||||
namespace eve::graphics {
|
||||
class Graphics;
|
||||
class Texture;
|
||||
} // namespace eve::graphics
|
||||
|
||||
namespace eve::pixelworld {
|
||||
class PixelWorld;
|
||||
} // namespace eve::pixelworld
|
||||
|
||||
namespace eve::pixelworld_graphics {
|
||||
|
||||
/**
|
||||
* @brief Render-thread-affine RGBA atlas projection of a rectangular PixelWorld region.
|
||||
* @ownership The renderer owns only its CPU atlas; Graphics owns the Texture handle.
|
||||
* @lifetime A renderer using a texture must be destroyed before its Graphics backend.
|
||||
*
|
||||
* The renderer never owns or retains the PixelWorld/Graphics arguments supplied to
|
||||
* sync(). Its Texture is a borrowed backend-owned handle and must not outlive Graphics.
|
||||
* The CPU atlas is authoritative only for presentation and is rebuilt after world
|
||||
* revision rollback (clear/restore); gameplay continues to read PixelWorld itself.
|
||||
*/
|
||||
class PixelWorldAtlasRenderer {
|
||||
public:
|
||||
/** @brief Create a transparent atlas for `[origin, origin + size)`. Throws on invalid size. */
|
||||
PixelWorldAtlasRenderer(int originX, int originY, int width, int height);
|
||||
~PixelWorldAtlasRenderer();
|
||||
PixelWorldAtlasRenderer(PixelWorldAtlasRenderer&&) noexcept;
|
||||
PixelWorldAtlasRenderer& operator=(PixelWorldAtlasRenderer&&) noexcept;
|
||||
PixelWorldAtlasRenderer(const PixelWorldAtlasRenderer&) = delete;
|
||||
PixelWorldAtlasRenderer& operator=(const PixelWorldAtlasRenderer&) = delete;
|
||||
|
||||
/**
|
||||
* @brief Patch changed CPU chunks and upload the atlas when its world revision advances.
|
||||
* @return Number of changed chunks patched; zero means no GPU upload was issued.
|
||||
* @remarks Must run on the render thread. The arguments are borrowed for this call only.
|
||||
* @ownership `world` and `graphics` remain owned by their callers and are never retained.
|
||||
* @lifetime Both arguments need only remain valid for the duration of this call.
|
||||
*/
|
||||
int sync(pixelworld::PixelWorld* world, graphics::Graphics* graphics);
|
||||
|
||||
/**
|
||||
* @brief Draw Chunk activity/temperature outlines and a bounded step-time graph.
|
||||
* @param world Borrowed authoritative world for this call only.
|
||||
* @param graphics Borrowed render-thread graphics service for this call only.
|
||||
* @param scale Screen pixels per simulated cell; must be positive.
|
||||
* @return Number of allocated Chunk diagnostics drawn.
|
||||
*/
|
||||
int drawDiagnostics(pixelworld::PixelWorld* world, graphics::Graphics* graphics,
|
||||
float scale);
|
||||
|
||||
/**
|
||||
* @brief Borrow the stable backend-owned atlas texture, or null before first sync.
|
||||
* @ownership Graphics owns the returned pointer; callers must not delete it.
|
||||
* @lifetime Valid until the originating Graphics releases textures or is destroyed.
|
||||
*/
|
||||
graphics::Texture* texture() const noexcept;
|
||||
int originX() const noexcept;
|
||||
int originY() const noexcept;
|
||||
int width() const noexcept;
|
||||
int height() const noexcept;
|
||||
std::uint64_t renderedRevision() const noexcept;
|
||||
std::uint64_t totalUploadedChunks() const noexcept;
|
||||
std::uint64_t uploadCount() const noexcept;
|
||||
|
||||
private:
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
|
||||
/** @brief Optional rendering satellite for the backend-neutral PixelWorld module. */
|
||||
class PixelWorldGraphics : public Module {
|
||||
public:
|
||||
Module_REG(PixelWorldGraphics);
|
||||
|
||||
/**
|
||||
* @brief Create a caller-owned atlas renderer for one world-space rectangle.
|
||||
* @return Newly allocated non-null renderer owned by the caller.
|
||||
* @ownership The caller must delete the returned renderer.
|
||||
* @lifetime If synchronized, the renderer must not be used after its Graphics backend.
|
||||
*/
|
||||
[[nodiscard("retain and delete the returned PixelWorldAtlasRenderer")]]
|
||||
PixelWorldAtlasRenderer* newRenderer(int originX, int originY, int width, int height);
|
||||
};
|
||||
|
||||
} // namespace eve::pixelworld_graphics
|
||||
@@ -0,0 +1,627 @@
|
||||
#include "pixelworld_physics/PixelWorldPhysics.h"
|
||||
|
||||
#include "common/Exception.h"
|
||||
#include "physics/Body.h"
|
||||
#include "physics/World.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <string>
|
||||
|
||||
namespace eve::pixelworld_physics {
|
||||
namespace {
|
||||
|
||||
template <class T>
|
||||
eve::Result<T> failure(eve::DiagnosticCode code, std::string message, std::string path) {
|
||||
return eve::Result<T>::failure(eve::Diagnostic::error(
|
||||
code, std::move(message), std::move(path), {}, "pixelworld_physics"));
|
||||
}
|
||||
|
||||
std::vector<FragmentCollisionRect> decompose(const eve::pixelworld::PixelFragment& fragment) {
|
||||
const int width = fragment.width, height = fragment.height;
|
||||
std::vector<std::uint8_t> consumed(std::size_t(width) * std::size_t(height));
|
||||
std::vector<FragmentCollisionRect> result;
|
||||
const auto occupied = [&](int x, int y) {
|
||||
return fragment.cells[std::size_t(y) * std::size_t(width) + std::size_t(x)].material !=
|
||||
eve::pixelworld::MaterialId::Air;
|
||||
};
|
||||
for (int y = 0; y < height; ++y)
|
||||
for (int x = 0; x < width; ++x) {
|
||||
const std::size_t start = std::size_t(y) * std::size_t(width) + std::size_t(x);
|
||||
if (consumed[start] || !occupied(x, y)) continue;
|
||||
int rectWidth = 1;
|
||||
while (x + rectWidth < width && !consumed[start + std::size_t(rectWidth)] &&
|
||||
occupied(x + rectWidth, y))
|
||||
++rectWidth;
|
||||
int rectHeight = 1;
|
||||
for (; y + rectHeight < height; ++rectHeight) {
|
||||
bool complete = true;
|
||||
for (int ox = 0; ox < rectWidth; ++ox) {
|
||||
const std::size_t index = std::size_t(y + rectHeight) * std::size_t(width) +
|
||||
std::size_t(x + ox);
|
||||
if (consumed[index] || !occupied(x + ox, y + rectHeight)) {
|
||||
complete = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!complete) break;
|
||||
}
|
||||
for (int oy = 0; oy < rectHeight; ++oy)
|
||||
for (int ox = 0; ox < rectWidth; ++ox)
|
||||
consumed[std::size_t(y + oy) * std::size_t(width) + std::size_t(x + ox)] = 1;
|
||||
result.push_back({x, y, rectWidth, rectHeight});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
eve::pixelworld::PixelFragment rotated(const eve::pixelworld::PixelFragment& source, int turns) {
|
||||
turns = ((turns % 4) + 4) % 4;
|
||||
eve::pixelworld::PixelFragment result = source;
|
||||
if (turns == 0) return result;
|
||||
result.width = (turns % 2 == 0) ? source.width : source.height;
|
||||
result.height = (turns % 2 == 0) ? source.height : source.width;
|
||||
result.cells.assign(std::size_t(result.width) * std::size_t(result.height), {});
|
||||
for (int y = 0; y < source.height; ++y)
|
||||
for (int x = 0; x < source.width; ++x) {
|
||||
int rx = 0, ry = 0;
|
||||
if (turns == 1) {
|
||||
rx = source.height - 1 - y;
|
||||
ry = x;
|
||||
} else if (turns == 2) {
|
||||
rx = source.width - 1 - x;
|
||||
ry = source.height - 1 - y;
|
||||
} else {
|
||||
rx = y;
|
||||
ry = source.width - 1 - x;
|
||||
}
|
||||
result.cells[std::size_t(ry) * std::size_t(result.width) + std::size_t(rx)] =
|
||||
source.cells[std::size_t(y) * std::size_t(source.width) + std::size_t(x)];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
struct GridPoint {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
auto operator<=>(const GridPoint&) const = default;
|
||||
};
|
||||
|
||||
struct GridEdge {
|
||||
GridPoint from;
|
||||
GridPoint to;
|
||||
};
|
||||
|
||||
int direction(const GridEdge& edge) {
|
||||
if (edge.to.x > edge.from.x) return 0;
|
||||
if (edge.to.y > edge.from.y) return 1;
|
||||
if (edge.to.x < edge.from.x) return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
int turnRank(int previous, int next) {
|
||||
const int turn = (next - previous + 4) % 4;
|
||||
if (turn == 1) return 0;
|
||||
if (turn == 0) return 1;
|
||||
if (turn == 3) return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
bool collinear(GridPoint a, GridPoint b, GridPoint c) {
|
||||
return (b.x - a.x) * (c.y - b.y) == (b.y - a.y) * (c.x - b.x);
|
||||
}
|
||||
|
||||
bool validCellBounds(float minimum, float maximum) {
|
||||
return std::isfinite(minimum) && std::isfinite(maximum) &&
|
||||
double(minimum) >= double(std::numeric_limits<int>::min()) + 2.0 &&
|
||||
double(maximum) <= double(std::numeric_limits<int>::max()) - 2.0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
eve::Result<std::vector<TerrainCollisionContour>> extractTerrainContours(
|
||||
const eve::pixelworld::PixelWorld& pixelWorld, int chunkX, int chunkY,
|
||||
std::uint32_t maximumVertices) {
|
||||
if (maximumVertices < 2)
|
||||
return failure<std::vector<TerrainCollisionContour>>(
|
||||
eve::DiagnosticCode::InvalidArgument, "maximumVertices must be at least two",
|
||||
"maximumVertices");
|
||||
constexpr int size = eve::pixelworld::kPixelChunkSize;
|
||||
const std::int64_t wideOriginX = std::int64_t(chunkX) * size;
|
||||
const std::int64_t wideOriginY = std::int64_t(chunkY) * size;
|
||||
if (wideOriginX < std::numeric_limits<int>::min() ||
|
||||
wideOriginX + size > std::numeric_limits<int>::max() ||
|
||||
wideOriginY < std::numeric_limits<int>::min() ||
|
||||
wideOriginY + size > std::numeric_limits<int>::max())
|
||||
return failure<std::vector<TerrainCollisionContour>>(
|
||||
eve::DiagnosticCode::InvalidArgument, "Chunk coordinates exceed world coordinate range",
|
||||
"chunk");
|
||||
const int originX = int(wideOriginX), originY = int(wideOriginY);
|
||||
const auto solid = [&](int localX, int localY) {
|
||||
return pixelWorld.isSolidMaterial(
|
||||
pixelWorld.getCell(originX + localX, originY + localY).material);
|
||||
};
|
||||
std::vector<GridEdge> edges;
|
||||
edges.reserve(size * 4);
|
||||
for (int y = 0; y < size; ++y)
|
||||
for (int x = 0; x < size; ++x) {
|
||||
if (!solid(x, y)) continue;
|
||||
if (!solid(x, y - 1)) edges.push_back({{x, y}, {x + 1, y}});
|
||||
if (!solid(x + 1, y)) edges.push_back({{x + 1, y}, {x + 1, y + 1}});
|
||||
if (!solid(x, y + 1)) edges.push_back({{x + 1, y + 1}, {x, y + 1}});
|
||||
if (!solid(x - 1, y)) edges.push_back({{x, y + 1}, {x, y}});
|
||||
}
|
||||
std::map<GridPoint, std::vector<std::size_t>> outgoing;
|
||||
for (std::size_t index = 0; index < edges.size(); ++index)
|
||||
outgoing[edges[index].from].push_back(index);
|
||||
std::vector<std::uint8_t> used(edges.size());
|
||||
std::vector<TerrainCollisionContour> contours;
|
||||
std::uint64_t vertexCount = 0;
|
||||
for (std::size_t first = 0; first < edges.size(); ++first) {
|
||||
if (used[first]) continue;
|
||||
std::vector<GridPoint> points{edges[first].from};
|
||||
std::size_t current = first;
|
||||
bool loop = false;
|
||||
while (true) {
|
||||
used[current] = 1;
|
||||
const GridPoint end = edges[current].to;
|
||||
if (end == points.front()) {
|
||||
loop = true;
|
||||
break;
|
||||
}
|
||||
points.push_back(end);
|
||||
const auto found = outgoing.find(end);
|
||||
if (found == outgoing.end()) break;
|
||||
std::size_t next = edges.size();
|
||||
int bestRank = 5;
|
||||
for (const std::size_t candidate : found->second) {
|
||||
if (used[candidate]) continue;
|
||||
const int rank = turnRank(direction(edges[current]), direction(edges[candidate]));
|
||||
if (rank < bestRank || (rank == bestRank && candidate < next)) {
|
||||
bestRank = rank;
|
||||
next = candidate;
|
||||
}
|
||||
}
|
||||
if (next == edges.size()) break;
|
||||
current = next;
|
||||
}
|
||||
bool changed = true;
|
||||
while (changed && points.size() > (loop ? 3U : 2U)) {
|
||||
changed = false;
|
||||
for (std::size_t index = 0; index < points.size(); ++index) {
|
||||
if (!loop && (index == 0 || index + 1 == points.size())) continue;
|
||||
const std::size_t before = (index + points.size() - 1) % points.size();
|
||||
const std::size_t after = (index + 1) % points.size();
|
||||
if (collinear(points[before], points[index], points[after])) {
|
||||
points.erase(points.begin() + std::ptrdiff_t(index));
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (points.size() < (loop ? 3U : 2U)) continue;
|
||||
vertexCount += points.size();
|
||||
if (vertexCount > maximumVertices)
|
||||
return failure<std::vector<TerrainCollisionContour>>(
|
||||
eve::DiagnosticCode::PreconditionViolation,
|
||||
"terrain contour exceeds maximumVertices", "maximumVertices");
|
||||
TerrainCollisionContour contour;
|
||||
contour.loop = loop;
|
||||
contour.vertices.reserve(points.size() * 2);
|
||||
for (const GridPoint point : points) {
|
||||
contour.vertices.push_back(float(point.x));
|
||||
contour.vertices.push_back(float(point.y));
|
||||
}
|
||||
contours.push_back(std::move(contour));
|
||||
}
|
||||
return eve::Result<std::vector<TerrainCollisionContour>>::success(std::move(contours));
|
||||
}
|
||||
|
||||
eve::Result<PixelTerrainContact> probeTerrainCircle(
|
||||
const eve::pixelworld::PixelWorld& pixelWorld, float centerX, float centerY, float radius,
|
||||
std::uint32_t maximumCells) {
|
||||
if (!std::isfinite(centerX) || !std::isfinite(centerY) || !std::isfinite(radius) ||
|
||||
radius <= 0.f || maximumCells == 0)
|
||||
return failure<PixelTerrainContact>(eve::DiagnosticCode::InvalidArgument,
|
||||
"circle probe parameters are invalid", "probe");
|
||||
if (!validCellBounds(centerX - radius, centerX + radius) ||
|
||||
!validCellBounds(centerY - radius, centerY + radius))
|
||||
return failure<PixelTerrainContact>(eve::DiagnosticCode::InvalidArgument,
|
||||
"circle probe exceeds world coordinate range", "probe");
|
||||
const int minX = int(std::floor(centerX - radius));
|
||||
const int maxX = int(std::floor(centerX + radius));
|
||||
const int minY = int(std::floor(centerY - radius));
|
||||
const int maxY = int(std::floor(centerY + radius));
|
||||
const std::uint64_t width = std::uint64_t(std::int64_t(maxX) - minX + 1);
|
||||
const std::uint64_t height = std::uint64_t(std::int64_t(maxY) - minY + 1);
|
||||
if (width > maximumCells || height > maximumCells || width * height > maximumCells)
|
||||
return failure<PixelTerrainContact>(eve::DiagnosticCode::PreconditionViolation,
|
||||
"circle probe exceeds maximumCells", "maximumCells");
|
||||
PixelTerrainContact best;
|
||||
for (int y = minY; y <= maxY; ++y)
|
||||
for (int x = minX; x <= maxX; ++x) {
|
||||
const auto material = pixelWorld.getCell(x, y).material;
|
||||
if (!pixelWorld.isSolidMaterial(material)) continue;
|
||||
const float closestX = std::clamp(centerX, float(x), float(x + 1));
|
||||
const float closestY = std::clamp(centerY, float(y), float(y + 1));
|
||||
const float dx = centerX - closestX, dy = centerY - closestY;
|
||||
const float distanceSquared = dx * dx + dy * dy;
|
||||
if (distanceSquared >= radius * radius) continue;
|
||||
const float distance = std::sqrt(distanceSquared);
|
||||
const float depth = radius - distance;
|
||||
if (best.hit && depth < best.depth) continue;
|
||||
if (best.hit && depth == best.depth && std::pair(y, x) >= std::pair(best.cellY, best.cellX))
|
||||
continue;
|
||||
best.hit = true;
|
||||
best.material = material;
|
||||
best.cellX = x;
|
||||
best.cellY = y;
|
||||
best.pointX = closestX;
|
||||
best.pointY = closestY;
|
||||
best.depth = depth;
|
||||
if (distance > 0.f) {
|
||||
best.normalX = dx / distance;
|
||||
best.normalY = dy / distance;
|
||||
} else {
|
||||
const float left = centerX - float(x), right = float(x + 1) - centerX;
|
||||
const float top = centerY - float(y), bottom = float(y + 1) - centerY;
|
||||
const float nearest = std::min({left, right, top, bottom});
|
||||
if (nearest == left) best.normalX = -1.f;
|
||||
else if (nearest == right) best.normalX = 1.f;
|
||||
else if (nearest == top) best.normalY = -1.f;
|
||||
else best.normalY = 1.f;
|
||||
}
|
||||
}
|
||||
return eve::Result<PixelTerrainContact>::success(best);
|
||||
}
|
||||
|
||||
eve::Result<PixelTerrainContact> sweepTerrainCircle(
|
||||
const eve::pixelworld::PixelWorld& pixelWorld, float startX, float startY, float endX,
|
||||
float endY, float radius, std::uint32_t maximumCells) {
|
||||
if (!std::isfinite(startX) || !std::isfinite(startY) || !std::isfinite(endX) ||
|
||||
!std::isfinite(endY) || !std::isfinite(radius) || radius <= 0.f || maximumCells == 0)
|
||||
return failure<PixelTerrainContact>(eve::DiagnosticCode::InvalidArgument,
|
||||
"circle sweep parameters are invalid", "sweep");
|
||||
if (!validCellBounds(std::min(startX, endX) - radius,
|
||||
std::max(startX, endX) + radius) ||
|
||||
!validCellBounds(std::min(startY, endY) - radius,
|
||||
std::max(startY, endY) + radius))
|
||||
return failure<PixelTerrainContact>(eve::DiagnosticCode::InvalidArgument,
|
||||
"circle sweep exceeds world coordinate range", "sweep");
|
||||
auto initial = probeTerrainCircle(pixelWorld, startX, startY, radius, maximumCells);
|
||||
if (!initial.ok()) return initial;
|
||||
if (initial.value().hit) return initial;
|
||||
const int minX = int(std::floor(std::min(startX, endX) - radius));
|
||||
const int maxX = int(std::floor(std::max(startX, endX) + radius));
|
||||
const int minY = int(std::floor(std::min(startY, endY) - radius));
|
||||
const int maxY = int(std::floor(std::max(startY, endY) + radius));
|
||||
const std::uint64_t width = std::uint64_t(std::int64_t(maxX) - minX + 1);
|
||||
const std::uint64_t height = std::uint64_t(std::int64_t(maxY) - minY + 1);
|
||||
if (width > maximumCells || height > maximumCells || width * height > maximumCells)
|
||||
return failure<PixelTerrainContact>(eve::DiagnosticCode::PreconditionViolation,
|
||||
"circle sweep exceeds maximumCells", "maximumCells");
|
||||
const float deltaX = endX - startX, deltaY = endY - startY;
|
||||
PixelTerrainContact best;
|
||||
best.fraction = 1.f;
|
||||
for (int y = minY; y <= maxY; ++y)
|
||||
for (int x = minX; x <= maxX; ++x) {
|
||||
const auto material = pixelWorld.getCell(x, y).material;
|
||||
if (!pixelWorld.isSolidMaterial(material)) continue;
|
||||
float cellFraction = 2.f, cellNormalX = 0.f, cellNormalY = 0.f;
|
||||
const auto consider = [&](float fraction, float nx, float ny) {
|
||||
if (fraction >= 0.f && fraction <= 1.f && fraction < cellFraction) {
|
||||
cellFraction = fraction;
|
||||
cellNormalX = nx;
|
||||
cellNormalY = ny;
|
||||
}
|
||||
};
|
||||
if (deltaX > 0.f) {
|
||||
const float fraction = (float(x) - radius - startX) / deltaX;
|
||||
const float centerY = startY + deltaY * fraction;
|
||||
if (centerY >= float(y) && centerY <= float(y + 1))
|
||||
consider(fraction, -1.f, 0.f);
|
||||
} else if (deltaX < 0.f) {
|
||||
const float fraction = (float(x + 1) + radius - startX) / deltaX;
|
||||
const float centerY = startY + deltaY * fraction;
|
||||
if (centerY >= float(y) && centerY <= float(y + 1))
|
||||
consider(fraction, 1.f, 0.f);
|
||||
}
|
||||
if (deltaY > 0.f) {
|
||||
const float fraction = (float(y) - radius - startY) / deltaY;
|
||||
const float centerX = startX + deltaX * fraction;
|
||||
if (centerX >= float(x) && centerX <= float(x + 1))
|
||||
consider(fraction, 0.f, -1.f);
|
||||
} else if (deltaY < 0.f) {
|
||||
const float fraction = (float(y + 1) + radius - startY) / deltaY;
|
||||
const float centerX = startX + deltaX * fraction;
|
||||
if (centerX >= float(x) && centerX <= float(x + 1))
|
||||
consider(fraction, 0.f, 1.f);
|
||||
}
|
||||
const float quadraticA = deltaX * deltaX + deltaY * deltaY;
|
||||
if (quadraticA > 0.f) {
|
||||
struct Corner {
|
||||
float x;
|
||||
float y;
|
||||
int quadrantX;
|
||||
int quadrantY;
|
||||
};
|
||||
const Corner corners[] = {
|
||||
{float(x), float(y), -1, -1}, {float(x + 1), float(y), 1, -1},
|
||||
{float(x + 1), float(y + 1), 1, 1}, {float(x), float(y + 1), -1, 1}};
|
||||
for (const Corner corner : corners) {
|
||||
const float offsetX = startX - corner.x, offsetY = startY - corner.y;
|
||||
const float quadraticB = 2.f * (offsetX * deltaX + offsetY * deltaY);
|
||||
const float quadraticC = offsetX * offsetX + offsetY * offsetY - radius * radius;
|
||||
const float discriminant = quadraticB * quadraticB - 4.f * quadraticA * quadraticC;
|
||||
if (discriminant < 0.f) continue;
|
||||
const float fraction =
|
||||
(-quadraticB - std::sqrt(discriminant)) / (2.f * quadraticA);
|
||||
if (fraction < 0.f || fraction > 1.f) continue;
|
||||
const float hitX = startX + deltaX * fraction;
|
||||
const float hitY = startY + deltaY * fraction;
|
||||
if ((corner.quadrantX < 0 && hitX > corner.x) ||
|
||||
(corner.quadrantX > 0 && hitX < corner.x) ||
|
||||
(corner.quadrantY < 0 && hitY > corner.y) ||
|
||||
(corner.quadrantY > 0 && hitY < corner.y))
|
||||
continue;
|
||||
consider(fraction, (hitX - corner.x) / radius,
|
||||
(hitY - corner.y) / radius);
|
||||
}
|
||||
}
|
||||
if (cellFraction > 1.f) continue;
|
||||
if (best.hit && cellFraction > best.fraction) continue;
|
||||
if (best.hit && cellFraction == best.fraction &&
|
||||
std::pair(y, x) >= std::pair(best.cellY, best.cellX))
|
||||
continue;
|
||||
best.hit = true;
|
||||
best.material = material;
|
||||
best.cellX = x;
|
||||
best.cellY = y;
|
||||
best.fraction = cellFraction;
|
||||
best.pointX = startX + deltaX * cellFraction;
|
||||
best.pointY = startY + deltaY * cellFraction;
|
||||
best.normalX = cellNormalX;
|
||||
best.normalY = cellNormalY;
|
||||
}
|
||||
if (!best.hit) best.fraction = 1.f;
|
||||
return eve::Result<PixelTerrainContact>::success(best);
|
||||
}
|
||||
|
||||
PixelFragmentBody::PixelFragmentBody(eve::pixelworld::PixelFragment fragment,
|
||||
eve::physics::PhysicsLink link,
|
||||
std::vector<FragmentCollisionRect> rectangles)
|
||||
: fragment_(std::move(fragment)), link_(link), rectangles_(std::move(rectangles)) {}
|
||||
|
||||
eve::Result<std::unique_ptr<PixelFragmentBody>> PixelFragmentBody::create(
|
||||
eve::physics::World& world, eve::pixelworld::PixelFragment fragment, FragmentBodyConfig config) {
|
||||
if (fragment.width <= 0 || fragment.height <= 0 || fragment.id == 0 ||
|
||||
std::uint64_t(fragment.width) * std::uint64_t(fragment.height) != fragment.cells.size())
|
||||
return failure<std::unique_ptr<PixelFragmentBody>>(
|
||||
eve::DiagnosticCode::InvalidArgument, "fragment bitmap metadata is invalid", "fragment");
|
||||
if (!std::isfinite(config.density) || config.density <= 0.f ||
|
||||
!std::isfinite(config.friction) || config.friction < 0.f ||
|
||||
!std::isfinite(config.restitution) || config.restitution < 0.f || config.maximumFixtures == 0)
|
||||
return failure<std::unique_ptr<PixelFragmentBody>>(
|
||||
eve::DiagnosticCode::InvalidArgument, "fragment body config is invalid", "config");
|
||||
auto rectangles = decompose(fragment);
|
||||
if (rectangles.empty() || rectangles.size() > config.maximumFixtures)
|
||||
return failure<std::unique_ptr<PixelFragmentBody>>(
|
||||
eve::DiagnosticCode::PreconditionViolation,
|
||||
"fragment collision decomposition is empty or exceeds maximumFixtures", "maximumFixtures");
|
||||
|
||||
eve::physics::Body* body = nullptr;
|
||||
try {
|
||||
const float centerX = float(fragment.originX) + float(fragment.width) * 0.5f;
|
||||
const float centerY = float(fragment.originY) + float(fragment.height) * 0.5f;
|
||||
body = world.newBody("dynamic", centerX, centerY);
|
||||
for (const FragmentCollisionRect& rect : rectangles) {
|
||||
const float offsetX = float(rect.x) + float(rect.width) * 0.5f - float(fragment.width) * 0.5f;
|
||||
const float offsetY = float(rect.y) + float(rect.height) * 0.5f - float(fragment.height) * 0.5f;
|
||||
body->newRectangleFixtureAt(float(rect.width), float(rect.height), offsetX, offsetY,
|
||||
config.density, config.friction, config.restitution);
|
||||
}
|
||||
auto link = eve::physics::PhysicsLink::fromBody(*body);
|
||||
if (!link.ok()) {
|
||||
body->destroy();
|
||||
return eve::Result<std::unique_ptr<PixelFragmentBody>>::failure(link.status());
|
||||
}
|
||||
return eve::Result<std::unique_ptr<PixelFragmentBody>>::success(
|
||||
std::unique_ptr<PixelFragmentBody>(new PixelFragmentBody(
|
||||
std::move(fragment), std::move(link).takeValue(), std::move(rectangles))));
|
||||
} catch (const std::exception& error) {
|
||||
if (body && body->isValid()) body->destroy();
|
||||
return failure<std::unique_ptr<PixelFragmentBody>>(
|
||||
eve::DiagnosticCode::Failed, std::string("failed to create fragment fixtures: ") + error.what(),
|
||||
"physics");
|
||||
}
|
||||
}
|
||||
|
||||
const std::vector<FragmentCollisionRect>& PixelFragmentBody::collisionRects() const noexcept {
|
||||
return rectangles_;
|
||||
}
|
||||
|
||||
std::uint64_t PixelFragmentBody::fragmentId() const noexcept { return fragment_.id; }
|
||||
eve::physics::PhysicsLink PixelFragmentBody::physicsLink() const noexcept { return link_; }
|
||||
bool PixelFragmentBody::isRasterized() const noexcept { return rasterized_; }
|
||||
|
||||
eve::Result<FragmentSettleReceipt> PixelFragmentBody::settleIfSleeping(
|
||||
eve::physics::World& physicsWorld, eve::pixelworld::PixelWorld& pixelWorld) {
|
||||
if (rasterized_ || physicsReleased_)
|
||||
return failure<FragmentSettleReceipt>(eve::DiagnosticCode::PreconditionViolation,
|
||||
"fragment body is no longer active", "state");
|
||||
auto resolved = link_.resolve(physicsWorld);
|
||||
if (!resolved.ok()) return eve::Result<FragmentSettleReceipt>::failure(resolved.status());
|
||||
eve::physics::Body* body = resolved.value();
|
||||
if (body->isAwake())
|
||||
return eve::Result<FragmentSettleReceipt>::success(
|
||||
{FragmentSettleDisposition::StillAwake, fragment_.id, 0, 0});
|
||||
|
||||
constexpr double halfPi = 1.57079632679489661923;
|
||||
const int turns = int(std::llround(double(body->getAngle()) / halfPi));
|
||||
auto candidate = rotated(fragment_, turns);
|
||||
const int originX = int(std::lround(double(body->getX()) - double(candidate.width) * 0.5));
|
||||
const int originY = int(std::lround(double(body->getY()) - double(candidate.height) * 0.5));
|
||||
auto rasterized = pixelWorld.rasterizeFragment(candidate, originX, originY);
|
||||
if (!rasterized.ok()) return eve::Result<FragmentSettleReceipt>::failure(rasterized.status());
|
||||
body->destroy();
|
||||
physicsReleased_ = true;
|
||||
rasterized_ = true;
|
||||
return eve::Result<FragmentSettleReceipt>::success(
|
||||
{FragmentSettleDisposition::Rasterized, fragment_.id,
|
||||
rasterized.value().cellsPlaced, ((turns % 4) + 4) % 4});
|
||||
}
|
||||
|
||||
eve::Result<void> PixelFragmentBody::releasePhysics(eve::physics::World& physicsWorld) {
|
||||
if (physicsReleased_) return eve::Result<void>::success();
|
||||
auto resolved = link_.resolve(physicsWorld);
|
||||
if (!resolved.ok()) return eve::Result<void>::failure(resolved.status());
|
||||
resolved.value()->destroy();
|
||||
physicsReleased_ = true;
|
||||
return eve::Result<void>::success();
|
||||
}
|
||||
|
||||
struct PixelTerrainCollisionCache::Impl {
|
||||
using Coord = std::pair<int, int>;
|
||||
std::map<Coord, eve::physics::PhysicsLink> bodies;
|
||||
eve::pixelworld::PixelWorldLink pixelWorld;
|
||||
eve::physics::PhysicsWorldHandle physicsWorld = eve::physics::PhysicsWorldHandle::invalid();
|
||||
std::uint64_t revision = 0;
|
||||
};
|
||||
|
||||
PixelTerrainCollisionCache::PixelTerrainCollisionCache() : impl_(std::make_unique<Impl>()) {}
|
||||
PixelTerrainCollisionCache::~PixelTerrainCollisionCache() = default;
|
||||
PixelTerrainCollisionCache::PixelTerrainCollisionCache(PixelTerrainCollisionCache&&) noexcept = default;
|
||||
PixelTerrainCollisionCache& PixelTerrainCollisionCache::operator=(PixelTerrainCollisionCache&&) noexcept = default;
|
||||
|
||||
eve::Result<TerrainCollisionSyncReceipt> PixelTerrainCollisionCache::sync(
|
||||
eve::physics::World& physicsWorld, const eve::pixelworld::PixelWorld& pixelWorld,
|
||||
std::uint32_t maximumFixturesPerChunk) {
|
||||
if (maximumFixturesPerChunk == 0)
|
||||
return failure<TerrainCollisionSyncReceipt>(eve::DiagnosticCode::InvalidArgument,
|
||||
"maximumFixturesPerChunk must be positive",
|
||||
"maximumFixturesPerChunk");
|
||||
const bool samePixelWorld = impl_->pixelWorld == pixelWorld.worldLink();
|
||||
const bool samePhysicsWorld = impl_->physicsWorld == physicsWorld.runtimeHandle();
|
||||
const std::uint64_t since = (samePixelWorld && samePhysicsWorld) ? impl_->revision : 0;
|
||||
const auto changed = pixelWorld.snapshotChangedChunks(since);
|
||||
std::set<Impl::Coord> rebuildCoords;
|
||||
for (const auto& chunk : changed) {
|
||||
const Impl::Coord coord{chunk.x, chunk.y};
|
||||
rebuildCoords.insert(coord);
|
||||
constexpr Impl::Coord neighbors[] = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}};
|
||||
for (const auto [dx, dy] : neighbors) {
|
||||
const Impl::Coord neighbor{chunk.x + dx, chunk.y + dy};
|
||||
if (impl_->bodies.contains(neighbor)) rebuildCoords.insert(neighbor);
|
||||
}
|
||||
}
|
||||
|
||||
struct Candidate {
|
||||
Impl::Coord coord;
|
||||
eve::physics::PhysicsLink link;
|
||||
std::uint32_t fixtures = 0;
|
||||
};
|
||||
std::vector<Candidate> staged;
|
||||
staged.reserve(rebuildCoords.size());
|
||||
const auto destroyStaged = [&] {
|
||||
for (const Candidate& candidate : staged) {
|
||||
auto resolved = candidate.link.resolve(physicsWorld);
|
||||
if (resolved.ok()) resolved.value()->destroy();
|
||||
}
|
||||
};
|
||||
|
||||
for (const auto& coord : rebuildCoords) {
|
||||
auto extracted = extractTerrainContours(pixelWorld, coord.first, coord.second);
|
||||
if (!extracted.ok()) {
|
||||
destroyStaged();
|
||||
return eve::Result<TerrainCollisionSyncReceipt>::failure(extracted.status());
|
||||
}
|
||||
auto contours = std::move(extracted).takeValue();
|
||||
if (contours.size() > maximumFixturesPerChunk) {
|
||||
destroyStaged();
|
||||
return failure<TerrainCollisionSyncReceipt>(
|
||||
eve::DiagnosticCode::PreconditionViolation,
|
||||
"terrain Chunk contours exceed maximumFixturesPerChunk", "chunk");
|
||||
}
|
||||
if (contours.empty()) {
|
||||
staged.push_back({coord, {}, 0});
|
||||
continue;
|
||||
}
|
||||
eve::physics::Body* body = nullptr;
|
||||
try {
|
||||
body = physicsWorld.newBody(
|
||||
"static", float(coord.first) * float(eve::pixelworld::kPixelChunkSize),
|
||||
float(coord.second) * float(eve::pixelworld::kPixelChunkSize));
|
||||
for (const TerrainCollisionContour& contour : contours)
|
||||
body->newChainFixture(contour.vertices, contour.loop, 0.5f, 0.f);
|
||||
auto link = eve::physics::PhysicsLink::fromBody(*body);
|
||||
if (!link.ok()) {
|
||||
body->destroy();
|
||||
destroyStaged();
|
||||
return eve::Result<TerrainCollisionSyncReceipt>::failure(link.status());
|
||||
}
|
||||
staged.push_back({coord, std::move(link).takeValue(),
|
||||
std::uint32_t(contours.size())});
|
||||
} catch (const std::exception& error) {
|
||||
if (body && body->isValid()) body->destroy();
|
||||
destroyStaged();
|
||||
return failure<TerrainCollisionSyncReceipt>(
|
||||
eve::DiagnosticCode::Failed,
|
||||
std::string("failed to stage terrain collision body: ") + error.what(), "physics");
|
||||
}
|
||||
}
|
||||
|
||||
TerrainCollisionSyncReceipt receipt;
|
||||
receipt.sourceRevision = pixelWorld.revision();
|
||||
receipt.chunksRebuilt = std::uint32_t(staged.size());
|
||||
if (!samePhysicsWorld) {
|
||||
impl_->bodies.clear();
|
||||
} else if (!samePixelWorld) {
|
||||
for (const auto& [coord, link] : impl_->bodies) {
|
||||
(void)coord;
|
||||
auto resolved = link.resolve(physicsWorld);
|
||||
if (resolved.ok()) resolved.value()->destroy();
|
||||
++receipt.bodiesRemoved;
|
||||
}
|
||||
impl_->bodies.clear();
|
||||
}
|
||||
for (const Candidate& candidate : staged) {
|
||||
const auto old = impl_->bodies.find(candidate.coord);
|
||||
if (old != impl_->bodies.end()) {
|
||||
auto resolved = old->second.resolve(physicsWorld);
|
||||
if (resolved.ok()) resolved.value()->destroy();
|
||||
impl_->bodies.erase(old);
|
||||
++receipt.bodiesRemoved;
|
||||
}
|
||||
if (candidate.fixtures != 0) {
|
||||
impl_->bodies.emplace(candidate.coord, candidate.link);
|
||||
receipt.fixturesCreated += candidate.fixtures;
|
||||
}
|
||||
}
|
||||
impl_->pixelWorld = pixelWorld.worldLink();
|
||||
impl_->physicsWorld = physicsWorld.runtimeHandle();
|
||||
impl_->revision = pixelWorld.revision();
|
||||
return eve::Result<TerrainCollisionSyncReceipt>::success(receipt);
|
||||
}
|
||||
|
||||
eve::Result<void> PixelTerrainCollisionCache::clearPhysics(eve::physics::World& physicsWorld) {
|
||||
if (impl_->physicsWorld.isValid() && impl_->physicsWorld != physicsWorld.runtimeHandle())
|
||||
return failure<void>(eve::DiagnosticCode::StaleHandle,
|
||||
"terrain collision cache belongs to another physics world", "physicsWorld");
|
||||
for (const auto& [coord, link] : impl_->bodies) {
|
||||
(void)coord;
|
||||
auto resolved = link.resolve(physicsWorld);
|
||||
if (resolved.ok()) resolved.value()->destroy();
|
||||
}
|
||||
impl_->bodies.clear();
|
||||
impl_->pixelWorld = {};
|
||||
impl_->physicsWorld = eve::physics::PhysicsWorldHandle::invalid();
|
||||
impl_->revision = 0;
|
||||
return eve::Result<void>::success();
|
||||
}
|
||||
|
||||
std::uint64_t PixelTerrainCollisionCache::sourceRevision() const noexcept { return impl_->revision; }
|
||||
std::size_t PixelTerrainCollisionCache::bodyCount() const noexcept { return impl_->bodies.size(); }
|
||||
|
||||
} // namespace eve::pixelworld_physics
|
||||
@@ -0,0 +1,201 @@
|
||||
#pragma once
|
||||
|
||||
#include "common/Result.h"
|
||||
#include "physics/PhysicsLink.h"
|
||||
#include "pixelworld/PixelWorld.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
namespace eve::physics {
|
||||
class World;
|
||||
}
|
||||
|
||||
namespace eve::pixelworld_physics {
|
||||
|
||||
/** @brief One deterministic axis-aligned fixture produced from a fragment bitmap. */
|
||||
struct FragmentCollisionRect {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
};
|
||||
|
||||
/** @brief Policy for dynamic fragment fixture creation and terrain settlement. */
|
||||
struct FragmentBodyConfig {
|
||||
float density = 1.0f;
|
||||
float friction = 0.4f;
|
||||
float restitution = 0.05f;
|
||||
std::uint32_t maximumFixtures = 256;
|
||||
};
|
||||
|
||||
/** @brief Observable outcome of polling a fragment body for settlement. */
|
||||
enum class FragmentSettleDisposition : std::uint8_t { StillAwake, Rasterized };
|
||||
|
||||
/** @brief Receipt for one checked fragment-body settlement poll. */
|
||||
struct FragmentSettleReceipt {
|
||||
FragmentSettleDisposition disposition = FragmentSettleDisposition::StillAwake;
|
||||
std::uint64_t fragmentId = 0;
|
||||
std::uint32_t cellsRasterized = 0;
|
||||
int quarterTurns = 0;
|
||||
};
|
||||
|
||||
/** @brief Counters for one transactional dirty-Chunk terrain collision sync. */
|
||||
struct TerrainCollisionSyncReceipt {
|
||||
std::uint64_t sourceRevision = 0;
|
||||
std::uint32_t chunksRebuilt = 0;
|
||||
std::uint32_t fixturesCreated = 0;
|
||||
std::uint32_t bodiesRemoved = 0;
|
||||
};
|
||||
|
||||
/** @brief One simplified local-space boundary contour generated for a terrain Chunk. */
|
||||
struct TerrainCollisionContour {
|
||||
/** @brief Packed local pixel-space vertices x0,y0,x1,y1,... without a repeated loop endpoint. */
|
||||
std::vector<float> vertices;
|
||||
/** @brief Whether the last vertex connects back to the first. */
|
||||
bool loop = false;
|
||||
};
|
||||
|
||||
/** @brief Deterministic contact returned by an authoritative terrain probe or sweep. */
|
||||
struct PixelTerrainContact {
|
||||
bool hit = false;
|
||||
eve::pixelworld::MaterialId material = eve::pixelworld::MaterialId::Air;
|
||||
int cellX = 0;
|
||||
int cellY = 0;
|
||||
float pointX = 0.f;
|
||||
float pointY = 0.f;
|
||||
float normalX = 0.f;
|
||||
float normalY = 0.f;
|
||||
/** @brief Probe penetration depth, or zero for a non-overlapping sweep hit. */
|
||||
float depth = 0.f;
|
||||
/** @brief Sweep time in [0,1], or zero for a probe. */
|
||||
float fraction = 0.f;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Extract simplified binary Marching-Squares-equivalent boundary chains for one Chunk.
|
||||
* @remarks Solid occupancy is sampled through PixelWorld, including the one-cell neighbor halo,
|
||||
* so shared Chunk borders do not emit internal collision edges. Output is deterministic and owning.
|
||||
*/
|
||||
[[nodiscard]] eve::Result<std::vector<TerrainCollisionContour>> extractTerrainContours(
|
||||
const eve::pixelworld::PixelWorld& pixelWorld, int chunkX, int chunkY,
|
||||
std::uint32_t maximumVertices = 4096);
|
||||
|
||||
/**
|
||||
* @brief Probe a circular character contact directly against authoritative solid material cells.
|
||||
* @param maximumCells Positive upper bound on candidate cells inspected.
|
||||
*/
|
||||
[[nodiscard]] eve::Result<PixelTerrainContact> probeTerrainCircle(
|
||||
const eve::pixelworld::PixelWorld& pixelWorld, float centerX, float centerY, float radius,
|
||||
std::uint32_t maximumCells = 4096);
|
||||
|
||||
/**
|
||||
* @brief Sweep a circular character shape continuously through authoritative solid material cells.
|
||||
* @remarks Uses an exact segment-vs-expanded-cell slab test and deterministic fraction/cell tie-breaks.
|
||||
*/
|
||||
[[nodiscard]] eve::Result<PixelTerrainContact> sweepTerrainCircle(
|
||||
const eve::pixelworld::PixelWorld& pixelWorld, float startX, float startY, float endX,
|
||||
float endY, float radius, std::uint32_t maximumCells = 16384);
|
||||
|
||||
/**
|
||||
* @brief Owning detached bitmap plus a non-owning, stale-safe dynamic body link.
|
||||
*
|
||||
* PixelWorld remains the only owner of live terrain. This adapter owns the detached
|
||||
* bitmap while Physics World owns the body and fixtures. It stores no World/Body raw
|
||||
* pointer across calls; every operation resolves `PhysicsLink` against a caller-borrowed
|
||||
* world on the owning simulation thread. No method invokes scripts or unknown callbacks.
|
||||
*/
|
||||
class PixelFragmentBody {
|
||||
public:
|
||||
~PixelFragmentBody() = default;
|
||||
PixelFragmentBody(PixelFragmentBody&&) noexcept = default;
|
||||
PixelFragmentBody& operator=(PixelFragmentBody&&) noexcept = default;
|
||||
PixelFragmentBody(const PixelFragmentBody&) = delete;
|
||||
PixelFragmentBody& operator=(const PixelFragmentBody&) = delete;
|
||||
|
||||
/**
|
||||
* @brief Create one dynamic Box2D body using deterministic greedy rectangle decomposition.
|
||||
* @param world Borrowed Physics world; it owns the created body and fixtures.
|
||||
* @param fragment Owning detached bitmap transferred into the adapter on success.
|
||||
* @param config Finite fixture properties and a positive fixture budget.
|
||||
* @return Owning adapter; failure destroys any partially created body.
|
||||
*/
|
||||
[[nodiscard]] static eve::Result<std::unique_ptr<PixelFragmentBody>> create(
|
||||
eve::physics::World& world, eve::pixelworld::PixelFragment fragment,
|
||||
FragmentBodyConfig config = {});
|
||||
|
||||
/** @brief Borrow deterministic collision rectangles for diagnostics until adapter mutation/destruction. */
|
||||
[[nodiscard]] const std::vector<FragmentCollisionRect>& collisionRects() const noexcept;
|
||||
/** @brief Detached bitmap id retained by this adapter. */
|
||||
[[nodiscard]] std::uint64_t fragmentId() const noexcept;
|
||||
/** @brief Process-local physics relationship; resolve it before every use. */
|
||||
[[nodiscard]] eve::physics::PhysicsLink physicsLink() const noexcept;
|
||||
/** @brief Whether the bitmap has already returned to authoritative terrain. */
|
||||
[[nodiscard]] bool isRasterized() const noexcept;
|
||||
|
||||
/**
|
||||
* @brief If the body sleeps, snap its bitmap to the nearest quarter turn and rasterize it.
|
||||
* @remarks Physics body destruction occurs only after PixelWorld accepts the entire bitmap.
|
||||
* A stale physics/world link or terrain conflict returns failure without changing ownership.
|
||||
*/
|
||||
[[nodiscard]] eve::Result<FragmentSettleReceipt> settleIfSleeping(
|
||||
eve::physics::World& physicsWorld, eve::pixelworld::PixelWorld& pixelWorld);
|
||||
|
||||
/**
|
||||
* @brief Explicitly destroy a still-live physics body without rasterizing its bitmap.
|
||||
* @return Success if already released; stale/foreign world links are reported.
|
||||
*/
|
||||
[[nodiscard]] eve::Result<void> releasePhysics(eve::physics::World& physicsWorld);
|
||||
|
||||
private:
|
||||
PixelFragmentBody(eve::pixelworld::PixelFragment fragment, eve::physics::PhysicsLink link,
|
||||
std::vector<FragmentCollisionRect> rectangles);
|
||||
|
||||
eve::pixelworld::PixelFragment fragment_;
|
||||
eve::physics::PhysicsLink link_;
|
||||
std::vector<FragmentCollisionRect> rectangles_;
|
||||
bool rasterized_ = false;
|
||||
bool physicsReleased_ = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Incremental static collision projection of authoritative PixelWorld chunks.
|
||||
*
|
||||
* The cache owns only process-local PhysicsLinks and revision metadata. Physics World
|
||||
* owns all bodies/fixtures and PixelWorld owns all material cells. Sync is owner-thread
|
||||
* affine, invokes no callbacks, and stages every replacement body before committing.
|
||||
*/
|
||||
class PixelTerrainCollisionCache {
|
||||
public:
|
||||
PixelTerrainCollisionCache();
|
||||
~PixelTerrainCollisionCache();
|
||||
PixelTerrainCollisionCache(PixelTerrainCollisionCache&&) noexcept;
|
||||
PixelTerrainCollisionCache& operator=(PixelTerrainCollisionCache&&) noexcept;
|
||||
PixelTerrainCollisionCache(const PixelTerrainCollisionCache&) = delete;
|
||||
PixelTerrainCollisionCache& operator=(const PixelTerrainCollisionCache&) = delete;
|
||||
|
||||
/**
|
||||
* @brief Rebuild only chunks newer than the cached source revision.
|
||||
* @param physicsWorld Borrowed owner of static bodies.
|
||||
* @param pixelWorld Borrowed authoritative material world.
|
||||
* @param maximumFixturesPerChunk Positive deterministic complexity budget.
|
||||
* @return Applied counters; failure destroys staged candidates and preserves prior cache entries.
|
||||
*/
|
||||
[[nodiscard]] eve::Result<TerrainCollisionSyncReceipt> sync(
|
||||
eve::physics::World& physicsWorld, const eve::pixelworld::PixelWorld& pixelWorld,
|
||||
std::uint32_t maximumFixturesPerChunk = 256);
|
||||
|
||||
/** @brief Explicitly destroy every resolvable projected body and reset revision tracking. */
|
||||
[[nodiscard]] eve::Result<void> clearPhysics(eve::physics::World& physicsWorld);
|
||||
/** @brief Last authoritative revision fully represented by this cache. */
|
||||
[[nodiscard]] std::uint64_t sourceRevision() const noexcept;
|
||||
/** @brief Number of chunks currently owning a non-empty static collision body. */
|
||||
[[nodiscard]] std::size_t bodyCount() const noexcept;
|
||||
|
||||
private:
|
||||
struct Impl;
|
||||
std::unique_ptr<Impl> impl_;
|
||||
};
|
||||
|
||||
} // namespace eve::pixelworld_physics
|
||||
@@ -0,0 +1,387 @@
|
||||
#include "pixelworld_replay/PixelWorldReplay.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <charconv>
|
||||
#include <cstddef>
|
||||
#include <limits>
|
||||
#include <set>
|
||||
#include <type_traits>
|
||||
|
||||
namespace eve::pixelworld_replay {
|
||||
namespace {
|
||||
|
||||
constexpr std::string_view kReplayType = "pixelworld.replay-log";
|
||||
constexpr std::uint64_t kReplaySchemaVersion = 2;
|
||||
constexpr std::size_t kMaximumReplayEntries = 1'000'000;
|
||||
constexpr std::size_t kMaximumReplayCheckpoints = 100'000;
|
||||
constexpr std::size_t kMaximumReplayChunkDigests = 1'000'000;
|
||||
|
||||
template <class T>
|
||||
eve::Result<T> fail(eve::DiagnosticCode code, std::string message, std::string path = {}) {
|
||||
return eve::Result<T>::failure(eve::Diagnostic::error(code, std::move(message), std::move(path), {},
|
||||
"pixelworld.replay"));
|
||||
}
|
||||
|
||||
eve::LogicalId replaySchema() {
|
||||
const auto parsed = eve::LogicalId::parse("pixelworld:replay-log");
|
||||
if (!parsed) std::terminate();
|
||||
return *parsed;
|
||||
}
|
||||
|
||||
eve::Result<const eve::Value::Object*> object(const eve::Value& value, std::string path,
|
||||
std::initializer_list<std::string_view> fields) {
|
||||
const auto* result = value.getIf<eve::Value::Object>();
|
||||
if (!result || result->size() != fields.size())
|
||||
return fail<const eve::Value::Object*>(eve::DiagnosticCode::ParseError,
|
||||
"replay object has unknown or missing fields", std::move(path));
|
||||
for (const auto field : fields)
|
||||
if (!result->contains(std::string(field)))
|
||||
return fail<const eve::Value::Object*>(eve::DiagnosticCode::ParseError,
|
||||
"replay object has unknown or missing fields", std::move(path));
|
||||
return eve::Result<const eve::Value::Object*>::success(result);
|
||||
}
|
||||
|
||||
eve::Result<std::uint64_t> decimal(const eve::Value& value, std::string path) {
|
||||
const auto* text = value.getIf<std::string>();
|
||||
if (!text) return fail<std::uint64_t>(eve::DiagnosticCode::ParseError, "expected decimal string", path);
|
||||
std::uint64_t result = 0;
|
||||
const auto [end, error] = std::from_chars(text->data(), text->data() + text->size(), result);
|
||||
if (error != std::errc{} || end != text->data() + text->size())
|
||||
return fail<std::uint64_t>(eve::DiagnosticCode::ParseError, "invalid decimal string", std::move(path));
|
||||
return eve::Result<std::uint64_t>::success(result);
|
||||
}
|
||||
|
||||
eve::Result<int> integer(const eve::Value& value, std::string path) {
|
||||
const auto* number = value.getIf<std::int64_t>();
|
||||
if (!number || *number < std::numeric_limits<int>::min() || *number > std::numeric_limits<int>::max())
|
||||
return fail<int>(eve::DiagnosticCode::ParseError, "expected in-range integer", std::move(path));
|
||||
return eve::Result<int>::success(static_cast<int>(*number));
|
||||
}
|
||||
|
||||
eve::Value commandValue(const eve::pixelworld::PixelEditCommand& command) {
|
||||
return eve::Value(eve::Value::Object{
|
||||
{"centerX", eve::Value(command.centerX)},
|
||||
{"centerY", eve::Value(command.centerY)},
|
||||
{"kind", eve::Value(static_cast<int>(command.kind))},
|
||||
{"material", eve::Value(static_cast<int>(command.material))},
|
||||
{"radius", eve::Value(command.radius)},
|
||||
{"sequence", eve::Value(std::to_string(command.sequence))},
|
||||
{"strength", eve::Value(command.strength)},
|
||||
{"temperatureDelta", eve::Value(int(command.temperatureDelta))},
|
||||
});
|
||||
}
|
||||
|
||||
eve::Result<eve::pixelworld::PixelEditCommand> parseCommand(const eve::Value& value, const std::string& path) {
|
||||
auto record = object(value, path, {"centerX", "centerY", "kind", "material", "radius", "sequence",
|
||||
"strength", "temperatureDelta"});
|
||||
if (!record) return eve::Result<eve::pixelworld::PixelEditCommand>::failure(record.status());
|
||||
eve::pixelworld::PixelEditCommand command;
|
||||
auto centerX = integer(record.value()->at("centerX"), path + ".centerX");
|
||||
auto centerY = integer(record.value()->at("centerY"), path + ".centerY");
|
||||
auto kind = integer(record.value()->at("kind"), path + ".kind");
|
||||
auto material = integer(record.value()->at("material"), path + ".material");
|
||||
auto radius = integer(record.value()->at("radius"), path + ".radius");
|
||||
auto sequence = decimal(record.value()->at("sequence"), path + ".sequence");
|
||||
auto strength = integer(record.value()->at("strength"), path + ".strength");
|
||||
auto temperature = integer(record.value()->at("temperatureDelta"), path + ".temperatureDelta");
|
||||
if (!centerX || !centerY || !kind || !material || !radius || !sequence || !strength || !temperature)
|
||||
return fail<eve::pixelworld::PixelEditCommand>(eve::DiagnosticCode::ParseError,
|
||||
"invalid replay command", path);
|
||||
if (kind.value() < 0 || kind.value() > static_cast<int>(eve::pixelworld::PixelEditKind::Explosion) ||
|
||||
material.value() < 0 || material.value() > std::numeric_limits<std::uint16_t>::max() ||
|
||||
temperature.value() < std::numeric_limits<std::int16_t>::min() ||
|
||||
temperature.value() > std::numeric_limits<std::int16_t>::max())
|
||||
return fail<eve::pixelworld::PixelEditCommand>(eve::DiagnosticCode::ParseError,
|
||||
"replay command enum or scalar is out of range", path);
|
||||
command.sequence = sequence.value();
|
||||
command.kind = static_cast<eve::pixelworld::PixelEditKind>(kind.value());
|
||||
command.centerX = centerX.value();
|
||||
command.centerY = centerY.value();
|
||||
command.radius = radius.value();
|
||||
command.material = static_cast<eve::pixelworld::MaterialId>(material.value());
|
||||
command.strength = strength.value();
|
||||
command.temperatureDelta = static_cast<std::int16_t>(temperature.value());
|
||||
return eve::Result<eve::pixelworld::PixelEditCommand>::success(command);
|
||||
}
|
||||
|
||||
std::uint64_t hashBytes(std::span<const std::byte> bytes) noexcept {
|
||||
std::uint64_t hash = 1469598103934665603ULL;
|
||||
for (const std::byte value : bytes) {
|
||||
hash ^= std::uint64_t(std::to_integer<std::uint8_t>(value));
|
||||
hash *= 1099511628211ULL;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
template <class T>
|
||||
void hashValue(std::uint64_t& hash, T value) noexcept {
|
||||
static_assert(std::is_integral_v<T>);
|
||||
using Unsigned = std::make_unsigned_t<T>;
|
||||
static_assert(sizeof(Unsigned) <= sizeof(std::uint64_t));
|
||||
std::uint64_t encoded = static_cast<std::uint64_t>(static_cast<Unsigned>(value));
|
||||
for (std::size_t index = 0; index < sizeof(T); ++index) {
|
||||
hash ^= encoded & 0xffULL;
|
||||
hash *= 1099511628211ULL;
|
||||
encoded >>= 8;
|
||||
}
|
||||
}
|
||||
|
||||
std::uint64_t hashChunk(const eve::pixelworld::PixelChunkSnapshot& chunk) noexcept {
|
||||
std::uint64_t hash = 1469598103934665603ULL;
|
||||
hashValue(hash, std::int32_t(chunk.x));
|
||||
hashValue(hash, std::int32_t(chunk.y));
|
||||
for (const auto cell : chunk.cells) {
|
||||
hashValue(hash, std::uint16_t(cell.material));
|
||||
hashValue(hash, cell.temperature);
|
||||
hashValue(hash, cell.lifetime);
|
||||
hashValue(hash, cell.thermalRemainder);
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
eve::Result<PixelReplayCheckpoint> checkpointFor(const eve::pixelworld::PixelWorld& world) {
|
||||
auto snapshot = world.saveSnapshot();
|
||||
if (!snapshot.ok()) return eve::Result<PixelReplayCheckpoint>::failure(snapshot.status());
|
||||
PixelReplayCheckpoint checkpoint;
|
||||
checkpoint.tick = eve::SimulationTick(world.tickValue());
|
||||
checkpoint.revision = world.revision();
|
||||
checkpoint.worldDigest = hashBytes(snapshot.value());
|
||||
for (const auto& chunk : world.snapshotChangedChunks(0))
|
||||
if (!chunk.removed)
|
||||
checkpoint.chunks.push_back({chunk.x, chunk.y, hashChunk(chunk)});
|
||||
return eve::Result<PixelReplayCheckpoint>::success(std::move(checkpoint));
|
||||
}
|
||||
|
||||
eve::Value replayPayload(const std::vector<PixelReplayEntry>& entries,
|
||||
const std::vector<PixelReplayCheckpoint>& checkpoints) {
|
||||
eve::Value::Array encodedEntries;
|
||||
encodedEntries.reserve(entries.size());
|
||||
for (const auto& entry : entries)
|
||||
encodedEntries.emplace_back(eve::Value::Object{
|
||||
{"command", commandValue(entry.command)},
|
||||
{"tick", eve::Value(std::to_string(entry.tick.value()))},
|
||||
});
|
||||
|
||||
eve::Value::Array encodedCheckpoints;
|
||||
encodedCheckpoints.reserve(checkpoints.size());
|
||||
for (const auto& checkpoint : checkpoints) {
|
||||
eve::Value::Array chunks;
|
||||
chunks.reserve(checkpoint.chunks.size());
|
||||
for (const auto& chunk : checkpoint.chunks)
|
||||
chunks.emplace_back(eve::Value::Object{
|
||||
{"digest", eve::Value(std::to_string(chunk.digest))},
|
||||
{"x", eve::Value(chunk.x)},
|
||||
{"y", eve::Value(chunk.y)},
|
||||
});
|
||||
encodedCheckpoints.emplace_back(eve::Value::Object{
|
||||
{"chunks", eve::Value(std::move(chunks))},
|
||||
{"revision", eve::Value(std::to_string(checkpoint.revision))},
|
||||
{"tick", eve::Value(std::to_string(checkpoint.tick.value()))},
|
||||
{"worldDigest", eve::Value(std::to_string(checkpoint.worldDigest))},
|
||||
});
|
||||
}
|
||||
return eve::Value(eve::Value::Object{
|
||||
{"checkpoints", eve::Value(std::move(encodedCheckpoints))},
|
||||
{"entries", eve::Value(std::move(encodedEntries))},
|
||||
});
|
||||
}
|
||||
|
||||
PixelReplayDivergence compare(const PixelReplayCheckpoint& expected,
|
||||
const PixelReplayCheckpoint& actual) {
|
||||
PixelReplayDivergence divergence;
|
||||
divergence.tick = expected.tick;
|
||||
divergence.expectedRevision = expected.revision;
|
||||
divergence.actualRevision = actual.revision;
|
||||
const std::size_t count = std::max(expected.chunks.size(), actual.chunks.size());
|
||||
for (std::size_t index = 0; index < count; ++index) {
|
||||
const bool expectedPresent = index < expected.chunks.size();
|
||||
const bool actualPresent = index < actual.chunks.size();
|
||||
if (expectedPresent && actualPresent && expected.chunks[index] == actual.chunks[index]) continue;
|
||||
if (expectedPresent) divergence.expectedChunk = expected.chunks[index];
|
||||
if (actualPresent) divergence.actualChunk = actual.chunks[index];
|
||||
break;
|
||||
}
|
||||
return divergence;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
eve::Result<void> PixelReplayLog::append(PixelReplayEntry entry) {
|
||||
if (entry.tick.value() == 0)
|
||||
return eve::Result<void>::failure(eve::Diagnostic::error(
|
||||
eve::DiagnosticCode::InvalidArgument, "replay command Tick must be positive",
|
||||
"tick", {}, "pixelworld.replay"));
|
||||
if (!entries_.empty() && entry.tick.value() < entries_.back().tick.value())
|
||||
return eve::Result<void>::failure(eve::Diagnostic::error(
|
||||
eve::DiagnosticCode::PreconditionViolation, "replay command Ticks must be nondecreasing",
|
||||
"tick", {}, "pixelworld.replay"));
|
||||
const std::uint64_t expectedSequence = entries_.empty() ? 1 : entries_.back().command.sequence + 1;
|
||||
if (entry.command.sequence != expectedSequence)
|
||||
return eve::Result<void>::failure(eve::Diagnostic::error(
|
||||
eve::DiagnosticCode::PreconditionViolation, "replay edit sequence must be contiguous",
|
||||
"command.sequence", {}, "pixelworld.replay"));
|
||||
entries_.push_back(std::move(entry));
|
||||
return eve::Result<void>::success();
|
||||
}
|
||||
|
||||
eve::Result<void> PixelReplayLog::captureCheckpoint(const eve::pixelworld::PixelWorld& world) {
|
||||
if (!checkpoints_.empty() && world.tickValue() <= checkpoints_.back().tick.value())
|
||||
return eve::Result<void>::failure(eve::Diagnostic::error(
|
||||
eve::DiagnosticCode::PreconditionViolation, "checkpoint Tick must increase",
|
||||
"tick", {}, "pixelworld.replay"));
|
||||
auto checkpoint = checkpointFor(world);
|
||||
if (!checkpoint.ok()) return eve::Result<void>::failure(checkpoint.status());
|
||||
checkpoints_.push_back(std::move(checkpoint).value());
|
||||
return eve::Result<void>::success();
|
||||
}
|
||||
|
||||
eve::Result<PixelReplayReceipt> PixelReplayLog::replay(
|
||||
eve::pixelworld::PixelWorld& target, eve::SimulationTick throughTick) const {
|
||||
if (throughTick.value() == 0 || target.tickValue() != 0 || target.chunkCount() != 0 ||
|
||||
target.lastEditSequence() != 0)
|
||||
return eve::Result<PixelReplayReceipt>::failure(eve::Diagnostic::error(
|
||||
eve::DiagnosticCode::PreconditionViolation,
|
||||
"replay requires a positive target Tick and an empty target world",
|
||||
"target", {}, "pixelworld.replay"));
|
||||
if ((!entries_.empty() && entries_.back().tick.value() > throughTick.value()) ||
|
||||
(!checkpoints_.empty() && checkpoints_.back().tick.value() > throughTick.value()))
|
||||
return eve::Result<PixelReplayReceipt>::failure(eve::Diagnostic::error(
|
||||
eve::DiagnosticCode::InvalidArgument, "throughTick does not cover the complete log",
|
||||
"throughTick", {}, "pixelworld.replay"));
|
||||
|
||||
PixelReplayReceipt receipt;
|
||||
std::size_t entryIndex = 0;
|
||||
std::size_t checkpointIndex = 0;
|
||||
for (std::uint64_t tick = 1; tick <= throughTick.value(); ++tick) {
|
||||
while (entryIndex < entries_.size() && entries_[entryIndex].tick.value() == tick) {
|
||||
auto applied = target.applyEdit(entries_[entryIndex].command);
|
||||
if (!applied.ok()) return eve::Result<PixelReplayReceipt>::failure(applied.status());
|
||||
++entryIndex;
|
||||
++receipt.commandsApplied;
|
||||
}
|
||||
auto advanced = target.advance(eve::SimulationTick(tick));
|
||||
if (!advanced.ok()) return eve::Result<PixelReplayReceipt>::failure(advanced.status());
|
||||
receipt.reachedTick = eve::SimulationTick(tick);
|
||||
if (checkpointIndex < checkpoints_.size() && checkpoints_[checkpointIndex].tick.value() == tick) {
|
||||
auto actual = checkpointFor(target);
|
||||
if (!actual.ok()) return eve::Result<PixelReplayReceipt>::failure(actual.status());
|
||||
const auto& expected = checkpoints_[checkpointIndex];
|
||||
if (actual.value().worldDigest != expected.worldDigest) {
|
||||
receipt.match = PixelReplayMatch::Diverged;
|
||||
receipt.firstDivergence = compare(expected, actual.value());
|
||||
return eve::Result<PixelReplayReceipt>::success(std::move(receipt));
|
||||
}
|
||||
++checkpointIndex;
|
||||
}
|
||||
}
|
||||
return eve::Result<PixelReplayReceipt>::success(std::move(receipt));
|
||||
}
|
||||
|
||||
eve::Result<eve::SnapshotEnvelope> PixelReplayLog::snapshot(
|
||||
eve::PersistentId instanceId, const eve::SnapshotHashProvider& hashProvider) const {
|
||||
const std::uint64_t entryTick = entries_.empty() ? 0 : entries_.back().tick.value();
|
||||
const std::uint64_t checkpointTick = checkpoints_.empty() ? 0 : checkpoints_.back().tick.value();
|
||||
return eve::makeSnapshotEnvelope(
|
||||
std::string(kReplayType), replaySchema(), eve::SchemaVersion(kReplaySchemaVersion), instanceId,
|
||||
eve::Revision(entries_.size() + checkpoints_.size()),
|
||||
eve::SimulationTick(std::max(entryTick, checkpointTick)), replayPayload(entries_, checkpoints_),
|
||||
hashProvider);
|
||||
}
|
||||
|
||||
eve::Result<void> PixelReplayLog::restoreSnapshot(
|
||||
const eve::SnapshotEnvelope& snapshot, const eve::SnapshotHashProvider& hashProvider) {
|
||||
auto verified = eve::verifySnapshotEnvelope(snapshot, hashProvider);
|
||||
if (!verified) return verified;
|
||||
if (snapshot.type != kReplayType || snapshot.schema != replaySchema())
|
||||
return fail<void>(eve::DiagnosticCode::InvalidArgument, "replay snapshot type or schema does not match",
|
||||
"schema");
|
||||
if (snapshot.schemaVersion.value() < 1 || snapshot.schemaVersion.value() > kReplaySchemaVersion)
|
||||
return fail<void>(eve::DiagnosticCode::UnknownVersion, "unsupported replay snapshot schema version",
|
||||
"schemaVersion");
|
||||
|
||||
auto root = object(snapshot.payload, "payload", {"checkpoints", "entries"});
|
||||
if (!root) return eve::Result<void>::failure(root.status());
|
||||
const auto* encodedEntries = root.value()->at("entries").getIf<eve::Value::Array>();
|
||||
const auto* encodedCheckpoints = root.value()->at("checkpoints").getIf<eve::Value::Array>();
|
||||
if (!encodedEntries || !encodedCheckpoints || encodedEntries->size() > kMaximumReplayEntries ||
|
||||
encodedCheckpoints->size() > kMaximumReplayCheckpoints)
|
||||
return fail<void>(eve::DiagnosticCode::ParseError, "replay arrays are invalid or exceed decode budget",
|
||||
"payload");
|
||||
|
||||
PixelReplayLog candidate;
|
||||
for (std::size_t index = 0; index < encodedEntries->size(); ++index) {
|
||||
const std::string path = "payload.entries[" + std::to_string(index) + "]";
|
||||
auto record = object((*encodedEntries)[index], path, {"command", "tick"});
|
||||
if (!record) return eve::Result<void>::failure(record.status());
|
||||
auto tick = decimal(record.value()->at("tick"), path + ".tick");
|
||||
auto command = parseCommand(record.value()->at("command"), path + ".command");
|
||||
if (!tick || !command)
|
||||
return fail<void>(eve::DiagnosticCode::ParseError, "invalid replay entry", path);
|
||||
auto appended = candidate.append({eve::SimulationTick(tick.value()), std::move(command).value()});
|
||||
if (!appended) return appended;
|
||||
}
|
||||
|
||||
std::size_t totalChunks = 0;
|
||||
std::uint64_t previousCheckpointTick = 0;
|
||||
for (std::size_t index = 0; index < encodedCheckpoints->size(); ++index) {
|
||||
const std::string path = "payload.checkpoints[" + std::to_string(index) + "]";
|
||||
const bool versionOne = snapshot.schemaVersion.value() == 1;
|
||||
auto record = versionOne
|
||||
? object((*encodedCheckpoints)[index], path, {"revision", "tick", "worldDigest"})
|
||||
: object((*encodedCheckpoints)[index], path,
|
||||
{"chunks", "revision", "tick", "worldDigest"});
|
||||
if (!record) return eve::Result<void>::failure(record.status());
|
||||
auto tick = decimal(record.value()->at("tick"), path + ".tick");
|
||||
auto revision = decimal(record.value()->at("revision"), path + ".revision");
|
||||
auto worldDigest = decimal(record.value()->at("worldDigest"), path + ".worldDigest");
|
||||
if (!tick || !revision || !worldDigest || tick.value() == 0 || tick.value() <= previousCheckpointTick)
|
||||
return fail<void>(eve::DiagnosticCode::ParseError, "invalid replay checkpoint", path);
|
||||
PixelReplayCheckpoint checkpoint;
|
||||
checkpoint.tick = eve::SimulationTick(tick.value());
|
||||
checkpoint.revision = revision.value();
|
||||
checkpoint.worldDigest = worldDigest.value();
|
||||
|
||||
if (!versionOne) {
|
||||
const auto* chunks = record.value()->at("chunks").getIf<eve::Value::Array>();
|
||||
if (!chunks || chunks->size() > kMaximumReplayChunkDigests - totalChunks)
|
||||
return fail<void>(eve::DiagnosticCode::ParseError,
|
||||
"replay Chunk digests exceed decode budget", path + ".chunks");
|
||||
totalChunks += chunks->size();
|
||||
for (std::size_t chunkIndex = 0; chunkIndex < chunks->size(); ++chunkIndex) {
|
||||
const std::string chunkPath = path + ".chunks[" + std::to_string(chunkIndex) + "]";
|
||||
auto chunk = object((*chunks)[chunkIndex], chunkPath, {"digest", "x", "y"});
|
||||
if (!chunk) return eve::Result<void>::failure(chunk.status());
|
||||
auto x = integer(chunk.value()->at("x"), chunkPath + ".x");
|
||||
auto y = integer(chunk.value()->at("y"), chunkPath + ".y");
|
||||
auto digest = decimal(chunk.value()->at("digest"), chunkPath + ".digest");
|
||||
if (!x || !y || !digest)
|
||||
return fail<void>(eve::DiagnosticCode::ParseError, "invalid replay Chunk digest", chunkPath);
|
||||
PixelChunkDigest decoded{x.value(), y.value(), digest.value()};
|
||||
if (!checkpoint.chunks.empty()) {
|
||||
const auto& prior = checkpoint.chunks.back();
|
||||
if (decoded.y < prior.y || (decoded.y == prior.y && decoded.x <= prior.x))
|
||||
return fail<void>(eve::DiagnosticCode::ParseError,
|
||||
"replay Chunk digests must be unique canonical order", chunkPath);
|
||||
}
|
||||
checkpoint.chunks.push_back(decoded);
|
||||
}
|
||||
}
|
||||
previousCheckpointTick = tick.value();
|
||||
candidate.checkpoints_.push_back(std::move(checkpoint));
|
||||
}
|
||||
|
||||
const std::uint64_t entryTick = candidate.entries_.empty() ? 0 : candidate.entries_.back().tick.value();
|
||||
const std::uint64_t checkpointTick = candidate.checkpoints_.empty()
|
||||
? 0
|
||||
: candidate.checkpoints_.back().tick.value();
|
||||
if (snapshot.revision.value() != candidate.entries_.size() + candidate.checkpoints_.size() ||
|
||||
snapshot.tick.value() != std::max(entryTick, checkpointTick))
|
||||
return fail<void>(eve::DiagnosticCode::Conflict,
|
||||
"replay payload count or Tick disagrees with envelope metadata", "payload");
|
||||
|
||||
entries_ = std::move(candidate.entries_);
|
||||
checkpoints_ = std::move(candidate.checkpoints_);
|
||||
return eve::Result<void>::success();
|
||||
}
|
||||
|
||||
} // namespace eve::pixelworld_replay
|
||||
@@ -0,0 +1,113 @@
|
||||
#pragma once
|
||||
|
||||
#include "common/Result.h"
|
||||
#include "common/Snapshot.h"
|
||||
#include "common/Time.h"
|
||||
#include "pixelworld/PixelWorld.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <span>
|
||||
#include <vector>
|
||||
|
||||
namespace eve::pixelworld_replay {
|
||||
|
||||
/** @brief One deterministic edit admitted immediately before its simulation Tick. */
|
||||
struct PixelReplayEntry {
|
||||
eve::SimulationTick tick = eve::SimulationTick::zero();
|
||||
eve::pixelworld::PixelEditCommand command;
|
||||
};
|
||||
|
||||
/** @brief Canonical content digest for one authoritative PixelWorld Chunk. */
|
||||
struct PixelChunkDigest {
|
||||
int x = 0;
|
||||
int y = 0;
|
||||
std::uint64_t digest = 0;
|
||||
friend bool operator==(const PixelChunkDigest&, const PixelChunkDigest&) = default;
|
||||
};
|
||||
|
||||
/** @brief Periodic replay checkpoint containing world and per-Chunk diagnostics. */
|
||||
struct PixelReplayCheckpoint {
|
||||
eve::SimulationTick tick = eve::SimulationTick::zero();
|
||||
std::uint64_t revision = 0;
|
||||
std::uint64_t worldDigest = 0;
|
||||
std::vector<PixelChunkDigest> chunks;
|
||||
};
|
||||
|
||||
/** @brief Replay verification outcome; divergence is data, not an operational failure. */
|
||||
enum class PixelReplayMatch : std::uint8_t { Matched, Diverged };
|
||||
|
||||
/** @brief First canonical mismatch found while replaying a log. */
|
||||
struct PixelReplayDivergence {
|
||||
eve::SimulationTick tick = eve::SimulationTick::zero();
|
||||
std::uint64_t expectedRevision = 0;
|
||||
std::uint64_t actualRevision = 0;
|
||||
std::optional<PixelChunkDigest> expectedChunk;
|
||||
std::optional<PixelChunkDigest> actualChunk;
|
||||
};
|
||||
|
||||
/** @brief Receipt from deterministic replay through an exact target Tick. */
|
||||
struct PixelReplayReceipt {
|
||||
PixelReplayMatch match = PixelReplayMatch::Matched;
|
||||
eve::SimulationTick reachedTick = eve::SimulationTick::zero();
|
||||
std::uint64_t commandsApplied = 0;
|
||||
std::optional<PixelReplayDivergence> firstDivergence;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Owning command log and periodic checkpoint projection for PixelWorld.
|
||||
*
|
||||
* The log is not another mutable world authority. It owns immutable replay inputs
|
||||
* and digests only. APIs are simulation-thread affine, invoke no callbacks, and
|
||||
* use bit-exact integer hashes over canonical PixelWorld bytes.
|
||||
*/
|
||||
class PixelReplayLog {
|
||||
public:
|
||||
/** @brief Append the next sequenced command; Tick order must be nondecreasing. */
|
||||
[[nodiscard]] eve::Result<void> append(PixelReplayEntry entry);
|
||||
|
||||
/**
|
||||
* @brief Capture a checkpoint from the synchronously borrowed world.
|
||||
* @remarks The world is not retained or mutated. Checkpoint Tick must increase.
|
||||
*/
|
||||
[[nodiscard]] eve::Result<void> captureCheckpoint(const eve::pixelworld::PixelWorld& world);
|
||||
|
||||
/**
|
||||
* @brief Replay into an empty target through `throughTick` and verify checkpoints.
|
||||
* @return A matched receipt or the first Tick/Chunk divergence. Malformed log or
|
||||
* non-empty target is a structured failure and leaves admission state unchanged.
|
||||
* @remarks The target is mutated after validation succeeds; operational failure
|
||||
* during replay may leave it at the last fully committed Tick.
|
||||
*/
|
||||
[[nodiscard]] eve::Result<PixelReplayReceipt> replay(
|
||||
eve::pixelworld::PixelWorld& target, eve::SimulationTick throughTick) const;
|
||||
|
||||
/**
|
||||
* @brief Seal this log in the shared versioned SnapshotEnvelope.
|
||||
* @param instanceId Stable identity of the owning replay/session.
|
||||
* @param hashProvider Injected content digest provider; no weak default is selected.
|
||||
* @return Schema `pixelworld:replay-log` version 2 with canonical owning payload.
|
||||
*/
|
||||
[[nodiscard]] eve::Result<eve::SnapshotEnvelope> snapshot(
|
||||
eve::PersistentId instanceId, const eve::SnapshotHashProvider& hashProvider) const;
|
||||
|
||||
/**
|
||||
* @brief Verify, migrate and transactionally replace this log from an envelope.
|
||||
* @param snapshot Externally owned envelope borrowed only for this call.
|
||||
* @param hashProvider Digest provider used to verify the envelope before parsing.
|
||||
* @remarks Versions 1 and 2 are accepted. Version 1 checkpoints migrate without
|
||||
* per-Chunk diagnostics. Any failure leaves entries and checkpoints unchanged.
|
||||
*/
|
||||
[[nodiscard]] eve::Result<void> restoreSnapshot(
|
||||
const eve::SnapshotEnvelope& snapshot, const eve::SnapshotHashProvider& hashProvider);
|
||||
|
||||
/** @brief Immutable owning-log views, valid until this log is mutated or destroyed. */
|
||||
[[nodiscard]] std::span<const PixelReplayEntry> entries() const noexcept { return entries_; }
|
||||
[[nodiscard]] std::span<const PixelReplayCheckpoint> checkpoints() const noexcept { return checkpoints_; }
|
||||
|
||||
private:
|
||||
std::vector<PixelReplayEntry> entries_;
|
||||
std::vector<PixelReplayCheckpoint> checkpoints_;
|
||||
};
|
||||
|
||||
} // namespace eve::pixelworld_replay
|
||||
@@ -0,0 +1,311 @@
|
||||
#include "pixelworld_streaming/PixelWorldStreaming.h"
|
||||
|
||||
#include "asset/EvpackCompression.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <bit>
|
||||
#include <charconv>
|
||||
#include <limits>
|
||||
#include <string>
|
||||
#include <type_traits>
|
||||
|
||||
namespace eve::pixelworld_streaming {
|
||||
namespace {
|
||||
|
||||
constexpr std::string_view kArchiveType = "pixelworld.chunk-batch";
|
||||
constexpr std::uint64_t kArchiveVersion = 3;
|
||||
constexpr std::size_t kMaximumArchiveChunks = 65'536;
|
||||
constexpr std::uint64_t kMaximumDecodedBytes = 512ULL * 1024ULL * 1024ULL;
|
||||
constexpr std::size_t kLegacyDecodedChunkBytes =
|
||||
std::size_t(eve::pixelworld::kPixelChunkSize * eve::pixelworld::kPixelChunkSize) * 5;
|
||||
constexpr std::size_t kDecodedChunkBytes =
|
||||
std::size_t(eve::pixelworld::kPixelChunkSize * eve::pixelworld::kPixelChunkSize) * 7;
|
||||
|
||||
template <class T>
|
||||
eve::Result<T> fail(eve::DiagnosticCode code, std::string message, std::string path = {}) {
|
||||
return eve::Result<T>::failure(eve::Diagnostic::error(
|
||||
code, std::move(message), std::move(path), {}, "pixelworld.chunk-archive"));
|
||||
}
|
||||
|
||||
eve::LogicalId archiveSchema() {
|
||||
const auto parsed = eve::LogicalId::parse("pixelworld:chunk-batch");
|
||||
if (!parsed) std::terminate();
|
||||
return *parsed;
|
||||
}
|
||||
|
||||
eve::asset::EvpackCodec assetCodec(PixelChunkArchiveCodec codec) {
|
||||
return codec == PixelChunkArchiveCodec::Zstd ? eve::asset::EvpackCodec::Zstd
|
||||
: eve::asset::EvpackCodec::None;
|
||||
}
|
||||
|
||||
std::string hex(std::span<const std::uint8_t> bytes) {
|
||||
static constexpr char digits[] = "0123456789abcdef";
|
||||
std::string result(bytes.size() * 2, '0');
|
||||
for (std::size_t index = 0; index < bytes.size(); ++index) {
|
||||
result[index * 2] = digits[bytes[index] >> 4];
|
||||
result[index * 2 + 1] = digits[bytes[index] & 0xf];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
eve::Result<std::vector<std::uint8_t>> unhex(const eve::Value& value, std::size_t maximumBytes,
|
||||
std::string path) {
|
||||
const auto* text = value.getIf<std::string>();
|
||||
if (!text || text->size() % 2 != 0 || text->size() / 2 > maximumBytes)
|
||||
return fail<std::vector<std::uint8_t>>(eve::DiagnosticCode::ParseError,
|
||||
"invalid or oversized hexadecimal payload", std::move(path));
|
||||
const auto nibble = [](char value) -> int {
|
||||
if (value >= '0' && value <= '9') return value - '0';
|
||||
if (value >= 'a' && value <= 'f') return value - 'a' + 10;
|
||||
return -1;
|
||||
};
|
||||
std::vector<std::uint8_t> result(text->size() / 2);
|
||||
for (std::size_t index = 0; index < result.size(); ++index) {
|
||||
const int high = nibble((*text)[index * 2]);
|
||||
const int low = nibble((*text)[index * 2 + 1]);
|
||||
if (high < 0 || low < 0)
|
||||
return fail<std::vector<std::uint8_t>>(eve::DiagnosticCode::ParseError,
|
||||
"invalid hexadecimal digit", std::move(path));
|
||||
result[index] = std::uint8_t((high << 4) | low);
|
||||
}
|
||||
return eve::Result<std::vector<std::uint8_t>>::success(std::move(result));
|
||||
}
|
||||
|
||||
template <class T>
|
||||
void put(std::vector<std::uint8_t>& bytes, T value) {
|
||||
using Unsigned = std::make_unsigned_t<T>;
|
||||
std::uint64_t encoded = static_cast<std::uint64_t>(static_cast<Unsigned>(value));
|
||||
for (std::size_t index = 0; index < sizeof(T); ++index) {
|
||||
bytes.push_back(std::uint8_t(encoded & 0xff));
|
||||
encoded >>= 8;
|
||||
}
|
||||
}
|
||||
|
||||
template <class T>
|
||||
T take(std::span<const std::uint8_t> bytes, std::size_t& cursor) {
|
||||
using Unsigned = std::make_unsigned_t<T>;
|
||||
Unsigned encoded = 0;
|
||||
for (std::size_t index = 0; index < sizeof(T); ++index)
|
||||
encoded |= Unsigned(bytes[cursor++]) << (index * 8);
|
||||
if constexpr (std::is_signed_v<T>) return std::bit_cast<T>(encoded);
|
||||
return encoded;
|
||||
}
|
||||
|
||||
std::vector<std::uint8_t> encodeCells(const eve::pixelworld::PixelChunkSnapshot& chunk) {
|
||||
std::vector<std::uint8_t> result;
|
||||
result.reserve(kDecodedChunkBytes);
|
||||
for (const auto cell : chunk.cells) {
|
||||
put(result, std::uint16_t(cell.material));
|
||||
put(result, cell.temperature);
|
||||
put(result, cell.lifetime);
|
||||
put(result, cell.thermalRemainder);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
eve::Result<const eve::Value::Object*> object(const eve::Value& value, std::string path,
|
||||
std::initializer_list<std::string_view> fields) {
|
||||
const auto* result = value.getIf<eve::Value::Object>();
|
||||
if (!result || result->size() != fields.size())
|
||||
return fail<const eve::Value::Object*>(eve::DiagnosticCode::ParseError,
|
||||
"archive object has unknown or missing fields", std::move(path));
|
||||
for (const auto field : fields)
|
||||
if (!result->contains(std::string(field)))
|
||||
return fail<const eve::Value::Object*>(eve::DiagnosticCode::ParseError,
|
||||
"archive object has unknown or missing fields", std::move(path));
|
||||
return eve::Result<const eve::Value::Object*>::success(result);
|
||||
}
|
||||
|
||||
eve::Result<std::uint64_t> decimal(const eve::Value& value, std::string path) {
|
||||
const auto* text = value.getIf<std::string>();
|
||||
if (!text) return fail<std::uint64_t>(eve::DiagnosticCode::ParseError, "expected decimal string", path);
|
||||
std::uint64_t result = 0;
|
||||
const auto [end, error] = std::from_chars(text->data(), text->data() + text->size(), result);
|
||||
if (error != std::errc{} || end != text->data() + text->size())
|
||||
return fail<std::uint64_t>(eve::DiagnosticCode::ParseError, "invalid decimal string", std::move(path));
|
||||
return eve::Result<std::uint64_t>::success(result);
|
||||
}
|
||||
|
||||
eve::Result<int> integer(const eve::Value& value, std::string path) {
|
||||
const auto* number = value.getIf<std::int64_t>();
|
||||
if (!number || *number < std::numeric_limits<int>::min() || *number > std::numeric_limits<int>::max())
|
||||
return fail<int>(eve::DiagnosticCode::ParseError, "expected in-range integer", std::move(path));
|
||||
return eve::Result<int>::success(int(*number));
|
||||
}
|
||||
|
||||
eve::Result<eve::ContentId> digest(const std::vector<std::uint8_t>& decoded,
|
||||
const eve::SnapshotHashProvider& hashProvider) {
|
||||
if (!hashProvider)
|
||||
return fail<eve::ContentId>(eve::DiagnosticCode::Unsupported, "archive hash provider is required");
|
||||
return hashProvider(hex(decoded));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
eve::Result<eve::SnapshotEnvelope> archiveChunkBatch(
|
||||
const eve::pixelworld::PixelChunkBatch& batch, eve::PersistentId instanceId,
|
||||
PixelChunkArchiveCodec codec, const eve::SnapshotHashProvider& hashProvider) {
|
||||
if (codec != PixelChunkArchiveCodec::None && codec != PixelChunkArchiveCodec::Zstd)
|
||||
return fail<eve::SnapshotEnvelope>(eve::DiagnosticCode::Unsupported, "unknown Chunk archive codec");
|
||||
if (batch.chunks.size() > kMaximumArchiveChunks)
|
||||
return fail<eve::SnapshotEnvelope>(eve::DiagnosticCode::InvalidArgument,
|
||||
"Chunk archive exceeds record budget", "chunks");
|
||||
eve::Value::Array records;
|
||||
std::optional<std::pair<int, int>> previous;
|
||||
for (const auto& chunk : batch.chunks) {
|
||||
const std::pair<int, int> key{chunk.y, chunk.x};
|
||||
if (previous && !(previous.value() < key))
|
||||
return fail<eve::SnapshotEnvelope>(eve::DiagnosticCode::InvalidArgument,
|
||||
"Chunks must be unique canonical y/x order", "chunks");
|
||||
previous = key;
|
||||
if (chunk.revision == 0 || chunk.revision > batch.sourceRevision ||
|
||||
(chunk.removed && !chunk.cells.empty()) ||
|
||||
(!chunk.removed && chunk.cells.size() !=
|
||||
std::size_t(eve::pixelworld::kPixelChunkSize * eve::pixelworld::kPixelChunkSize)))
|
||||
return fail<eve::SnapshotEnvelope>(eve::DiagnosticCode::InvalidArgument,
|
||||
"invalid Chunk revision or cell shape", "chunks");
|
||||
const auto decoded = chunk.removed ? std::vector<std::uint8_t>{} : encodeCells(chunk);
|
||||
auto contentHash = digest(decoded, hashProvider);
|
||||
if (!contentHash) return eve::Result<eve::SnapshotEnvelope>::failure(contentHash.status());
|
||||
const auto physical = chunk.removed ? PixelChunkArchiveCodec::None : codec;
|
||||
auto stored = eve::asset::compressEvpackChunk(assetCodec(physical), decoded);
|
||||
if (!stored) return eve::Result<eve::SnapshotEnvelope>::failure(stored.status());
|
||||
records.emplace_back(eve::Value::Object{
|
||||
{"codec", eve::Value(int(physical))},
|
||||
{"contentHash", eve::Value(contentHash.value().format())},
|
||||
{"decodedSize", eve::Value(std::to_string(decoded.size()))},
|
||||
{"removed", eve::Value(chunk.removed)},
|
||||
{"revision", eve::Value(std::to_string(chunk.revision))},
|
||||
{"stored", eve::Value(hex(stored.value()))},
|
||||
{"x", eve::Value(chunk.x)},
|
||||
{"y", eve::Value(chunk.y)},
|
||||
});
|
||||
}
|
||||
eve::Value payload(eve::Value::Object{
|
||||
{"catalogFingerprint", eve::Value(std::to_string(batch.catalogFingerprint))},
|
||||
{"chunks", eve::Value(std::move(records))},
|
||||
{"fullResync", eve::Value(batch.fullResync)},
|
||||
{"sourceLastEditSequence", eve::Value(std::to_string(batch.sourceLastEditSequence))},
|
||||
{"sourceSeed", eve::Value(std::to_string(batch.sourceSeed))},
|
||||
});
|
||||
return eve::makeSnapshotEnvelope(std::string(kArchiveType), archiveSchema(),
|
||||
eve::SchemaVersion(kArchiveVersion), instanceId,
|
||||
eve::Revision(batch.sourceRevision), batch.sourceTick,
|
||||
std::move(payload), hashProvider);
|
||||
}
|
||||
|
||||
eve::Result<eve::pixelworld::PixelChunkBatch> decodeChunkBatchArchive(
|
||||
const eve::SnapshotEnvelope& snapshot, const eve::SnapshotHashProvider& hashProvider) {
|
||||
auto verified = eve::verifySnapshotEnvelope(snapshot, hashProvider);
|
||||
if (!verified) return eve::Result<eve::pixelworld::PixelChunkBatch>::failure(verified.status());
|
||||
if (snapshot.type != kArchiveType || snapshot.schema != archiveSchema())
|
||||
return fail<eve::pixelworld::PixelChunkBatch>(eve::DiagnosticCode::InvalidArgument,
|
||||
"Chunk archive type or schema does not match", "schema");
|
||||
if (snapshot.schemaVersion.value() < 1 || snapshot.schemaVersion.value() > kArchiveVersion)
|
||||
return fail<eve::pixelworld::PixelChunkBatch>(eve::DiagnosticCode::UnknownVersion,
|
||||
"unsupported Chunk archive version", "schemaVersion");
|
||||
const bool versionOne = snapshot.schemaVersion.value() == 1;
|
||||
const bool versionTwo = snapshot.schemaVersion.value() == 2;
|
||||
auto root = versionOne
|
||||
? object(snapshot.payload, "payload", {"catalogFingerprint", "chunks", "sourceSeed"})
|
||||
: versionTwo
|
||||
? object(snapshot.payload, "payload", {"catalogFingerprint", "chunks",
|
||||
"sourceLastEditSequence", "sourceSeed"})
|
||||
: object(snapshot.payload, "payload", {"catalogFingerprint", "chunks",
|
||||
"fullResync", "sourceLastEditSequence",
|
||||
"sourceSeed"});
|
||||
if (!root) return eve::Result<eve::pixelworld::PixelChunkBatch>::failure(root.status());
|
||||
auto catalog = decimal(root.value()->at("catalogFingerprint"), "payload.catalogFingerprint");
|
||||
auto seed = decimal(root.value()->at("sourceSeed"), "payload.sourceSeed");
|
||||
auto sequence = versionOne
|
||||
? eve::Result<std::uint64_t>::success(0)
|
||||
: decimal(root.value()->at("sourceLastEditSequence"),
|
||||
"payload.sourceLastEditSequence");
|
||||
const auto* records = root.value()->at("chunks").getIf<eve::Value::Array>();
|
||||
if (!catalog || !seed || !sequence || !records || records->size() > kMaximumArchiveChunks)
|
||||
return fail<eve::pixelworld::PixelChunkBatch>(eve::DiagnosticCode::ParseError,
|
||||
"invalid Chunk archive payload", "payload");
|
||||
|
||||
eve::pixelworld::PixelChunkBatch batch;
|
||||
batch.catalogFingerprint = catalog.value();
|
||||
batch.sourceSeed = seed.value();
|
||||
batch.sourceRevision = snapshot.revision.value();
|
||||
batch.sourceTick = snapshot.tick;
|
||||
batch.sourceLastEditSequence = sequence.value();
|
||||
if (!versionOne && !versionTwo) {
|
||||
const auto* fullResync = root.value()->at("fullResync").getIf<bool>();
|
||||
if (!fullResync)
|
||||
return fail<eve::pixelworld::PixelChunkBatch>(eve::DiagnosticCode::ParseError,
|
||||
"invalid fullResync flag", "payload.fullResync");
|
||||
batch.fullResync = *fullResync;
|
||||
}
|
||||
std::optional<std::pair<int, int>> previous;
|
||||
std::uint64_t decodedTotal = 0;
|
||||
for (std::size_t index = 0; index < records->size(); ++index) {
|
||||
const std::string path = "payload.chunks[" + std::to_string(index) + "]";
|
||||
auto record = object((*records)[index], path,
|
||||
{"codec", "contentHash", "decodedSize", "removed", "revision",
|
||||
"stored", "x", "y"});
|
||||
if (!record) return eve::Result<eve::pixelworld::PixelChunkBatch>::failure(record.status());
|
||||
auto x = integer(record.value()->at("x"), path + ".x");
|
||||
auto y = integer(record.value()->at("y"), path + ".y");
|
||||
auto revision = decimal(record.value()->at("revision"), path + ".revision");
|
||||
auto decodedSize = decimal(record.value()->at("decodedSize"), path + ".decodedSize");
|
||||
const auto* removed = record.value()->at("removed").getIf<bool>();
|
||||
auto codecValue = integer(record.value()->at("codec"), path + ".codec");
|
||||
const auto* hashText = record.value()->at("contentHash").getIf<std::string>();
|
||||
if (!x || !y || !revision || !decodedSize || !removed || !codecValue || !hashText ||
|
||||
revision.value() == 0 || revision.value() > batch.sourceRevision ||
|
||||
codecValue.value() < 0 || codecValue.value() > int(PixelChunkArchiveCodec::Zstd))
|
||||
return fail<eve::pixelworld::PixelChunkBatch>(eve::DiagnosticCode::ParseError,
|
||||
"invalid Chunk archive record", path);
|
||||
const std::pair<int, int> key{y.value(), x.value()};
|
||||
if (previous && !(previous.value() < key))
|
||||
return fail<eve::pixelworld::PixelChunkBatch>(eve::DiagnosticCode::ParseError,
|
||||
"Chunk records are not canonical", path);
|
||||
previous = key;
|
||||
const bool currentCells = decodedSize.value() == kDecodedChunkBytes;
|
||||
const bool legacyCells = decodedSize.value() == kLegacyDecodedChunkBytes;
|
||||
const std::uint64_t expectedSize = *removed ? 0 : decodedSize.value();
|
||||
if ((!*removed && !currentCells && !legacyCells) || (*removed && decodedSize.value() != 0) ||
|
||||
decodedTotal > kMaximumDecodedBytes - expectedSize)
|
||||
return fail<eve::pixelworld::PixelChunkBatch>(eve::DiagnosticCode::ParseError,
|
||||
"Chunk decoded size exceeds budget", path);
|
||||
decodedTotal += expectedSize;
|
||||
auto stored = unhex(record.value()->at("stored"), kDecodedChunkBytes + 1024, path + ".stored");
|
||||
if (!stored) return eve::Result<eve::pixelworld::PixelChunkBatch>::failure(stored.status());
|
||||
const auto codec = static_cast<PixelChunkArchiveCodec>(codecValue.value());
|
||||
if (*removed && (codec != PixelChunkArchiveCodec::None || !stored.value().empty()))
|
||||
return fail<eve::pixelworld::PixelChunkBatch>(eve::DiagnosticCode::ParseError,
|
||||
"removed Chunk must have empty uncompressed payload", path);
|
||||
auto decoded = eve::asset::decompressEvpackChunk(assetCodec(codec), stored.value(),
|
||||
expectedSize, kMaximumDecodedBytes);
|
||||
if (!decoded) return eve::Result<eve::pixelworld::PixelChunkBatch>::failure(decoded.status());
|
||||
const auto expectedHash = eve::ContentId::parse(*hashText);
|
||||
auto actualHash = digest(decoded.value(), hashProvider);
|
||||
if (!expectedHash || !actualHash || actualHash.value() != *expectedHash)
|
||||
return fail<eve::pixelworld::PixelChunkBatch>(eve::DiagnosticCode::HashMismatch,
|
||||
"decoded Chunk content hash does not match", path);
|
||||
|
||||
eve::pixelworld::PixelChunkSnapshot chunk;
|
||||
chunk.x = x.value();
|
||||
chunk.y = y.value();
|
||||
chunk.revision = revision.value();
|
||||
chunk.removed = *removed;
|
||||
if (!*removed) {
|
||||
chunk.cells.resize(eve::pixelworld::kPixelChunkSize * eve::pixelworld::kPixelChunkSize);
|
||||
std::size_t cursor = 0;
|
||||
for (auto& cell : chunk.cells) {
|
||||
cell.material = eve::pixelworld::MaterialId(take<std::uint16_t>(decoded.value(), cursor));
|
||||
cell.temperature = take<std::int16_t>(decoded.value(), cursor);
|
||||
cell.lifetime = take<std::uint8_t>(decoded.value(), cursor);
|
||||
if (currentCells)
|
||||
cell.thermalRemainder = take<std::uint16_t>(decoded.value(), cursor);
|
||||
}
|
||||
}
|
||||
batch.chunks.push_back(std::move(chunk));
|
||||
}
|
||||
return eve::Result<eve::pixelworld::PixelChunkBatch>::success(std::move(batch));
|
||||
}
|
||||
|
||||
} // namespace eve::pixelworld_streaming
|
||||
@@ -0,0 +1,83 @@
|
||||
#include "pixelworld_streaming/PixelWorldStreaming.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <limits>
|
||||
#include <vector>
|
||||
|
||||
namespace eve::pixelworld_streaming {
|
||||
namespace {
|
||||
|
||||
using Coord = std::pair<int, int>;
|
||||
|
||||
Coord coord(const eve::pixelworld::PixelChunkSnapshot& chunk) { return {chunk.y, chunk.x}; }
|
||||
|
||||
bool contains(eve::pixelworld::PixelChunkRegion region, Coord value) noexcept {
|
||||
const auto [y, x] = value;
|
||||
return x >= region.minX && x <= region.maxX && y >= region.minY && y <= region.maxY;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
eve::Result<PixelChunkStreamUpdate> PixelChunkStreamCursor::capture(
|
||||
const eve::pixelworld::PixelWorld& source, eve::pixelworld::PixelChunkRegion interest) {
|
||||
auto all = source.snapshotChunksInRegion(interest, 0);
|
||||
if (!all) return eve::Result<PixelChunkStreamUpdate>::failure(all.status());
|
||||
|
||||
const auto link = source.worldLink();
|
||||
const bool fullResync = source_.world == 0 || source_ != link;
|
||||
std::set<Coord> nextKnown;
|
||||
std::set<Coord> emitted;
|
||||
std::vector<eve::pixelworld::PixelChunkSnapshot> output;
|
||||
output.reserve(all.value().size() + known_.size());
|
||||
PixelChunkStreamUpdate update;
|
||||
update.fullResync = fullResync;
|
||||
|
||||
for (const auto& chunk : all.value()) {
|
||||
const Coord key = coord(chunk);
|
||||
if (chunk.removed) {
|
||||
if (fullResync || known_.contains(key)) {
|
||||
output.push_back(chunk);
|
||||
emitted.insert(key);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
nextKnown.insert(key);
|
||||
if (fullResync || !known_.contains(key) || chunk.revision > revision_) {
|
||||
output.push_back(chunk);
|
||||
emitted.insert(key);
|
||||
if (!known_.contains(key)) ++update.chunksEntered;
|
||||
}
|
||||
}
|
||||
|
||||
for (const Coord key : known_) {
|
||||
if (nextKnown.contains(key) || emitted.contains(key)) continue;
|
||||
++update.chunksEvicted;
|
||||
if (fullResync || contains(interest, key)) continue;
|
||||
const auto [y, x] = key;
|
||||
output.push_back({x, y, source.revision(), true, {}});
|
||||
}
|
||||
std::sort(output.begin(), output.end(), [](const auto& left, const auto& right) {
|
||||
return coord(left) < coord(right);
|
||||
});
|
||||
|
||||
update.batch.catalogFingerprint = source.materialCatalogFingerprint();
|
||||
update.batch.sourceSeed = source.seed();
|
||||
update.batch.sourceRevision = source.revision();
|
||||
update.batch.sourceTick = eve::SimulationTick(source.tickValue());
|
||||
update.batch.sourceLastEditSequence = source.lastEditSequence();
|
||||
update.batch.fullResync = fullResync;
|
||||
update.batch.chunks = std::move(output);
|
||||
|
||||
source_ = link;
|
||||
revision_ = source.revision();
|
||||
known_ = std::move(nextKnown);
|
||||
return eve::Result<PixelChunkStreamUpdate>::success(std::move(update));
|
||||
}
|
||||
|
||||
void PixelChunkStreamCursor::reset() noexcept {
|
||||
source_ = {};
|
||||
revision_ = 0;
|
||||
known_.clear();
|
||||
}
|
||||
|
||||
} // namespace eve::pixelworld_streaming
|
||||
@@ -0,0 +1,79 @@
|
||||
#pragma once
|
||||
|
||||
#include "common/Result.h"
|
||||
#include "common/Snapshot.h"
|
||||
#include "pixelworld/PixelWorld.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <set>
|
||||
#include <utility>
|
||||
|
||||
namespace eve::pixelworld_streaming {
|
||||
|
||||
/** @brief Stable physical codec for one independently stored PixelWorld Chunk. */
|
||||
enum class PixelChunkArchiveCodec : std::uint8_t { None = 0, Zstd = 1 };
|
||||
|
||||
/**
|
||||
* @brief Encode one authoritative batch as `pixelworld:chunk-batch` SnapshotEnvelope version 3.
|
||||
* @param batch Canonical owning batch to persist.
|
||||
* @param instanceId Stable world/save identity.
|
||||
* @param codec Per-present-Chunk physical compression codec.
|
||||
* @param hashProvider Injected digest used by the envelope and every decoded Chunk.
|
||||
* @return Integrity-sealed archive with independently compressed Chunk records.
|
||||
*/
|
||||
[[nodiscard]] eve::Result<eve::SnapshotEnvelope> archiveChunkBatch(
|
||||
const eve::pixelworld::PixelChunkBatch& batch, eve::PersistentId instanceId,
|
||||
PixelChunkArchiveCodec codec, const eve::SnapshotHashProvider& hashProvider);
|
||||
|
||||
/**
|
||||
* @brief Verify and decode a version 1, 2 or 3 Chunk-batch archive into an owning candidate.
|
||||
* @remarks Decoding is bounded and has no world side effects. Version 1 migrates with
|
||||
* `sourceLastEditSequence == 0`; versions 1 and 2 migrate with `fullResync == false`.
|
||||
* Bad envelope/Chunk hashes fail before any authority write.
|
||||
*/
|
||||
[[nodiscard]] eve::Result<eve::pixelworld::PixelChunkBatch> decodeChunkBatchArchive(
|
||||
const eve::SnapshotEnvelope& snapshot, const eve::SnapshotHashProvider& hashProvider);
|
||||
|
||||
/** @brief One interest-stream update suitable for persistence or network transport. */
|
||||
struct PixelChunkStreamUpdate {
|
||||
eve::pixelworld::PixelChunkBatch batch;
|
||||
/** @brief Mirrors `batch.fullResync` for capture diagnostics. */
|
||||
bool fullResync = false;
|
||||
std::uint32_t chunksEntered = 0;
|
||||
std::uint32_t chunksEvicted = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Stateful revision cursor that projects one PixelWorld interest region.
|
||||
*
|
||||
* The cursor owns only consumer projection state: source link, last revision and
|
||||
* known Chunk coordinates. PixelWorld remains the sole material authority. APIs
|
||||
* are simulation-thread affine, retain no world pointer and invoke no callbacks.
|
||||
*/
|
||||
class PixelChunkStreamCursor {
|
||||
public:
|
||||
/**
|
||||
* @brief Capture changed/entered Chunks and eviction tombstones for an interest region.
|
||||
* @param source World borrowed synchronously and never retained.
|
||||
* @param interest Inclusive Chunk-coordinate rectangle, bounded by PixelWorld.
|
||||
* @return Owning canonical correction batch. Source epoch changes force full resync.
|
||||
* @remarks Failure leaves cursor revision and known coordinates unchanged.
|
||||
*/
|
||||
[[nodiscard]] eve::Result<PixelChunkStreamUpdate> capture(
|
||||
const eve::pixelworld::PixelWorld& source, eve::pixelworld::PixelChunkRegion interest);
|
||||
|
||||
/** @brief Forget consumer projection state so the next capture is a full resync. */
|
||||
void reset() noexcept;
|
||||
|
||||
/** @brief Last successfully captured source revision, or zero before first capture. */
|
||||
[[nodiscard]] std::uint64_t revision() const noexcept { return revision_; }
|
||||
/** @brief Number of present Chunk coordinates believed to be held by the consumer. */
|
||||
[[nodiscard]] std::size_t knownChunkCount() const noexcept { return known_.size(); }
|
||||
|
||||
private:
|
||||
eve::pixelworld::PixelWorldLink source_{};
|
||||
std::uint64_t revision_ = 0;
|
||||
std::set<std::pair<int, int>> known_;
|
||||
};
|
||||
|
||||
} // namespace eve::pixelworld_streaming
|
||||
@@ -0,0 +1,257 @@
|
||||
#include "pixelworld_streaming/PixelWorldTransport.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <limits>
|
||||
#include <string>
|
||||
|
||||
namespace eve::pixelworld_streaming {
|
||||
namespace {
|
||||
|
||||
template <class T>
|
||||
eve::Result<T> fail(eve::DiagnosticCode code, std::string message, std::string path) {
|
||||
return eve::Result<T>::failure(eve::Diagnostic::error(
|
||||
code, std::move(message), std::move(path), {}, "pixelworld.reliable-transport"));
|
||||
}
|
||||
|
||||
bool valid(PixelChunkTransportConfig config) {
|
||||
return config.maximumInFlightTransfers > 0 && config.maximumInFlightTransfers <= 1024 &&
|
||||
config.maximumChunksPerPart > 0 && config.maximumChunksPerPart <= 1024 &&
|
||||
config.maximumPartsPerTransfer > 0 && config.maximumPartsPerTransfer <= 65'536 &&
|
||||
config.maximumBufferedTransfers > 0 && config.maximumBufferedTransfers <= 1024;
|
||||
}
|
||||
|
||||
bool sameMetadata(const PixelChunkTransferPart& left, const PixelChunkTransferPart& right) {
|
||||
return left.streamId == right.streamId && left.transferId == right.transferId &&
|
||||
left.partCount == right.partCount && left.interest == right.interest &&
|
||||
left.batch.catalogFingerprint == right.batch.catalogFingerprint &&
|
||||
left.batch.sourceSeed == right.batch.sourceSeed &&
|
||||
left.batch.sourceRevision == right.batch.sourceRevision &&
|
||||
left.batch.sourceTick == right.batch.sourceTick &&
|
||||
left.batch.sourceLastEditSequence == right.batch.sourceLastEditSequence &&
|
||||
left.batch.fullResync == right.batch.fullResync;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ReliablePixelChunkSender::ReliablePixelChunkSender(std::uint64_t streamId,
|
||||
PixelChunkTransportConfig config)
|
||||
: streamId_(streamId), config_(config) {}
|
||||
|
||||
eve::Result<ReliablePixelChunkSender> ReliablePixelChunkSender::create(
|
||||
std::uint64_t streamId, PixelChunkTransportConfig config) {
|
||||
if (streamId == 0 || !valid(config))
|
||||
return fail<ReliablePixelChunkSender>(eve::DiagnosticCode::InvalidArgument,
|
||||
"stream id or transport policy is invalid", "config");
|
||||
return eve::Result<ReliablePixelChunkSender>::success(
|
||||
ReliablePixelChunkSender(streamId, config));
|
||||
}
|
||||
|
||||
eve::Result<std::vector<PixelChunkTransferPart>> ReliablePixelChunkSender::capture(
|
||||
const eve::pixelworld::PixelWorld& source, eve::pixelworld::PixelChunkRegion interest) {
|
||||
if (inFlight_.size() >= config_.maximumInFlightTransfers)
|
||||
return fail<std::vector<PixelChunkTransferPart>>(
|
||||
eve::DiagnosticCode::PreconditionViolation, "reliable send window is full",
|
||||
"maximumInFlightTransfers");
|
||||
if (nextTransferId_ == std::numeric_limits<std::uint64_t>::max())
|
||||
return fail<std::vector<PixelChunkTransferPart>>(
|
||||
eve::DiagnosticCode::PreconditionViolation, "transfer id space is exhausted",
|
||||
"transferId");
|
||||
auto candidateCursor = cursor_;
|
||||
auto captured = candidateCursor.capture(source, interest);
|
||||
if (!captured.ok())
|
||||
return eve::Result<std::vector<PixelChunkTransferPart>>::failure(captured.status());
|
||||
auto update = std::move(captured).takeValue();
|
||||
if (captured_ && !update.fullResync && update.batch.chunks.empty() &&
|
||||
update.batch.sourceRevision == lastEnqueuedRevision_) {
|
||||
cursor_ = std::move(candidateCursor);
|
||||
return eve::Result<std::vector<PixelChunkTransferPart>>::success({});
|
||||
}
|
||||
|
||||
const std::size_t chunkCount = update.batch.chunks.size();
|
||||
const std::size_t partCount = std::max<std::size_t>(
|
||||
1, (chunkCount + config_.maximumChunksPerPart - 1) / config_.maximumChunksPerPart);
|
||||
if (partCount > config_.maximumPartsPerTransfer)
|
||||
return fail<std::vector<PixelChunkTransferPart>>(
|
||||
eve::DiagnosticCode::PreconditionViolation, "captured update exceeds part budget",
|
||||
"maximumPartsPerTransfer");
|
||||
std::vector<PixelChunkTransferPart> parts;
|
||||
parts.reserve(partCount);
|
||||
for (std::size_t index = 0; index < partCount; ++index) {
|
||||
PixelChunkTransferPart part;
|
||||
part.streamId = streamId_;
|
||||
part.transferId = nextTransferId_;
|
||||
part.partIndex = std::uint32_t(index);
|
||||
part.partCount = std::uint32_t(partCount);
|
||||
part.interest = interest;
|
||||
part.batch.catalogFingerprint = update.batch.catalogFingerprint;
|
||||
part.batch.sourceSeed = update.batch.sourceSeed;
|
||||
part.batch.sourceRevision = update.batch.sourceRevision;
|
||||
part.batch.sourceTick = update.batch.sourceTick;
|
||||
part.batch.sourceLastEditSequence = update.batch.sourceLastEditSequence;
|
||||
part.batch.fullResync = update.batch.fullResync;
|
||||
const std::size_t begin = index * config_.maximumChunksPerPart;
|
||||
const std::size_t end = std::min(chunkCount, begin + config_.maximumChunksPerPart);
|
||||
if (begin < end)
|
||||
part.batch.chunks.assign(update.batch.chunks.begin() + std::ptrdiff_t(begin),
|
||||
update.batch.chunks.begin() + std::ptrdiff_t(end));
|
||||
parts.push_back(std::move(part));
|
||||
}
|
||||
inFlight_.emplace(nextTransferId_, parts);
|
||||
cursor_ = std::move(candidateCursor);
|
||||
++nextTransferId_;
|
||||
captured_ = true;
|
||||
lastEnqueuedRevision_ = update.batch.sourceRevision;
|
||||
return eve::Result<std::vector<PixelChunkTransferPart>>::success(std::move(parts));
|
||||
}
|
||||
|
||||
std::vector<PixelChunkTransferPart> ReliablePixelChunkSender::pendingParts() const {
|
||||
std::vector<PixelChunkTransferPart> result;
|
||||
for (const auto& [transfer, parts] : inFlight_) {
|
||||
(void)transfer;
|
||||
result.insert(result.end(), parts.begin(), parts.end());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
eve::Result<PixelChunkAckReceipt> ReliablePixelChunkSender::acknowledge(
|
||||
PixelChunkTransferAck ack) {
|
||||
if (ack.streamId != streamId_)
|
||||
return fail<PixelChunkAckReceipt>(eve::DiagnosticCode::Conflict,
|
||||
"ACK belongs to another stream", "streamId");
|
||||
if (ack.acknowledgedThrough <= acknowledgedThrough_)
|
||||
return eve::Result<PixelChunkAckReceipt>::success(
|
||||
{0, 0, acknowledgedThrough_});
|
||||
if (ack.acknowledgedThrough >= nextTransferId_)
|
||||
return fail<PixelChunkAckReceipt>(eve::DiagnosticCode::Conflict,
|
||||
"ACK advances beyond the sent window",
|
||||
"acknowledgedThrough");
|
||||
const auto acknowledged = inFlight_.find(ack.acknowledgedThrough);
|
||||
if (acknowledged == inFlight_.end() || acknowledged->second.empty() ||
|
||||
acknowledged->second.front().batch.sourceRevision != ack.appliedRevision)
|
||||
return fail<PixelChunkAckReceipt>(eve::DiagnosticCode::Conflict,
|
||||
"ACK revision does not match the transfer",
|
||||
"appliedRevision");
|
||||
PixelChunkAckReceipt receipt;
|
||||
for (auto iterator = inFlight_.begin(); iterator != inFlight_.end() &&
|
||||
iterator->first <= ack.acknowledgedThrough;) {
|
||||
for (const auto& part : iterator->second)
|
||||
receipt.tombstonesReleased += std::uint32_t(std::count_if(
|
||||
part.batch.chunks.begin(), part.batch.chunks.end(),
|
||||
[](const auto& chunk) { return chunk.removed; }));
|
||||
iterator = inFlight_.erase(iterator);
|
||||
++receipt.transfersReleased;
|
||||
}
|
||||
acknowledgedThrough_ = ack.acknowledgedThrough;
|
||||
receipt.acknowledgedThrough = acknowledgedThrough_;
|
||||
return eve::Result<PixelChunkAckReceipt>::success(receipt);
|
||||
}
|
||||
|
||||
std::size_t ReliablePixelChunkSender::inFlightTransferCount() const noexcept {
|
||||
return inFlight_.size();
|
||||
}
|
||||
|
||||
std::size_t ReliablePixelChunkSender::retainedTombstoneCount() const noexcept {
|
||||
std::size_t count = 0;
|
||||
for (const auto& [transfer, parts] : inFlight_) {
|
||||
(void)transfer;
|
||||
for (const auto& part : parts)
|
||||
count += std::count_if(part.batch.chunks.begin(), part.batch.chunks.end(),
|
||||
[](const auto& chunk) { return chunk.removed; });
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
ReliablePixelChunkReceiver::ReliablePixelChunkReceiver(std::uint64_t streamId,
|
||||
PixelChunkTransportConfig config)
|
||||
: streamId_(streamId), config_(config) {}
|
||||
|
||||
eve::Result<ReliablePixelChunkReceiver> ReliablePixelChunkReceiver::create(
|
||||
std::uint64_t streamId, PixelChunkTransportConfig config) {
|
||||
if (streamId == 0 || !valid(config))
|
||||
return fail<ReliablePixelChunkReceiver>(eve::DiagnosticCode::InvalidArgument,
|
||||
"stream id or transport policy is invalid", "config");
|
||||
return eve::Result<ReliablePixelChunkReceiver>::success(
|
||||
ReliablePixelChunkReceiver(streamId, config));
|
||||
}
|
||||
|
||||
eve::Result<PixelChunkReceiveReceipt> ReliablePixelChunkReceiver::receive(
|
||||
PixelChunkTransferPart part, eve::pixelworld::PixelWorld& replica) {
|
||||
if (part.streamId != streamId_ || part.transferId == 0 || part.partCount == 0 ||
|
||||
part.partCount > config_.maximumPartsPerTransfer || part.partIndex >= part.partCount ||
|
||||
part.batch.chunks.size() > config_.maximumChunksPerPart)
|
||||
return fail<PixelChunkReceiveReceipt>(eve::DiagnosticCode::InvalidArgument,
|
||||
"transfer part metadata exceeds policy", "part");
|
||||
PixelChunkReceiveReceipt receipt;
|
||||
if (part.transferId < expectedTransferId_) {
|
||||
receipt.duplicate = true;
|
||||
receipt.ack = {streamId_, expectedTransferId_ - 1, replica.revision()};
|
||||
receipt.partsBuffered = std::uint32_t(bufferedPartCount());
|
||||
return eve::Result<PixelChunkReceiveReceipt>::success(receipt);
|
||||
}
|
||||
auto found = buffered_.find(part.transferId);
|
||||
if (found == buffered_.end()) {
|
||||
if (buffered_.size() >= config_.maximumBufferedTransfers)
|
||||
return fail<PixelChunkReceiveReceipt>(
|
||||
eve::DiagnosticCode::PreconditionViolation,
|
||||
"out-of-order receive window is full", "maximumBufferedTransfers");
|
||||
Assembly assembly;
|
||||
assembly.partCount = part.partCount;
|
||||
assembly.parts.resize(part.partCount);
|
||||
found = buffered_.emplace(part.transferId, std::move(assembly)).first;
|
||||
}
|
||||
Assembly& assembly = found->second;
|
||||
if (assembly.partCount != part.partCount)
|
||||
return fail<PixelChunkReceiveReceipt>(eve::DiagnosticCode::Conflict,
|
||||
"transfer part count changed", "partCount");
|
||||
auto& slot = assembly.parts[part.partIndex];
|
||||
if (slot) {
|
||||
if (*slot != part)
|
||||
return fail<PixelChunkReceiveReceipt>(eve::DiagnosticCode::Conflict,
|
||||
"duplicate part payload conflicts", "part");
|
||||
receipt.duplicate = true;
|
||||
} else {
|
||||
slot = std::make_unique<PixelChunkTransferPart>(std::move(part));
|
||||
}
|
||||
|
||||
while (true) {
|
||||
auto next = buffered_.find(expectedTransferId_);
|
||||
if (next == buffered_.end()) break;
|
||||
const bool complete = std::all_of(next->second.parts.begin(), next->second.parts.end(),
|
||||
[](const auto& value) { return bool(value); });
|
||||
if (!complete) break;
|
||||
const PixelChunkTransferPart& first = *next->second.parts.front();
|
||||
eve::pixelworld::PixelChunkBatch combined = first.batch;
|
||||
combined.chunks.clear();
|
||||
for (const auto& value : next->second.parts) {
|
||||
if (!sameMetadata(first, *value))
|
||||
return fail<PixelChunkReceiveReceipt>(eve::DiagnosticCode::Conflict,
|
||||
"transfer parts disagree on authority metadata",
|
||||
"part.batch");
|
||||
combined.chunks.insert(combined.chunks.end(), value->batch.chunks.begin(),
|
||||
value->batch.chunks.end());
|
||||
}
|
||||
auto applied = replica.applyChunkBatch(combined, replica.revision());
|
||||
if (!applied.ok()) {
|
||||
buffered_.erase(next);
|
||||
return eve::Result<PixelChunkReceiveReceipt>::failure(applied.status());
|
||||
}
|
||||
buffered_.erase(next);
|
||||
++expectedTransferId_;
|
||||
++receipt.transfersApplied;
|
||||
}
|
||||
receipt.ack = {streamId_, expectedTransferId_ - 1, replica.revision()};
|
||||
receipt.partsBuffered = std::uint32_t(bufferedPartCount());
|
||||
return eve::Result<PixelChunkReceiveReceipt>::success(receipt);
|
||||
}
|
||||
|
||||
std::size_t ReliablePixelChunkReceiver::bufferedPartCount() const noexcept {
|
||||
std::size_t count = 0;
|
||||
for (const auto& [transfer, assembly] : buffered_) {
|
||||
(void)transfer;
|
||||
count += std::count_if(assembly.parts.begin(), assembly.parts.end(),
|
||||
[](const auto& value) { return bool(value); });
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
} // namespace eve::pixelworld_streaming
|
||||
@@ -0,0 +1,131 @@
|
||||
#pragma once
|
||||
|
||||
#include "common/Result.h"
|
||||
#include "pixelworld/PixelWorld.h"
|
||||
#include "pixelworld_streaming/PixelWorldStreaming.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
namespace eve::pixelworld_streaming {
|
||||
|
||||
/** @brief Bounded application-level reliability policy layered over any byte transport. */
|
||||
struct PixelChunkTransportConfig {
|
||||
std::uint32_t maximumInFlightTransfers = 8;
|
||||
std::uint32_t maximumChunksPerPart = 1;
|
||||
std::uint32_t maximumPartsPerTransfer = 8192;
|
||||
std::uint32_t maximumBufferedTransfers = 16;
|
||||
};
|
||||
|
||||
/** @brief One independently retransmittable part of an atomic Chunk transfer. */
|
||||
struct PixelChunkTransferPart {
|
||||
std::uint64_t streamId = 0;
|
||||
std::uint64_t transferId = 0;
|
||||
std::uint32_t partIndex = 0;
|
||||
std::uint32_t partCount = 0;
|
||||
eve::pixelworld::PixelChunkRegion interest{};
|
||||
eve::pixelworld::PixelChunkBatch batch;
|
||||
|
||||
friend bool operator==(const PixelChunkTransferPart&, const PixelChunkTransferPart&) = default;
|
||||
};
|
||||
|
||||
/** @brief Cumulative application ACK proving transfers were committed to replica authority. */
|
||||
struct PixelChunkTransferAck {
|
||||
std::uint64_t streamId = 0;
|
||||
std::uint64_t acknowledgedThrough = 0;
|
||||
std::uint64_t appliedRevision = 0;
|
||||
};
|
||||
|
||||
/** @brief Counters returned after accepting a cumulative ACK. */
|
||||
struct PixelChunkAckReceipt {
|
||||
std::uint32_t transfersReleased = 0;
|
||||
std::uint32_t tombstonesReleased = 0;
|
||||
std::uint64_t acknowledgedThrough = 0;
|
||||
};
|
||||
|
||||
/** @brief Receiver outcome after one possibly duplicate or out-of-order part. */
|
||||
struct PixelChunkReceiveReceipt {
|
||||
PixelChunkTransferAck ack;
|
||||
std::uint32_t transfersApplied = 0;
|
||||
std::uint32_t partsBuffered = 0;
|
||||
bool duplicate = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Reliable sender retaining owning transfer parts and tombstones until application ACK.
|
||||
*
|
||||
* The sender is transport-neutral: callers may send `pendingParts()` through EVNetwork reliable
|
||||
* messages, another socket, or deterministic tests. Cursor state advances only after a transfer
|
||||
* fits the bounded in-flight window. Repeated `pendingParts()` calls are intentional retransmits.
|
||||
*/
|
||||
class ReliablePixelChunkSender {
|
||||
public:
|
||||
/** @brief Validate policy and create a fresh nonzero stream session. */
|
||||
[[nodiscard]] static eve::Result<ReliablePixelChunkSender> create(
|
||||
std::uint64_t streamId, PixelChunkTransportConfig config = {});
|
||||
|
||||
/** @brief Capture and enqueue one atomic revision transfer, split into bounded owning parts. */
|
||||
[[nodiscard]] eve::Result<std::vector<PixelChunkTransferPart>> capture(
|
||||
const eve::pixelworld::PixelWorld& source,
|
||||
eve::pixelworld::PixelChunkRegion interest);
|
||||
|
||||
/** @brief Return every unacknowledged part in canonical transfer/part order for transmission. */
|
||||
[[nodiscard]] std::vector<PixelChunkTransferPart> pendingParts() const;
|
||||
/** @brief Release cumulatively acknowledged transfers and their retained tombstones. */
|
||||
[[nodiscard]] eve::Result<PixelChunkAckReceipt> acknowledge(PixelChunkTransferAck ack);
|
||||
/** @brief Number of unacknowledged atomic transfers. */
|
||||
[[nodiscard]] std::size_t inFlightTransferCount() const noexcept;
|
||||
/** @brief Number of tombstones retained specifically for possible retransmission. */
|
||||
[[nodiscard]] std::size_t retainedTombstoneCount() const noexcept;
|
||||
|
||||
private:
|
||||
ReliablePixelChunkSender(std::uint64_t streamId, PixelChunkTransportConfig config);
|
||||
|
||||
std::uint64_t streamId_ = 0;
|
||||
std::uint64_t nextTransferId_ = 1;
|
||||
std::uint64_t acknowledgedThrough_ = 0;
|
||||
std::uint64_t lastEnqueuedRevision_ = 0;
|
||||
bool captured_ = false;
|
||||
PixelChunkTransportConfig config_{};
|
||||
PixelChunkStreamCursor cursor_;
|
||||
std::map<std::uint64_t, std::vector<PixelChunkTransferPart>> inFlight_;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Ordered atomic receiver with duplicate suppression and bounded out-of-order buffering.
|
||||
*
|
||||
* Parts never mutate PixelWorld individually. A complete next transfer is canonicalized and
|
||||
* committed through `applyChunkBatch`; only then does the cumulative ACK advance. Failure leaves
|
||||
* both replica and expected transfer id unchanged.
|
||||
*/
|
||||
class ReliablePixelChunkReceiver {
|
||||
public:
|
||||
/** @brief Validate policy and create a receiver for one nonzero stream session. */
|
||||
[[nodiscard]] static eve::Result<ReliablePixelChunkReceiver> create(
|
||||
std::uint64_t streamId, PixelChunkTransportConfig config = {});
|
||||
|
||||
/** @brief Buffer one part and atomically apply every newly contiguous complete transfer. */
|
||||
[[nodiscard]] eve::Result<PixelChunkReceiveReceipt> receive(
|
||||
PixelChunkTransferPart part, eve::pixelworld::PixelWorld& replica);
|
||||
/** @brief Last cumulatively applied transfer id. */
|
||||
[[nodiscard]] std::uint64_t acknowledgedThrough() const noexcept { return expectedTransferId_ - 1; }
|
||||
/** @brief Count parts retained while waiting for gaps or completion. */
|
||||
[[nodiscard]] std::size_t bufferedPartCount() const noexcept;
|
||||
|
||||
private:
|
||||
struct Assembly {
|
||||
std::uint32_t partCount = 0;
|
||||
std::vector<std::unique_ptr<PixelChunkTransferPart>> parts;
|
||||
};
|
||||
|
||||
ReliablePixelChunkReceiver(std::uint64_t streamId, PixelChunkTransportConfig config);
|
||||
|
||||
std::uint64_t streamId_ = 0;
|
||||
std::uint64_t expectedTransferId_ = 1;
|
||||
PixelChunkTransportConfig config_{};
|
||||
std::map<std::uint64_t, Assembly> buffered_;
|
||||
};
|
||||
|
||||
} // namespace eve::pixelworld_streaming
|
||||
@@ -0,0 +1,33 @@
|
||||
#include "pixelworld_thread/PixelWorldThread.h"
|
||||
|
||||
#include "thread/JobSystem.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <memory>
|
||||
|
||||
namespace eve::pixelworld_thread {
|
||||
|
||||
JobSystemPixelScheduler::JobSystemPixelScheduler(eve::thread::JobSystem& jobs) noexcept
|
||||
: jobs_(&jobs) {}
|
||||
|
||||
void JobSystemPixelScheduler::parallelFor(
|
||||
std::size_t workItems, const std::function<void(std::size_t)>& body) {
|
||||
if (workItems == 0) return;
|
||||
try {
|
||||
std::unique_ptr<eve::thread::Job> job(jobs_->parallelFor(
|
||||
0, int(workItems), [&body](int first, int last) {
|
||||
for (int index = first; index < last; ++index) body(std::size_t(index));
|
||||
}, 1));
|
||||
job->wait();
|
||||
if (!job->hasFailed()) return;
|
||||
} catch (...) {
|
||||
// The serial pass below restores every index-owned result slot.
|
||||
}
|
||||
for (std::size_t index = 0; index < workItems; ++index) body(index);
|
||||
}
|
||||
|
||||
std::size_t JobSystemPixelScheduler::workerCount() const noexcept {
|
||||
return std::size_t(std::max(0, jobs_->getWorkerCount()));
|
||||
}
|
||||
|
||||
} // namespace eve::pixelworld_thread
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
|
||||
#include "pixelworld/PixelWorld.h"
|
||||
|
||||
namespace eve::thread {
|
||||
class JobSystem;
|
||||
}
|
||||
|
||||
namespace eve::pixelworld_thread {
|
||||
|
||||
/**
|
||||
* @brief Synchronous PixelWorld candidate scheduler backed by engine JobSystem.
|
||||
*
|
||||
* The adapter borrows JobSystem for its whole lifetime and never stops or owns it.
|
||||
* `parallelFor` joins every submitted job before returning. Worker failure falls
|
||||
* back to a deterministic serial overwrite of all index-owned result slots.
|
||||
*/
|
||||
class JobSystemPixelScheduler final : public eve::pixelworld::PixelWorkScheduler {
|
||||
public:
|
||||
/**
|
||||
* @brief Borrow a running scheduler.
|
||||
* @param jobs JobSystem that must outlive this adapter and all calls.
|
||||
*/
|
||||
explicit JobSystemPixelScheduler(eve::thread::JobSystem& jobs) noexcept;
|
||||
|
||||
void parallelFor(std::size_t workItems,
|
||||
const std::function<void(std::size_t)>& body) override;
|
||||
[[nodiscard]] std::size_t workerCount() const noexcept override;
|
||||
|
||||
private:
|
||||
eve::thread::JobSystem* jobs_ = nullptr;
|
||||
};
|
||||
|
||||
} // namespace eve::pixelworld_thread
|
||||
@@ -13,6 +13,8 @@
|
||||
#include "graphics/TextureSampler.h"
|
||||
#include "image/ImageData.h"
|
||||
#include "image/Image.h"
|
||||
#include "pixelworld/PixelWorld.h"
|
||||
#include "pixelworld_graphics/PixelWorldGraphics.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
@@ -113,6 +115,15 @@ TEST_CASE("graphics.backendParity.textureUpdateAndAlphaBlend") {
|
||||
REQUIRE(gfx->updateTexture(texture, 1, 1, green));
|
||||
REQUIRE(texture == stable);
|
||||
REQUIRE(!gfx->updateTexture(texture, 2, 1, green));
|
||||
REQUIRE(gfx->updateTextureRegion(texture, 0, 0, 1, 1, red).ok());
|
||||
CHECK(!gfx->updateTextureRegion(texture, 1, 0, 1, 1, red).ok());
|
||||
CHECK(!gfx->updateTextureRegion(texture, 0, 0, 1, 1,
|
||||
std::span<const uint8_t>(red, 3)).ok());
|
||||
const TextureRegionUpload rejectedBatch[] = {
|
||||
{0, 0, 1, 1, green, 0},
|
||||
{1, 0, 1, 1, green, 0},
|
||||
};
|
||||
CHECK(!gfx->updateTextureRegions(texture, rejectedBatch).ok());
|
||||
|
||||
Canvas *canvas = gfx->newCanvas(64, 64);
|
||||
REQUIRE(canvas != nullptr);
|
||||
@@ -131,8 +142,8 @@ TEST_CASE("graphics.backendParity.textureUpdateAndAlphaBlend") {
|
||||
REQUIRE(background[2] > 247);
|
||||
|
||||
const uint8_t *updated = pixel(*image, 16, 16);
|
||||
REQUIRE(updated[0] < 8);
|
||||
REQUIRE(updated[1] > 247);
|
||||
REQUIRE(updated[0] > 247);
|
||||
REQUIRE(updated[1] < 8);
|
||||
REQUIRE(updated[2] < 8);
|
||||
|
||||
const uint8_t *blended = pixel(*image, 40, 16);
|
||||
@@ -144,6 +155,31 @@ TEST_CASE("graphics.backendParity.textureUpdateAndAlphaBlend") {
|
||||
writeParityArtifact(*image, "texture_update_alpha_blend", backend);
|
||||
}
|
||||
|
||||
TEST_CASE("graphics.backendParity.pixelWorldMillionPixelAtlas") {
|
||||
Graphics *gfx = headlessGraphics();
|
||||
REQUIRE(gfx != nullptr);
|
||||
|
||||
eve::pixelworld::PixelWorld world(4242);
|
||||
// Radius 724 covers every cell in [0,1024)^2, creating a genuine one-million
|
||||
// pixel viewport while also proving off-viewport chunks are clipped by the adapter.
|
||||
REQUIRE(world.paintCircleChecked(512, 512, 724, "stone").ok());
|
||||
eve::pixelworld_graphics::PixelWorldAtlasRenderer renderer(0, 0, 1024, 1024);
|
||||
CHECK(renderer.sync(&world, gfx) == 256);
|
||||
REQUIRE(renderer.texture() != nullptr);
|
||||
CHECK(renderer.texture()->width == 1024);
|
||||
CHECK(renderer.texture()->height == 1024);
|
||||
CHECK(renderer.uploadCount() == 1);
|
||||
CHECK(renderer.totalUploadedChunks() == 256);
|
||||
CHECK(renderer.renderedRevision() == world.revision());
|
||||
|
||||
world.setMaterial(1, 1, "sand");
|
||||
world.setMaterial(1022, 1022, "water");
|
||||
CHECK(renderer.sync(&world, gfx) == 2);
|
||||
CHECK(renderer.uploadCount() == 2);
|
||||
CHECK(renderer.totalUploadedChunks() == 258);
|
||||
CHECK(renderer.renderedRevision() == world.revision());
|
||||
}
|
||||
|
||||
TEST_CASE("graphics.backendParity.draw2dUvRotationAndBlendModes") {
|
||||
Graphics *gfx = headlessGraphics();
|
||||
REQUIRE(gfx != nullptr);
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include "devtools/DevTool.hpp"
|
||||
#include "devtools/McpServer.hpp"
|
||||
#include "editor/Editor.h"
|
||||
#include "pixelworld/PixelWorldModule.h"
|
||||
#include "ui/EditorHost.h"
|
||||
|
||||
#include <Poco/Dynamic/Var.h>
|
||||
@@ -276,6 +277,9 @@ TEST_CASE("devtools.mcp.initializeToolsStatus") {
|
||||
dt.attach(vm, false);
|
||||
dt.exposeScriptApi(vm);
|
||||
|
||||
eve::pixelworld::PixelWorldModule pixelworldModule;
|
||||
eve::pixelworld::PixelWorld pixelworld(9081);
|
||||
|
||||
eve::editor::Editor editor;
|
||||
eve::editor::CommandDescriptor editorCommand;
|
||||
editorCommand.id = eve::editor::CommandId("mcp.test-command");
|
||||
@@ -348,6 +352,11 @@ TEST_CASE("devtools.mcp.initializeToolsStatus") {
|
||||
bool foundAgentStart = false;
|
||||
bool foundAgentEvidence = false;
|
||||
bool foundAgentComplete = false;
|
||||
bool foundPixelWorlds = false;
|
||||
bool foundPixelSamples = false;
|
||||
bool foundPixelCatalog = false;
|
||||
bool foundPixelValidate = false;
|
||||
bool foundPixelApply = false;
|
||||
for (size_t i = 0; i < tools->size(); ++i) {
|
||||
auto t = tools->getObject(static_cast<unsigned>(i));
|
||||
const std::string name = t->getValue<std::string>("name");
|
||||
@@ -385,6 +394,11 @@ TEST_CASE("devtools.mcp.initializeToolsStatus") {
|
||||
if (name == "eve_agent_session_start") foundAgentStart = true;
|
||||
if (name == "eve_agent_session_evidence") foundAgentEvidence = true;
|
||||
if (name == "eve_agent_session_complete") foundAgentComplete = true;
|
||||
if (name == "eve_pixelworld_worlds") foundPixelWorlds = true;
|
||||
if (name == "eve_pixelworld_samples") foundPixelSamples = true;
|
||||
if (name == "eve_pixelworld_catalog_builtin") foundPixelCatalog = true;
|
||||
if (name == "eve_pixelworld_catalog_validate") foundPixelValidate = true;
|
||||
if (name == "eve_pixelworld_catalog_apply") foundPixelApply = true;
|
||||
}
|
||||
CHECK(foundStatus);
|
||||
CHECK(foundEval);
|
||||
@@ -420,6 +434,11 @@ TEST_CASE("devtools.mcp.initializeToolsStatus") {
|
||||
CHECK(foundAgentStart);
|
||||
CHECK(foundAgentEvidence);
|
||||
CHECK(foundAgentComplete);
|
||||
CHECK(foundPixelWorlds);
|
||||
CHECK(foundPixelSamples);
|
||||
CHECK(foundPixelCatalog);
|
||||
CHECK(foundPixelValidate);
|
||||
CHECK(foundPixelApply);
|
||||
|
||||
client.sendRequest(3, "tools/call", "{\"name\":\"eve_status\",\"arguments\":{}}");
|
||||
auto statusMsg = client.expectResult(3);
|
||||
@@ -509,6 +528,26 @@ TEST_CASE("devtools.mcp.initializeToolsStatus") {
|
||||
const std::string sceneObserveAbsent = textOf(
|
||||
21, "{\"name\":\"eve_editor_execute_observe\",\"arguments\":{\"target\":\"mcp.test\",\"command\":\"mcp.test-command\",\"payload\":{},\"observer\":\"scene-node\",\"host\":\"world\",\"node\":\"player\"}}");
|
||||
CHECK(sceneObserveAbsent.find("scene module not available") != std::string::npos);
|
||||
const std::string pixelWorlds = textOf(22, "{\"name\":\"eve_pixelworld_worlds\",\"arguments\":{}}");
|
||||
CHECK(pixelWorlds.find("\"seed\":9081") != std::string::npos);
|
||||
const std::string worldId = std::to_string(pixelworld.worldLink().world);
|
||||
const std::string pixelPaused = textOf(
|
||||
23, "{\"name\":\"eve_pixelworld_pause\",\"arguments\":{\"world\":" + worldId +
|
||||
",\"paused\":true}}");
|
||||
CHECK(pixelPaused.find("\"paused\":true") != std::string::npos);
|
||||
const std::string pixelStepped = textOf(
|
||||
24, "{\"name\":\"eve_pixelworld_step\",\"arguments\":{\"world\":" + worldId +
|
||||
",\"count\":2}}");
|
||||
CHECK(pixelStepped.find("\"lastTick\":2") != std::string::npos);
|
||||
const std::string pixelSamples = textOf(
|
||||
25, "{\"name\":\"eve_pixelworld_samples\",\"arguments\":{\"world\":" + worldId + "}}");
|
||||
CHECK(pixelSamples.find("\"tick\":2") != std::string::npos);
|
||||
const std::string pixelCatalog =
|
||||
textOf(26, "{\"name\":\"eve_pixelworld_catalog_builtin\",\"arguments\":{}}");
|
||||
CHECK(pixelCatalog.find("eve.pixelworld.material-catalog") != std::string::npos);
|
||||
const std::string invalidCatalog = textOf(
|
||||
27, "{\"name\":\"eve_pixelworld_catalog_validate\",\"arguments\":{\"catalog\":{}}}");
|
||||
CHECK(invalidCatalog.find("\"ok\":false") != std::string::npos);
|
||||
|
||||
mcp.stop();
|
||||
dt.detach();
|
||||
|
||||
@@ -0,0 +1,646 @@
|
||||
#include "zeroerr/assert.h"
|
||||
#include "zeroerr/unittest.h"
|
||||
|
||||
#include "pixelworld/PixelWorld.h"
|
||||
#include "pixelworld/PixelMaterialCatalogCodec.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstddef>
|
||||
#include <cstring>
|
||||
#include <limits>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
using namespace eve::pixelworld;
|
||||
|
||||
namespace {
|
||||
|
||||
std::vector<std::byte> withoutThermalRemainders(const std::vector<std::byte>& current) {
|
||||
REQUIRE(current.size() >= 50);
|
||||
std::uint32_t chunks = 0;
|
||||
std::memcpy(&chunks, current.data() + 46, sizeof(chunks));
|
||||
std::vector<std::byte> legacy(current.begin(), current.begin() + 50);
|
||||
std::size_t cursor = 50;
|
||||
for (std::uint32_t chunk = 0; chunk < chunks; ++chunk) {
|
||||
REQUIRE(current.size() - cursor >= 8);
|
||||
legacy.insert(legacy.end(), current.begin() + std::ptrdiff_t(cursor),
|
||||
current.begin() + std::ptrdiff_t(cursor + 8));
|
||||
cursor += 8;
|
||||
for (int cell = 0; cell < kPixelChunkSize * kPixelChunkSize; ++cell) {
|
||||
REQUIRE(current.size() - cursor >= 7);
|
||||
legacy.insert(legacy.end(), current.begin() + std::ptrdiff_t(cursor),
|
||||
current.begin() + std::ptrdiff_t(cursor + 5));
|
||||
cursor += 7;
|
||||
}
|
||||
}
|
||||
REQUIRE_EQ(cursor, current.size());
|
||||
return legacy;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("pixelworld.sand_falls_and_stops_on_stone") {
|
||||
PixelWorld world(7);
|
||||
for (int x = -2; x <= 2; ++x) world.setMaterial(x, 4, "stone");
|
||||
world.setMaterial(0, 0, "sand");
|
||||
for (std::uint64_t tick = 1; tick <= 6; ++tick) {
|
||||
auto result = world.advance(eve::SimulationTick(tick));
|
||||
REQUIRE(result.ok());
|
||||
}
|
||||
CHECK_EQ(world.getMaterial(0, 3), int(MaterialId::Sand));
|
||||
CHECK_EQ(world.getMaterial(0, 4), int(MaterialId::Stone));
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld.cross_chunk_motion_is_conservative") {
|
||||
PixelWorld world(11);
|
||||
for (int x = 61; x <= 65; ++x) world.setMaterial(x, 65, "stone");
|
||||
world.setMaterial(63, 63, "sand");
|
||||
for (std::uint64_t tick = 1; tick <= 4; ++tick)
|
||||
world.advance(eve::SimulationTick(tick)).expect("cross-chunk step");
|
||||
CHECK_EQ(world.getMaterial(63, 64), int(MaterialId::Sand));
|
||||
CHECK_EQ(world.getMaterial(63, 65), int(MaterialId::Stone));
|
||||
CHECK(world.chunkCount() >= 2);
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld.fire_converts_water_to_steam") {
|
||||
PixelWorld world(3);
|
||||
world.setMaterial(10, 10, "fire");
|
||||
world.setMaterial(11, 10, "water");
|
||||
world.setMaterial(10, 11, "stone");
|
||||
world.setMaterial(11, 11, "stone");
|
||||
world.setMaterial(12, 10, "stone");
|
||||
world.setMaterial(12, 11, "stone");
|
||||
const StepStats stats = world.advance(eve::SimulationTick(1)).expect("reaction step");
|
||||
CHECK(stats.reactions > 0);
|
||||
const bool steamPresent = world.getMaterial(11, 10) == int(MaterialId::Steam) ||
|
||||
world.getMaterial(11, 9) == int(MaterialId::Steam);
|
||||
CHECK(steamPresent);
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld.fixed_seed_and_ticks_are_deterministic") {
|
||||
PixelWorld a(99), b(99);
|
||||
for (int x = -8; x <= 8; ++x) {
|
||||
a.setMaterial(x, 20, "stone");
|
||||
b.setMaterial(x, 20, "stone");
|
||||
}
|
||||
a.paintCircle(0, 2, 6, "sand");
|
||||
b.paintCircle(0, 2, 6, "sand");
|
||||
a.paintCircle(3, 0, 3, "water");
|
||||
b.paintCircle(3, 0, 3, "water");
|
||||
for (std::uint64_t tick = 1; tick <= 30; ++tick) {
|
||||
a.advance(eve::SimulationTick(tick)).expect("determinism a");
|
||||
b.advance(eve::SimulationTick(tick)).expect("determinism b");
|
||||
}
|
||||
auto aBytes = a.saveSnapshot().expect("snapshot a");
|
||||
auto bBytes = b.saveSnapshot().expect("snapshot b");
|
||||
REQUIRE_EQ(aBytes.size(), bBytes.size());
|
||||
CHECK(std::equal(aBytes.begin(), aBytes.end(), bBytes.begin()));
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld.snapshot_restore_is_transactional") {
|
||||
PixelWorld source(123);
|
||||
source.paintCircle(65, -2, 4, "oil");
|
||||
source.step();
|
||||
const auto bytes = source.saveSnapshot().expect("save source");
|
||||
|
||||
PixelWorld restored(1);
|
||||
restored.restoreSnapshot(bytes).expect("restore source");
|
||||
CHECK_EQ(restored.seed(), source.seed());
|
||||
CHECK_EQ(restored.tickValue(), source.tickValue());
|
||||
CHECK_EQ(restored.getMaterial(65, -1), source.getMaterial(65, -1));
|
||||
|
||||
std::vector<std::byte> malformed = bytes;
|
||||
malformed.pop_back();
|
||||
auto failed = restored.restoreSnapshot(malformed);
|
||||
CHECK(!failed.ok());
|
||||
CHECK_EQ(restored.seed(), source.seed());
|
||||
CHECK_EQ(restored.tickValue(), source.tickValue());
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld.rejects_non_monotonic_tick") {
|
||||
PixelWorld world;
|
||||
world.advance(eve::SimulationTick(2)).expect("first step");
|
||||
auto rejected = world.advance(eve::SimulationTick(2));
|
||||
CHECK(!rejected.ok());
|
||||
CHECK_EQ(rejected.error()->code(), eve::DiagnosticCode::PreconditionViolation);
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld.changed_chunk_snapshots_are_owned_canonical_and_incremental") {
|
||||
PixelWorld world(17);
|
||||
world.setMaterial(70, 2, "sand");
|
||||
world.setMaterial(-2, 2, "water");
|
||||
const std::uint64_t baseline = world.revision();
|
||||
|
||||
auto initial = world.snapshotChangedChunks(0);
|
||||
REQUIRE_EQ(initial.size(), std::size_t(2));
|
||||
CHECK(initial[0].x < initial[1].x);
|
||||
CHECK_EQ(initial[0].cells.size(), std::size_t(kPixelChunkSize * kPixelChunkSize));
|
||||
const PixelCell ownedCell = initial[0].cells[2 * kPixelChunkSize + 62];
|
||||
|
||||
world.setMaterial(70, 3, "stone");
|
||||
auto incremental = world.snapshotChangedChunks(baseline);
|
||||
REQUIRE_EQ(incremental.size(), std::size_t(1));
|
||||
CHECK_EQ(incremental[0].x, 1);
|
||||
CHECK_EQ(initial[0].cells[2 * kPixelChunkSize + 62], ownedCell);
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld.revision_tracks_lifetime_only_changes") {
|
||||
PixelWorld world(23);
|
||||
world.setMaterial(0, 0, "fire");
|
||||
const std::uint64_t before = world.revision();
|
||||
const StepStats stats = world.advance(eve::SimulationTick(1)).expect("fire lifetime step");
|
||||
CHECK(stats.cellsChanged > 0);
|
||||
CHECK(world.revision() > before);
|
||||
const auto changed = world.snapshotChangedChunks(before);
|
||||
CHECK(!changed.empty());
|
||||
CHECK(std::all_of(changed.begin(), changed.end(), [before](const PixelChunkSnapshot& chunk) {
|
||||
return chunk.revision > before;
|
||||
}));
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld.material_catalog_validates_names_and_drives_custom_reactions") {
|
||||
MaterialCatalog builtIn = MaterialCatalog::builtIn();
|
||||
std::vector<MaterialDefinition> definitions(builtIn.definitions().begin(), builtIn.definitions().end());
|
||||
const MaterialId brine = MaterialId(definitions.size());
|
||||
definitions.push_back(
|
||||
{brine, "brine", MaterialState::Liquid, 110, 35, 30, 90, 32767, 32767, 120});
|
||||
std::vector<MaterialReactionRule> reactions{
|
||||
{"brine.stone.hot-only", brine, MaterialId::Stone, MaterialId::Fire, MaterialId::Lava,
|
||||
1000, 0, 200},
|
||||
{"brine.stone.erode", brine, MaterialId::Stone, MaterialId::Water, MaterialId::Sand,
|
||||
-32768, 5, 100},
|
||||
};
|
||||
MaterialCatalog catalog =
|
||||
MaterialCatalog::create(std::move(definitions), std::move(reactions)).expect("custom catalog");
|
||||
PixelWorld world(41, catalog);
|
||||
|
||||
world.setMaterialChecked(10, 10, "brine").expect("place brine");
|
||||
world.setMaterial(11, 10, "stone");
|
||||
world.setMaterial(9, 10, "stone");
|
||||
world.setMaterial(10, 11, "stone");
|
||||
world.setMaterial(9, 11, "stone");
|
||||
world.setMaterial(11, 11, "stone");
|
||||
const std::uint64_t beforeUnknown = world.revision();
|
||||
auto unknown = world.setMaterialChecked(0, 0, "typo-material");
|
||||
CHECK(!unknown.ok());
|
||||
CHECK_EQ(unknown.error()->code(), eve::DiagnosticCode::NotFound);
|
||||
CHECK_EQ(world.revision(), beforeUnknown);
|
||||
|
||||
const StepStats stats = world.advance(eve::SimulationTick(1)).expect("custom reaction step");
|
||||
CHECK(stats.reactions > 0);
|
||||
const int center = world.getMaterial(10, 10);
|
||||
const bool adjacentSand = world.getMaterial(9, 10) == int(MaterialId::Sand) ||
|
||||
world.getMaterial(11, 10) == int(MaterialId::Sand) ||
|
||||
world.getMaterial(10, 11) == int(MaterialId::Sand);
|
||||
const bool adjacentWater = world.getMaterial(9, 10) == int(MaterialId::Water) ||
|
||||
world.getMaterial(11, 10) == int(MaterialId::Water) ||
|
||||
world.getMaterial(10, 11) == int(MaterialId::Water);
|
||||
const bool productsPresent = (center == int(MaterialId::Water) && adjacentSand) ||
|
||||
(center == int(MaterialId::Sand) && adjacentWater);
|
||||
CHECK(productsPresent);
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld.material_catalog_owns_color_and_canonical_tags") {
|
||||
MaterialCatalog builtIn = MaterialCatalog::builtIn();
|
||||
CHECK_EQ(builtIn.definition(MaterialId::Water).displayRgba, std::uint32_t(0x1F61F2DBU));
|
||||
PixelWorld world(42, builtIn);
|
||||
CHECK_EQ(world.materialDisplayRgba(MaterialId::Acid), std::uint32_t(0x4DEB3DE0U));
|
||||
|
||||
std::vector<MaterialDefinition> definitions(builtIn.definitions().begin(),
|
||||
builtIn.definitions().end());
|
||||
definitions[std::size_t(MaterialId::Sand)].tags = {"mobile", "granular"};
|
||||
MaterialCatalog canonical = MaterialCatalog::create(std::move(definitions)).expect("sorted tags");
|
||||
const auto& tags = canonical.definition(MaterialId::Sand).tags;
|
||||
REQUIRE_EQ(tags.size(), std::size_t(2));
|
||||
CHECK_EQ(tags[0], std::string("granular"));
|
||||
CHECK_EQ(tags[1], std::string("mobile"));
|
||||
|
||||
std::vector<MaterialDefinition> invalid(canonical.definitions().begin(),
|
||||
canonical.definitions().end());
|
||||
invalid[std::size_t(MaterialId::Sand)].tags = {"mobile", "mobile"};
|
||||
CHECK(!MaterialCatalog::create(std::move(invalid)).ok());
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld.material_catalog_json_roundtrips_and_rejects_unknown_schema") {
|
||||
const MaterialCatalog builtIn = MaterialCatalog::builtIn();
|
||||
const std::string json = encodeMaterialCatalogJson(builtIn).expect("encode Catalog JSON");
|
||||
const MaterialCatalog decoded = decodeMaterialCatalogJson(json).expect("decode Catalog JSON");
|
||||
CHECK_EQ(decoded.fingerprint(), builtIn.fingerprint());
|
||||
CHECK_EQ(decoded.definition(MaterialId::Fire).displayRgba,
|
||||
builtIn.definition(MaterialId::Fire).displayRgba);
|
||||
CHECK_EQ(decoded.reactions().size(), builtIn.reactions().size());
|
||||
CHECK_EQ(decoded.phaseRules().size(), builtIn.phaseRules().size());
|
||||
|
||||
std::string unknownField = json;
|
||||
unknownField.insert(unknownField.find('{') + 1, "\"unknown\":1,");
|
||||
CHECK(!decodeMaterialCatalogJson(unknownField).ok());
|
||||
std::string unknownVersion = json;
|
||||
const auto version = unknownVersion.find("\"version\": 1");
|
||||
REQUIRE(version != std::string::npos);
|
||||
unknownVersion.replace(version, std::string("\"version\": 1").size(), "\"version\": 2");
|
||||
CHECK(!decodeMaterialCatalogJson(unknownVersion).ok());
|
||||
CHECK(!decodeMaterialCatalogJson("{broken").ok());
|
||||
CHECK(!decodeMaterialCatalogJson(
|
||||
R"({"schema":"eve.pixelworld.material-catalog","version":1,"materials":{},"reactions":[],"phaseRules":[]})")
|
||||
.ok());
|
||||
CHECK(!decodeMaterialCatalogJson(
|
||||
R"({"schema":"eve.pixelworld.material-catalog","version":1,"materials":[7],"reactions":[],"phaseRules":[]})")
|
||||
.ok());
|
||||
std::string overflowingId = json;
|
||||
const auto firstId = overflowingId.find("\"id\": 0");
|
||||
REQUIRE(firstId != std::string::npos);
|
||||
overflowingId.replace(firstId, std::string("\"id\": 0").size(), "\"id\": 70000");
|
||||
CHECK(!decodeMaterialCatalogJson(overflowingId).ok());
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld.material_catalog_hot_reload_is_paused_compatible_and_transactional") {
|
||||
PixelWorld world(43);
|
||||
world.setMaterial(1, 1, "water");
|
||||
const auto originalLink = world.worldLink();
|
||||
const std::uint64_t originalFingerprint = world.materialCatalogFingerprint();
|
||||
MaterialCatalog builtIn = MaterialCatalog::builtIn();
|
||||
std::vector<MaterialDefinition> definitions(builtIn.definitions().begin(),
|
||||
builtIn.definitions().end());
|
||||
definitions[std::size_t(MaterialId::Water)].displayRgba = 0x12345678U;
|
||||
definitions[std::size_t(MaterialId::Water)].viscosity = 31;
|
||||
definitions[std::size_t(MaterialId::Water)].density = 60;
|
||||
MaterialCatalog replacement = MaterialCatalog::create(
|
||||
std::move(definitions),
|
||||
{builtIn.reactions().begin(), builtIn.reactions().end()},
|
||||
{builtIn.phaseRules().begin(), builtIn.phaseRules().end()})
|
||||
.expect("compatible Catalog");
|
||||
const std::string replacementJson =
|
||||
encodeMaterialCatalogJson(replacement).expect("encode replacement Catalog");
|
||||
|
||||
CHECK(!world.reloadMaterialCatalog(replacement, originalFingerprint).ok());
|
||||
CHECK_EQ(world.materialCatalogFingerprint(), originalFingerprint);
|
||||
world.setPaused(true);
|
||||
CHECK(!world.reloadMaterialCatalog(replacement, originalFingerprint + 1).ok());
|
||||
CHECK_EQ(world.materialCatalogFingerprint(), originalFingerprint);
|
||||
const auto receipt = world.reloadMaterialCatalog(std::move(replacement), originalFingerprint)
|
||||
.expect("paused Catalog reload");
|
||||
CHECK_EQ(receipt.fingerprintBefore, originalFingerprint);
|
||||
CHECK(receipt.fingerprintAfter != originalFingerprint);
|
||||
CHECK_EQ(receipt.chunksRebuilt, std::uint32_t(1));
|
||||
CHECK(receipt.replayHistoryInvalidated);
|
||||
CHECK(world.worldLink().epoch > originalLink.epoch);
|
||||
CHECK_EQ(world.materialDisplayRgba(MaterialId::Water), std::uint32_t(0x12345678U));
|
||||
CHECK_EQ(world.getMaterial(1, 1), int(MaterialId::Water));
|
||||
|
||||
world.setMaterial(1, 2, "oil");
|
||||
world.setMaterial(0, 1, "stone");
|
||||
world.setMaterial(2, 1, "stone");
|
||||
world.setMaterial(0, 2, "stone");
|
||||
world.setMaterial(2, 2, "stone");
|
||||
world.setPaused(false);
|
||||
world.advance(eve::SimulationTick(1)).expect("advance with rebuilt displacement table");
|
||||
CHECK_EQ(world.getMaterial(1, 1), int(MaterialId::Water));
|
||||
CHECK_EQ(world.getMaterial(1, 2), int(MaterialId::Oil));
|
||||
world.setPaused(true);
|
||||
|
||||
const std::uint64_t revisionBeforeNoOp = world.revision();
|
||||
const auto noOp = world.reloadMaterialCatalog(
|
||||
decodeMaterialCatalogJson(replacementJson)
|
||||
.expect("decode replacement Catalog"),
|
||||
world.materialCatalogFingerprint())
|
||||
.expect("same Catalog is a no-op");
|
||||
CHECK_EQ(noOp.revisionBefore, revisionBeforeNoOp);
|
||||
CHECK_EQ(noOp.revisionAfter, revisionBeforeNoOp);
|
||||
CHECK_EQ(noOp.worldEpoch, world.worldLink().epoch);
|
||||
CHECK(!noOp.replayHistoryInvalidated);
|
||||
|
||||
const MaterialCatalog incompatibleBase = MaterialCatalog::builtIn();
|
||||
std::vector<MaterialDefinition> incompatible(incompatibleBase.definitions().begin(),
|
||||
incompatibleBase.definitions().end());
|
||||
incompatible[std::size_t(MaterialId::Water)].name = "renamed-water";
|
||||
auto bad = MaterialCatalog::create(std::move(incompatible)).expect("valid but incompatible");
|
||||
const std::uint64_t revision = world.revision();
|
||||
CHECK(!world.reloadMaterialCatalog(std::move(bad), world.materialCatalogFingerprint()).ok());
|
||||
CHECK_EQ(world.revision(), revision);
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld.snapshot_rejects_material_catalog_mismatch_transactionally") {
|
||||
PixelWorld builtInWorld(5);
|
||||
builtInWorld.setMaterial(0, 0, "sand");
|
||||
const auto bytes = builtInWorld.saveSnapshot().expect("built-in snapshot");
|
||||
|
||||
MaterialCatalog builtIn = MaterialCatalog::builtIn();
|
||||
std::vector<MaterialDefinition> definitions(builtIn.definitions().begin(), builtIn.definitions().end());
|
||||
const MaterialId custom = MaterialId(definitions.size());
|
||||
definitions.push_back({custom, "custom", MaterialState::Powder, 150, 20, 10, 50});
|
||||
PixelWorld customWorld(
|
||||
9, MaterialCatalog::create(std::move(definitions)).expect("catalog with custom material"));
|
||||
customWorld.setMaterialChecked(3, 3, "custom").expect("place custom material");
|
||||
const std::uint64_t revisionBefore = customWorld.revision();
|
||||
|
||||
auto rejected = customWorld.restoreSnapshot(bytes);
|
||||
CHECK(!rejected.ok());
|
||||
CHECK_EQ(customWorld.revision(), revisionBefore);
|
||||
CHECK_EQ(customWorld.getMaterial(3, 3), int(custom));
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld.snapshot_v1_migrates_only_with_builtin_catalog") {
|
||||
PixelWorld source(77);
|
||||
source.setMaterial(4, 5, "water");
|
||||
std::vector<std::byte> legacy =
|
||||
withoutThermalRemainders(source.saveSnapshot().expect("v4 source"));
|
||||
REQUIRE(legacy.size() > 14);
|
||||
legacy[4] = std::byte{1};
|
||||
legacy[5] = std::byte{0};
|
||||
legacy.erase(legacy.begin() + 38, legacy.begin() + 46);
|
||||
legacy.erase(legacy.begin() + 6, legacy.begin() + 14);
|
||||
|
||||
PixelWorld restored(1);
|
||||
restored.restoreSnapshot(legacy).expect("v1 migration");
|
||||
CHECK_EQ(restored.seed(), std::uint64_t(77));
|
||||
CHECK_EQ(restored.getMaterial(4, 5), int(MaterialId::Water));
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld.snapshot_v2_migrates_with_zero_edit_sequence") {
|
||||
PixelWorld source(78);
|
||||
source.setMaterial(2, 3, "sand");
|
||||
std::vector<std::byte> version2 =
|
||||
withoutThermalRemainders(source.saveSnapshot().expect("v4 source"));
|
||||
REQUIRE(version2.size() > 46);
|
||||
version2[4] = std::byte{2};
|
||||
version2[5] = std::byte{0};
|
||||
version2.erase(version2.begin() + 38, version2.begin() + 46);
|
||||
|
||||
PixelWorld restored(1);
|
||||
restored.restoreSnapshot(version2).expect("v2 migration");
|
||||
CHECK_EQ(restored.getMaterial(2, 3), int(MaterialId::Sand));
|
||||
CHECK_EQ(restored.lastEditSequence(), std::uint64_t(0));
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld.snapshot_v3_migrates_with_zero_thermal_remainder") {
|
||||
PixelWorld source(79);
|
||||
source.setCell(2, 3, {MaterialId::Stone, 123, 0, 0});
|
||||
std::vector<std::byte> version3 =
|
||||
withoutThermalRemainders(source.saveSnapshot().expect("v4 source"));
|
||||
version3[4] = std::byte{3};
|
||||
version3[5] = std::byte{0};
|
||||
|
||||
PixelWorld restored(1);
|
||||
restored.restoreSnapshot(version3).expect("v3 migration");
|
||||
CHECK_EQ(restored.getCell(2, 3).temperature, std::int16_t(123));
|
||||
CHECK_EQ(restored.getCell(2, 3).thermalRemainder, std::uint16_t(0));
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld.fixed_point_heat_diffusion_is_directional_and_revisioned") {
|
||||
PixelWorld world(88);
|
||||
world.setCell(0, 0, {MaterialId::Stone, 1000, 0});
|
||||
world.setCell(1, 0, {MaterialId::Stone, 0, 0});
|
||||
const std::uint64_t before = world.revision();
|
||||
const StepStats stats = world.advance(eve::SimulationTick(1)).expect("thermal step");
|
||||
CHECK(stats.temperatureTransfers > 0);
|
||||
CHECK(world.getCell(0, 0).temperature < 1000);
|
||||
CHECK(world.getCell(1, 0).temperature > 0);
|
||||
CHECK(world.revision() > before);
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld.thermal_remainders_conserve_energy_and_roundtrip_authority") {
|
||||
const MaterialCatalog builtIn = MaterialCatalog::builtIn();
|
||||
std::vector<MaterialDefinition> definitions(builtIn.definitions().begin(),
|
||||
builtIn.definitions().end());
|
||||
definitions[std::size_t(MaterialId::Stone)].heatCapacity = 7;
|
||||
definitions[std::size_t(MaterialId::Stone)].thermalConductivity = 13;
|
||||
definitions[std::size_t(MaterialId::Wood)].heatCapacity = 11;
|
||||
definitions[std::size_t(MaterialId::Wood)].thermalConductivity = 13;
|
||||
const MaterialCatalog catalog =
|
||||
MaterialCatalog::create(std::move(definitions)).expect("thermal test Catalog");
|
||||
PixelWorld world(8801, catalog);
|
||||
world.setCell(0, 0, {MaterialId::Stone, 1000, 0, 3});
|
||||
world.setCell(1, 0, {MaterialId::Wood, -200, 0, 5});
|
||||
const auto energy = [&](const PixelWorld& value) {
|
||||
const PixelCell stone = value.getCell(0, 0);
|
||||
const PixelCell wood = value.getCell(1, 0);
|
||||
return std::int64_t(stone.temperature) * 7 + stone.thermalRemainder +
|
||||
std::int64_t(wood.temperature) * 11 + wood.thermalRemainder;
|
||||
};
|
||||
const std::int64_t initialEnergy = energy(world);
|
||||
std::uint64_t transferred = 0;
|
||||
for (std::uint64_t tick = 1; tick <= 200; ++tick) {
|
||||
const auto stats = world.advance(eve::SimulationTick(tick)).expect("conservative heat step");
|
||||
transferred += stats.thermalEnergyTransferred;
|
||||
CHECK_EQ(stats.thermalEnergyClamped, std::uint64_t(0));
|
||||
CHECK_EQ(energy(world), initialEnergy);
|
||||
}
|
||||
CHECK(transferred > 0);
|
||||
CHECK(world.getCell(0, 0).thermalRemainder < 7);
|
||||
CHECK(world.getCell(1, 0).thermalRemainder < 11);
|
||||
|
||||
const auto snapshot = world.saveSnapshot().expect("thermal v4 snapshot");
|
||||
PixelWorld restored(2, catalog);
|
||||
restored.restoreSnapshot(snapshot).expect("restore thermal v4 snapshot");
|
||||
CHECK_EQ(restored.getCell(0, 0), world.getCell(0, 0));
|
||||
CHECK_EQ(restored.getCell(1, 0), world.getCell(1, 0));
|
||||
|
||||
auto malformed = snapshot;
|
||||
REQUIRE(malformed.size() >= 67);
|
||||
const std::uint16_t invalidRemainder = std::numeric_limits<std::uint16_t>::max();
|
||||
std::memcpy(malformed.data() + 63, &invalidRemainder, sizeof(invalidRemainder));
|
||||
const auto beforeRejectedRestore = restored.saveSnapshot().expect("before bad remainder");
|
||||
CHECK(!restored.restoreSnapshot(malformed).ok());
|
||||
const auto afterRejectedRestore = restored.saveSnapshot().expect("after bad remainder");
|
||||
CHECK(std::equal(beforeRejectedRestore.begin(), beforeRejectedRestore.end(),
|
||||
afterRejectedRestore.begin()));
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld.phase_rules_freeze_boil_and_melt_without_hardcoded_branches") {
|
||||
PixelWorld world(89);
|
||||
world.setCell(10, 10, {MaterialId::Water, -20, 0});
|
||||
world.setCell(20, 10, {MaterialId::Water, 130, 0});
|
||||
world.setCell(30, 10, {MaterialId::Ice, 20, 0});
|
||||
for (const int center : {10, 20}) {
|
||||
world.setMaterial(center - 1, 10, "stone");
|
||||
world.setMaterial(center + 1, 10, "stone");
|
||||
world.setMaterial(center - 1, 11, "stone");
|
||||
world.setMaterial(center, 11, "stone");
|
||||
world.setMaterial(center + 1, 11, "stone");
|
||||
}
|
||||
const StepStats stats = world.advance(eve::SimulationTick(1)).expect("phase step");
|
||||
CHECK(stats.phaseChanges >= 3);
|
||||
CHECK_EQ(world.getMaterial(10, 10), int(MaterialId::Ice));
|
||||
CHECK_EQ(world.getMaterial(20, 10), int(MaterialId::Steam));
|
||||
CHECK_EQ(world.getMaterial(30, 10), int(MaterialId::Water));
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld.explosion_uses_resistance_heat_and_atomic_sequence_receipt") {
|
||||
PixelWorld world(90);
|
||||
world.setMaterial(0, 0, "wood");
|
||||
world.setMaterial(1, 0, "stone");
|
||||
world.setMaterial(-1, 0, "gunpowder");
|
||||
PixelEditCommand command;
|
||||
command.sequence = 1;
|
||||
command.kind = PixelEditKind::Explosion;
|
||||
command.radius = 4;
|
||||
command.strength = 120;
|
||||
command.temperatureDelta = 400;
|
||||
const PixelEditReceipt receipt = world.applyEdit(command).expect("explosion command");
|
||||
|
||||
CHECK_EQ(receipt.sequence, std::uint64_t(1));
|
||||
CHECK(receipt.cellsRemoved >= 2);
|
||||
CHECK_EQ(world.getMaterial(0, 0), int(MaterialId::Air));
|
||||
CHECK_EQ(world.getMaterial(-1, 0), int(MaterialId::Air));
|
||||
CHECK_EQ(world.getMaterial(1, 0), int(MaterialId::Stone));
|
||||
CHECK(world.getCell(1, 0).temperature > 20);
|
||||
CHECK_EQ(world.lastEditSequence(), std::uint64_t(1));
|
||||
CHECK(receipt.revisionAfter > receipt.revisionBefore);
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld.edit_sequence_gap_rejects_without_partial_mutation_and_roundtrips") {
|
||||
PixelWorld world(91);
|
||||
world.setMaterial(5, 5, "wood");
|
||||
world.explode(5, 5, 2, 100, 100);
|
||||
const auto before = world.saveSnapshot().expect("before rejected edit");
|
||||
|
||||
PixelEditCommand gap;
|
||||
gap.sequence = 3;
|
||||
gap.kind = PixelEditKind::HeatCircle;
|
||||
gap.centerX = 5;
|
||||
gap.centerY = 5;
|
||||
gap.radius = 2;
|
||||
gap.temperatureDelta = 500;
|
||||
auto rejected = world.applyEdit(gap);
|
||||
CHECK(!rejected.ok());
|
||||
const auto after = world.saveSnapshot().expect("after rejected edit");
|
||||
REQUIRE_EQ(before.size(), after.size());
|
||||
CHECK(std::equal(before.begin(), before.end(), after.begin()));
|
||||
|
||||
PixelWorld restored(1);
|
||||
restored.restoreSnapshot(before).expect("restore edit sequence");
|
||||
CHECK_EQ(restored.lastEditSequence(), std::uint64_t(1));
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld.unsupported_solids_detach_canonically_and_rasterize_atomically") {
|
||||
PixelWorld world(92);
|
||||
for (int x = -2; x <= 2; ++x) world.setMaterial(x, 10, "stone");
|
||||
world.setMaterial(-1, 2, "wood");
|
||||
world.setMaterial(0, 2, "wood");
|
||||
world.setMaterial(0, 3, "wood");
|
||||
world.setMaterial(4, 9, "stone");
|
||||
world.setMaterial(4, 10, "stone");
|
||||
|
||||
auto fragments = world.extractUnsupportedFragments({-4, 0, 6, 10}, 10, 2)
|
||||
.expect("extract unsupported component");
|
||||
REQUIRE_EQ(fragments.size(), std::size_t(1));
|
||||
const PixelFragment& fragment = fragments.front();
|
||||
CHECK_EQ(fragment.originX, -1);
|
||||
CHECK_EQ(fragment.originY, 2);
|
||||
CHECK_EQ(fragment.width, 2);
|
||||
CHECK_EQ(fragment.height, 2);
|
||||
CHECK_EQ(fragment.solidCellCount, std::uint32_t(3));
|
||||
CHECK_EQ(world.getMaterial(-1, 2), int(MaterialId::Air));
|
||||
CHECK_EQ(world.getMaterial(0, 3), int(MaterialId::Air));
|
||||
CHECK_EQ(world.getMaterial(4, 9), int(MaterialId::Stone));
|
||||
|
||||
world.setMaterial(20, 20, "stone");
|
||||
const auto beforeConflict = world.saveSnapshot().expect("before fragment conflict");
|
||||
auto conflict = world.rasterizeFragment(fragment, 20, 20);
|
||||
CHECK(!conflict.ok());
|
||||
const auto afterConflict = world.saveSnapshot().expect("after fragment conflict");
|
||||
CHECK(std::equal(beforeConflict.begin(), beforeConflict.end(), afterConflict.begin()));
|
||||
|
||||
const auto receipt = world.rasterizeFragment(fragment, 30, 30).expect("rasterize fragment");
|
||||
CHECK_EQ(receipt.cellsPlaced, std::uint32_t(3));
|
||||
CHECK_EQ(world.getMaterial(30, 30), int(MaterialId::Wood));
|
||||
CHECK_EQ(world.getMaterial(31, 31), int(MaterialId::Wood));
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld.fragment_link_is_stale_after_restore_or_clear") {
|
||||
PixelWorld world(93);
|
||||
world.setMaterial(0, 0, "wood");
|
||||
const auto checkpoint = world.saveSnapshot().expect("fragment checkpoint");
|
||||
auto fragment = world.extractUnsupportedFragments({-1, -1, 1, 1}, 10)
|
||||
.expect("extract before restore").front();
|
||||
world.restoreSnapshot(checkpoint).expect("restore invalidates links");
|
||||
CHECK(!world.rasterizeFragment(fragment, 5, 5).ok());
|
||||
|
||||
auto second = world.extractUnsupportedFragments({-1, -1, 1, 1}, 10)
|
||||
.expect("extract before clear").front();
|
||||
world.clear();
|
||||
CHECK(!world.rasterizeFragment(second, 5, 5).ok());
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld.empty_chunks_sleep_with_hysteresis_and_emit_removal_tombstones") {
|
||||
PixelWorld world(94);
|
||||
world.setMaterial(3, 4, "stone");
|
||||
const auto populatedRevision = world.revision();
|
||||
world.setMaterial(3, 4, "air");
|
||||
CHECK_EQ(world.chunkCount(), 1);
|
||||
|
||||
for (std::uint64_t tick = 1; tick < kPixelSleepHysteresisTicks; ++tick) {
|
||||
const auto stats = world.advance(eve::SimulationTick(tick)).expect("idle hysteresis tick");
|
||||
CHECK_EQ(stats.chunksReclaimed, std::uint32_t(0));
|
||||
CHECK_EQ(world.chunkCount(), 1);
|
||||
}
|
||||
const auto reclaimed = world.advance(eve::SimulationTick(kPixelSleepHysteresisTicks))
|
||||
.expect("chunk reclaim tick");
|
||||
CHECK_EQ(reclaimed.chunksReclaimed, std::uint32_t(1));
|
||||
CHECK_EQ(world.chunkCount(), 0);
|
||||
|
||||
const auto changed = world.snapshotChangedChunks(populatedRevision);
|
||||
REQUIRE_EQ(changed.size(), std::size_t(1));
|
||||
CHECK(changed.front().removed);
|
||||
CHECK(changed.front().cells.empty());
|
||||
CHECK(changed.front().revision > populatedRevision);
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld.authoritative_chunk_batch_replaces_and_removes_transactionally") {
|
||||
PixelWorld authority(95);
|
||||
authority.setMaterial(2, 3, "wood");
|
||||
const std::uint64_t populatedRevision = authority.revision();
|
||||
PixelChunkBatch populated;
|
||||
populated.catalogFingerprint = authority.materialCatalogFingerprint();
|
||||
populated.sourceSeed = authority.seed();
|
||||
populated.sourceRevision = populatedRevision;
|
||||
populated.sourceTick = eve::SimulationTick(authority.tickValue());
|
||||
populated.sourceLastEditSequence = authority.lastEditSequence();
|
||||
populated.chunks = authority.snapshotChangedChunks(0);
|
||||
|
||||
PixelWorld replica(95);
|
||||
const auto applied = replica.applyChunkBatch(populated, 0).expect("apply populated Chunk");
|
||||
CHECK_EQ(applied.chunksReplaced, std::uint32_t(1));
|
||||
CHECK_EQ(replica.getMaterial(2, 3), int(MaterialId::Wood));
|
||||
CHECK_EQ(replica.revision(), authority.revision());
|
||||
|
||||
auto fragment = replica.extractUnsupportedFragments({0, 0, 5, 5}, 20)
|
||||
.expect("extract replica fragment")
|
||||
.front();
|
||||
authority.setMaterial(2, 3, "air");
|
||||
for (std::uint64_t tick = 1; tick <= kPixelSleepHysteresisTicks; ++tick)
|
||||
authority.advance(eve::SimulationTick(tick)).expect("reclaim authority Chunk");
|
||||
PixelChunkBatch removed;
|
||||
removed.catalogFingerprint = authority.materialCatalogFingerprint();
|
||||
removed.sourceSeed = authority.seed();
|
||||
removed.sourceRevision = authority.revision();
|
||||
removed.sourceTick = eve::SimulationTick(authority.tickValue());
|
||||
removed.sourceLastEditSequence = authority.lastEditSequence();
|
||||
removed.chunks = authority.snapshotChangedChunks(populatedRevision);
|
||||
const auto removedReceipt = replica.applyChunkBatch(removed, replica.revision()).expect("apply tombstone");
|
||||
CHECK_EQ(removedReceipt.chunksRemoved, std::uint32_t(1));
|
||||
CHECK_EQ(replica.chunkCount(), 0);
|
||||
CHECK(!replica.rasterizeFragment(fragment, 10, 10).ok());
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld.authoritative_chunk_batch_rejects_stale_or_malformed_without_mutation") {
|
||||
PixelWorld world(96);
|
||||
world.setMaterial(0, 0, "stone");
|
||||
const auto before = world.saveSnapshot().expect("before bad correction");
|
||||
|
||||
PixelChunkBatch stale;
|
||||
stale.catalogFingerprint = world.materialCatalogFingerprint();
|
||||
stale.sourceSeed = world.seed();
|
||||
stale.sourceRevision = world.revision();
|
||||
stale.sourceTick = eve::SimulationTick(world.tickValue());
|
||||
CHECK(!world.applyChunkBatch(stale, world.revision() - 1).ok());
|
||||
|
||||
PixelChunkSnapshot malformed;
|
||||
malformed.revision = world.revision();
|
||||
malformed.cells.resize(1);
|
||||
stale.chunks.push_back(std::move(malformed));
|
||||
CHECK(!world.applyChunkBatch(stale, world.revision()).ok());
|
||||
const auto after = world.saveSnapshot().expect("after bad correction");
|
||||
REQUIRE_EQ(before.size(), after.size());
|
||||
CHECK(std::equal(before.begin(), before.end(), after.begin()));
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
#include "zeroerr/assert.h"
|
||||
#include "zeroerr/unittest.h"
|
||||
|
||||
#include "pixelworld/PixelWorldControl.h"
|
||||
#include "pixelworld/PixelMaterialCatalogCodec.h"
|
||||
|
||||
#include <utility>
|
||||
|
||||
using namespace eve::pixelworld;
|
||||
|
||||
TEST_CASE("pixelworld.control_registry_tracks_lifetime_and_move") {
|
||||
auto& control = pixelWorldControlService();
|
||||
std::uint64_t id = 0;
|
||||
{
|
||||
PixelWorld original(71);
|
||||
id = original.worldLink().world;
|
||||
REQUIRE(control.world(id).ok());
|
||||
PixelWorld moved(std::move(original));
|
||||
auto summary = control.world(id).expect("moved world remains registered");
|
||||
CHECK_EQ(summary.seed, std::uint64_t(71));
|
||||
CHECK_EQ(summary.link.world, id);
|
||||
}
|
||||
CHECK(!control.world(id).ok());
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld.control_pause_and_explicit_step_have_one_authority") {
|
||||
PixelWorld world(72);
|
||||
const std::uint64_t id = world.worldLink().world;
|
||||
auto& control = pixelWorldControlService();
|
||||
control.setPaused(id, true).expect("pause world");
|
||||
const StepStats paused = world.step();
|
||||
CHECK_EQ(paused.tick.value(), std::uint64_t(0));
|
||||
CHECK_EQ(world.tickValue(), std::uint64_t(0));
|
||||
|
||||
const auto receipt = control.step(id, 3).expect("explicit paused steps");
|
||||
CHECK_EQ(receipt.steps, std::uint32_t(3));
|
||||
CHECK_EQ(receipt.firstTick, std::uint64_t(1));
|
||||
CHECK_EQ(receipt.lastTick, std::uint64_t(3));
|
||||
CHECK_EQ(world.tickValue(), std::uint64_t(3));
|
||||
CHECK(world.isPaused());
|
||||
CHECK(!control.step(id, 0).ok());
|
||||
CHECK(!control.step(id, 1025).ok());
|
||||
const auto samples = control.performanceSamples(id, 2).expect("bounded samples");
|
||||
REQUIRE_EQ(samples.size(), std::size_t(2));
|
||||
CHECK_EQ(samples.front().tick, std::uint64_t(2));
|
||||
CHECK_EQ(samples.back().tick, std::uint64_t(3));
|
||||
CHECK(!control.performanceSamples(id, 0).ok());
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld.control_edit_snapshot_and_diagnostics_are_transactional") {
|
||||
PixelWorld world(73);
|
||||
const std::uint64_t id = world.worldLink().world;
|
||||
auto& control = pixelWorldControlService();
|
||||
PixelEditCommand paint;
|
||||
paint.sequence = 1;
|
||||
paint.kind = PixelEditKind::PaintCircle;
|
||||
paint.radius = 1;
|
||||
paint.material = MaterialId::Water;
|
||||
control.applyEdit(id, paint).expect("control paint");
|
||||
|
||||
const auto diagnostics = control.chunkDiagnostics(id, {0, 0, 0, 0})
|
||||
.expect("bounded diagnostics");
|
||||
REQUIRE_EQ(diagnostics.size(), std::size_t(1));
|
||||
CHECK_EQ(diagnostics.front().nonAirCells, std::uint32_t(5));
|
||||
CHECK_EQ(diagnostics.front().mobileCells, std::uint32_t(5));
|
||||
CHECK_EQ(diagnostics.front().minimumTemperature, std::int16_t(20));
|
||||
CHECK(!control.chunkDiagnostics(id, {0, 0, 256, 256}).ok());
|
||||
|
||||
const auto snapshot = control.captureSnapshot(id).expect("control snapshot");
|
||||
world.setMaterial(8, 8, "stone");
|
||||
REQUIRE_EQ(world.getMaterial(8, 8), int(MaterialId::Stone));
|
||||
control.restoreSnapshot(id, snapshot).expect("control restore");
|
||||
CHECK_EQ(world.getMaterial(8, 8), int(MaterialId::Air));
|
||||
|
||||
auto malformed = snapshot;
|
||||
malformed.pop_back();
|
||||
const std::uint64_t revision = world.revision();
|
||||
CHECK(!control.restoreSnapshot(id, malformed).ok());
|
||||
CHECK_EQ(world.revision(), revision);
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld.control_catalog_reload_reuses_validated_authority_path") {
|
||||
PixelWorld world(75);
|
||||
auto& control = pixelWorldControlService();
|
||||
control.setPaused(world.worldLink().world, true).expect("pause authoring world");
|
||||
MaterialCatalog builtIn = MaterialCatalog::builtIn();
|
||||
std::vector<MaterialDefinition> definitions(builtIn.definitions().begin(),
|
||||
builtIn.definitions().end());
|
||||
definitions[std::size_t(MaterialId::Stone)].displayRgba = 0x010203FFU;
|
||||
auto candidate = MaterialCatalog::create(
|
||||
std::move(definitions),
|
||||
{builtIn.reactions().begin(), builtIn.reactions().end()},
|
||||
{builtIn.phaseRules().begin(), builtIn.phaseRules().end()})
|
||||
.expect("authoring candidate");
|
||||
const std::string json = encodeMaterialCatalogJson(candidate).expect("authoring JSON");
|
||||
control.reloadMaterialCatalog(world.worldLink().world, json,
|
||||
world.materialCatalogFingerprint())
|
||||
.expect("control Catalog reload");
|
||||
CHECK_EQ(world.materialDisplayRgba(MaterialId::Stone), std::uint32_t(0x010203FFU));
|
||||
CHECK(!control.reloadMaterialCatalog(world.worldLink().world, "{}",
|
||||
world.materialCatalogFingerprint())
|
||||
.ok());
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
#include "zeroerr/assert.h"
|
||||
#include "zeroerr/unittest.h"
|
||||
|
||||
#include "pixelworld/PixelMaterialCatalogCodec.h"
|
||||
#include "pixelworld/PixelWorld.h"
|
||||
#include "pixelworld_editor/PixelWorldCatalogPanel.h"
|
||||
#include "ui/UIHost.h"
|
||||
|
||||
#include <string>
|
||||
|
||||
using namespace eve::pixelworld;
|
||||
using namespace eve::pixelworld_editor;
|
||||
|
||||
namespace {
|
||||
|
||||
eve::ui::WidgetDesc* findWidget(eve::ui::WidgetDesc& root, const std::string& id) {
|
||||
if (root.id == id) return &root;
|
||||
for (auto& child : root.children)
|
||||
if (auto* found = findWidget(child, id)) return found;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("pixelworld_editor_material_draft_is_transactional_and_codec_canonical") {
|
||||
auto panel = PixelWorldCatalogPanel::builtIn();
|
||||
const auto initialRevision = panel.revision();
|
||||
const auto initialFingerprint = panel.draft().fingerprint();
|
||||
MaterialDefinition stone = panel.draft().definition(MaterialId::Stone);
|
||||
stone.displayRgba = 0x102030FFU;
|
||||
const auto receipt = panel.replaceMaterial(stone).expect("replace material draft");
|
||||
CHECK_EQ(receipt.revisionBefore, initialRevision);
|
||||
CHECK_EQ(receipt.revisionAfter, initialRevision + 1);
|
||||
CHECK(receipt.fingerprint != initialFingerprint);
|
||||
CHECK_EQ(panel.draft().definition(MaterialId::Stone).displayRgba,
|
||||
std::uint32_t(0x102030FFU));
|
||||
|
||||
const auto beforeFailure = panel.document().expect("document before failure");
|
||||
stone.name = "water";
|
||||
CHECK(!panel.replaceMaterial(stone).ok());
|
||||
CHECK_EQ(panel.revision(), initialRevision + 1);
|
||||
CHECK_EQ(panel.document().expect("document after failure"), beforeFailure);
|
||||
|
||||
auto decoded = PixelWorldCatalogPanel::create(beforeFailure).expect("decode panel document");
|
||||
CHECK_EQ(decoded.draft().fingerprint(), panel.draft().fingerprint());
|
||||
CHECK(!panel.loadDocument("{broken").ok());
|
||||
CHECK_EQ(panel.document().expect("document after malformed load"), beforeFailure);
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld_editor_reaction_and_phase_operations_validate_whole_catalog") {
|
||||
auto panel = PixelWorldCatalogPanel::builtIn();
|
||||
MaterialReactionRule reaction;
|
||||
reaction.id = "editor-test-reaction";
|
||||
reaction.first = MaterialId::Water;
|
||||
reaction.second = MaterialId::Lava;
|
||||
reaction.firstResult = MaterialId::Steam;
|
||||
reaction.secondResult = MaterialId::Stone;
|
||||
reaction.heatDelta = 25;
|
||||
panel.addReaction(reaction).expect("add reaction");
|
||||
REQUIRE(panel.selectedReactionIndex().has_value());
|
||||
CHECK_EQ(panel.draft().reactions()[*panel.selectedReactionIndex()].id,
|
||||
std::string("editor-test-reaction"));
|
||||
|
||||
auto duplicate = reaction;
|
||||
duplicate.first = MaterialId::Oil;
|
||||
CHECK(!panel.addReaction(std::move(duplicate)).ok());
|
||||
|
||||
MaterialPhaseRule phase;
|
||||
phase.id = "editor-test-phase";
|
||||
phase.source = MaterialId::Water;
|
||||
phase.result = MaterialId::Steam;
|
||||
phase.threshold = 120;
|
||||
panel.addPhase(phase).expect("add phase");
|
||||
REQUIRE(panel.selectedPhaseIndex().has_value());
|
||||
CHECK_EQ(panel.draft().phaseRules()[*panel.selectedPhaseIndex()].id,
|
||||
std::string("editor-test-phase"));
|
||||
panel.removePhase(*panel.selectedPhaseIndex()).expect("remove phase");
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld_editor_widget_tree_exposes_visual_material_and_rule_controls") {
|
||||
auto panel = PixelWorldCatalogPanel::builtIn();
|
||||
auto tree = panel.buildWidgetTree();
|
||||
CHECK_EQ(tree.id, std::string("pixelworld-catalog-editor"));
|
||||
REQUIRE(findWidget(tree, "catalog-browser") != nullptr);
|
||||
REQUIRE(findWidget(tree, "material-preview") != nullptr);
|
||||
REQUIRE(findWidget(tree, "reaction-section") != nullptr);
|
||||
REQUIRE(findWidget(tree, "phase-section") != nullptr);
|
||||
|
||||
auto* stone = findWidget(tree, "material/1");
|
||||
REQUIRE(stone != nullptr);
|
||||
REQUIRE(bool(stone->onClick));
|
||||
stone->onClick();
|
||||
CHECK_EQ(panel.selectedMaterialIndex(), std::size_t(1));
|
||||
|
||||
tree = panel.buildWidgetTree();
|
||||
auto* rgba = findWidget(tree, "material-rgba");
|
||||
REQUIRE(rgba != nullptr);
|
||||
REQUIRE(bool(rgba->onTextChange));
|
||||
rgba->onTextChange("#112233FF");
|
||||
CHECK_EQ(panel.draft().definition(MaterialId::Stone).displayRgba,
|
||||
std::uint32_t(0x112233FFU));
|
||||
CHECK(panel.statusText().find("validated") != std::string::npos);
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld_editor_mounts_a_live_panel_and_reconciles_callback_edits") {
|
||||
auto panel = PixelWorldCatalogPanel::builtIn();
|
||||
const auto hostHandle = panel.open().expect("open catalog panel");
|
||||
REQUIRE(hostHandle);
|
||||
CHECK(panel.isOpen());
|
||||
auto host = eve::ui::UIHost::resolve(hostHandle);
|
||||
REQUIRE(host.has_value());
|
||||
auto stone = host->get().findById("material/1");
|
||||
REQUIRE(stone.has_value());
|
||||
const auto handler = stone->get().handlerClick;
|
||||
REQUIRE(handler > 0);
|
||||
auto click = host->get().tree()->clickHandlers[handler - 1];
|
||||
REQUIRE(bool(click));
|
||||
click();
|
||||
CHECK_EQ(panel.selectedMaterialIndex(), std::size_t(1));
|
||||
CHECK(host->get().findById("material-rgba").has_value());
|
||||
|
||||
panel.close();
|
||||
CHECK(!panel.isOpen());
|
||||
panel.open().expect("reopen catalog panel");
|
||||
CHECK(panel.isOpen());
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld_editor_apply_reuses_paused_authority_and_fingerprint_guard") {
|
||||
PixelWorld world(8601);
|
||||
auto& control = pixelWorldControlService();
|
||||
const auto worldId = world.worldLink().world;
|
||||
control.setPaused(worldId, true).expect("pause world");
|
||||
auto panel = PixelWorldCatalogPanel::builtIn();
|
||||
MaterialDefinition stone = panel.draft().definition(MaterialId::Stone);
|
||||
stone.displayRgba = 0xABCDEF88U;
|
||||
panel.replaceMaterial(std::move(stone)).expect("edit draft");
|
||||
const auto expected = world.materialCatalogFingerprint();
|
||||
panel.apply(control, worldId, expected).expect("apply Catalog panel");
|
||||
CHECK_EQ(world.materialDisplayRgba(MaterialId::Stone), std::uint32_t(0xABCDEF88U));
|
||||
CHECK(!panel.apply(control, worldId, expected).ok());
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
#include "zeroerr/assert.h"
|
||||
#include "zeroerr/unittest.h"
|
||||
|
||||
#include "pixelworld/PixelWorldGeneration.h"
|
||||
#include "pixelworld/PixelWorldGenerationCodec.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <limits>
|
||||
#include <set>
|
||||
#include <string>
|
||||
|
||||
using namespace eve::pixelworld;
|
||||
|
||||
TEST_CASE("pixelworld_generation.is_bit_exact_seamless_and_seed_sensitive") {
|
||||
const MaterialCatalog catalog = MaterialCatalog::builtIn();
|
||||
PixelWorldGenerationRequest request;
|
||||
request.seed = 0xC0FFEE;
|
||||
request.region = {-3, -1, 3, 2};
|
||||
request.surfaceY = 4;
|
||||
request.terrainAmplitude = 20;
|
||||
request.waterLevel = 48;
|
||||
request.caveThreshold = 4200;
|
||||
|
||||
const auto first = generatePixelWorld(request, catalog, 1).expect("first generated world");
|
||||
const auto second = generatePixelWorld(request, catalog, 1).expect("second generated world");
|
||||
CHECK_EQ(first.contentHash, second.contentHash);
|
||||
CHECK_EQ(first.batch, second.batch);
|
||||
CHECK_EQ(first.chunks, second.chunks);
|
||||
CHECK(std::any_of(first.chunks.begin(), first.chunks.end(), [](const auto& chunk) {
|
||||
return chunk.caveCells > 0;
|
||||
}));
|
||||
CHECK(std::any_of(first.chunks.begin(), first.chunks.end(), [](const auto& chunk) {
|
||||
return chunk.solidCells > 0;
|
||||
}));
|
||||
|
||||
PixelWorldGenerationRequest single = request;
|
||||
single.region = {0, 0, 0, 0};
|
||||
const auto isolated = generatePixelWorld(single, catalog, 1).expect("isolated Chunk");
|
||||
const auto wholeChunk = std::find_if(first.batch.chunks.begin(), first.batch.chunks.end(),
|
||||
[](const auto& chunk) {
|
||||
return chunk.x == 0 && chunk.y == 0;
|
||||
});
|
||||
REQUIRE(wholeChunk != first.batch.chunks.end());
|
||||
REQUIRE_EQ(isolated.batch.chunks.size(), std::size_t(1));
|
||||
CHECK_EQ(wholeChunk->cells, isolated.batch.chunks.front().cells);
|
||||
|
||||
request.seed += 1;
|
||||
const auto different = generatePixelWorld(request, catalog, 1).expect("different seed");
|
||||
CHECK(different.contentHash != first.contentHash);
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld_generation.stamps_cross_chunk_boundaries_and_apply_transactionally") {
|
||||
const MaterialCatalog catalog = MaterialCatalog::builtIn();
|
||||
PixelWorldGenerationRequest request;
|
||||
request.seed = 77;
|
||||
request.region = {0, 0, 1, 0};
|
||||
request.surfaceY = 0;
|
||||
request.caveThreshold = 0;
|
||||
PixelMaterialStamp stamp;
|
||||
stamp.originX = 62;
|
||||
stamp.originY = 10;
|
||||
stamp.width = 4;
|
||||
stamp.height = 2;
|
||||
stamp.cells.assign(8, {MaterialId::Wood, 20, 0, 0});
|
||||
request.stamps.push_back(stamp);
|
||||
|
||||
const auto generated = generatePixelWorld(request, catalog, 1).expect("stamped generation");
|
||||
REQUIRE_EQ(generated.batch.chunks.size(), std::size_t(2));
|
||||
CHECK_EQ(generated.chunks[0].stampedCells, std::uint32_t(4));
|
||||
CHECK_EQ(generated.chunks[1].stampedCells, std::uint32_t(4));
|
||||
|
||||
PixelWorld world(request.seed, catalog);
|
||||
const auto receipt = world.applyChunkBatch(generated.batch, 0).expect("apply generated batch");
|
||||
CHECK_EQ(receipt.chunksReplaced, std::uint32_t(2));
|
||||
CHECK_EQ(world.revision(), std::uint64_t(1));
|
||||
for (int y = 10; y < 12; ++y)
|
||||
for (int x = 62; x < 66; ++x)
|
||||
CHECK_EQ(world.getMaterial(x, y), int(MaterialId::Wood));
|
||||
|
||||
const auto before = world.saveSnapshot().expect("before invalid generation");
|
||||
PixelWorldGenerationRequest invalid = request;
|
||||
invalid.schemaVersion = 99;
|
||||
CHECK(!generatePixelWorld(invalid, catalog, 2).ok());
|
||||
const auto after = world.saveSnapshot().expect("after invalid generation");
|
||||
REQUIRE_EQ(after.size(), before.size());
|
||||
CHECK(std::equal(after.begin(), after.end(), before.begin()));
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld_generation.codec_round_trips_and_rejects_unknown_data") {
|
||||
PixelWorldGenerationRequest request;
|
||||
request.seed = std::numeric_limits<std::uint64_t>::max();
|
||||
request.region = {-2, 3, 4, 5};
|
||||
request.surfaceY = -17;
|
||||
request.terrainAmplitude = 91;
|
||||
request.waterLevel = 73;
|
||||
request.caveThreshold = 1234;
|
||||
PixelMaterialStamp stamp;
|
||||
stamp.originX = -65;
|
||||
stamp.originY = 7;
|
||||
stamp.width = 1;
|
||||
stamp.height = 1;
|
||||
stamp.cells.push_back({MaterialId::Lava, 777, 9, 4});
|
||||
request.stamps.push_back(stamp);
|
||||
|
||||
const std::string encoded =
|
||||
encodePixelWorldGenerationRequestJson(request).expect("encode request");
|
||||
const auto decoded =
|
||||
decodePixelWorldGenerationRequestJson(encoded).expect("decode request");
|
||||
CHECK_EQ(decoded.schemaVersion, request.schemaVersion);
|
||||
CHECK_EQ(decoded.seed, request.seed);
|
||||
CHECK_EQ(decoded.region.minX, request.region.minX);
|
||||
CHECK_EQ(decoded.region.minY, request.region.minY);
|
||||
CHECK_EQ(decoded.region.maxX, request.region.maxX);
|
||||
CHECK_EQ(decoded.region.maxY, request.region.maxY);
|
||||
CHECK_EQ(decoded.surfaceY, request.surfaceY);
|
||||
CHECK_EQ(decoded.terrainAmplitude, request.terrainAmplitude);
|
||||
CHECK_EQ(decoded.waterLevel, request.waterLevel);
|
||||
CHECK_EQ(decoded.caveThreshold, request.caveThreshold);
|
||||
REQUIRE_EQ(decoded.stamps.size(), std::size_t(1));
|
||||
CHECK_EQ(decoded.stamps.front().cells, request.stamps.front().cells);
|
||||
CHECK_EQ(encodePixelWorldGenerationRequestJson(decoded).expect("re-encode request"), encoded);
|
||||
|
||||
std::string unknownVersion = encoded;
|
||||
const auto versionAt = unknownVersion.find("\"version\": 1");
|
||||
REQUIRE(versionAt != std::string::npos);
|
||||
unknownVersion.replace(versionAt, std::string("\"version\": 1").size(), "\"version\": 2");
|
||||
CHECK(!decodePixelWorldGenerationRequestJson(unknownVersion).ok());
|
||||
|
||||
std::string unknownField = encoded;
|
||||
const auto closingBrace = unknownField.rfind('}');
|
||||
REQUIRE(closingBrace != std::string::npos);
|
||||
unknownField.insert(closingBrace, ",\n \"future\": true\n");
|
||||
CHECK(!decodePixelWorldGenerationRequestJson(unknownField).ok());
|
||||
CHECK(!decodePixelWorldGenerationRequestJson("{broken").ok());
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
#include "zeroerr/assert.h"
|
||||
#include "zeroerr/unittest.h"
|
||||
|
||||
#include "pixelworld/PixelWorld.h"
|
||||
#include "pixelworld_thread/PixelWorldThread.h"
|
||||
#include "thread/JobSystem.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <iostream>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
using namespace eve::pixelworld;
|
||||
|
||||
TEST_CASE("pixelworld.parallel_thermal_candidates_are_bit_exact_with_reference") {
|
||||
PixelWorld reference(7001);
|
||||
PixelWorld parallel(7001);
|
||||
for (PixelWorld* world : {&reference, ¶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<eve::thread::JobSystem> jobs(eve::thread::createJobSystem(4));
|
||||
eve::pixelworld_thread::JobSystemPixelScheduler scheduler(*jobs);
|
||||
CHECK_EQ(scheduler.workerCount(), std::size_t(4));
|
||||
for (std::uint64_t tick = 1; tick <= 100; ++tick) {
|
||||
reference.advance(eve::SimulationTick(tick)).expect("reference tick");
|
||||
const StepStats stats = parallel.advanceScheduled(eve::SimulationTick(tick), scheduler)
|
||||
.expect("scheduled tick");
|
||||
CHECK(stats.parallelTasks > 1);
|
||||
}
|
||||
const auto expected = reference.saveSnapshot().expect("reference snapshot");
|
||||
const auto actual = parallel.saveSnapshot().expect("parallel snapshot");
|
||||
REQUIRE_EQ(expected.size(), actual.size());
|
||||
CHECK(std::equal(expected.begin(), expected.end(), actual.begin()));
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld.benchmark_million_active_pixels_reports_p50_p95") {
|
||||
if (!std::getenv("EVENGINE_PIXELWORLD_MILLION_BENCHMARK")) return;
|
||||
PixelWorld world(7002);
|
||||
for (int y = 0; y < 1024; ++y)
|
||||
for (int x = 0; x < 1024; ++x) world.setMaterial(x, y, "stone");
|
||||
std::unique_ptr<eve::thread::JobSystem> jobs(eve::thread::createJobSystem(4));
|
||||
eve::pixelworld_thread::JobSystemPixelScheduler scheduler(*jobs);
|
||||
std::vector<double> milliseconds;
|
||||
std::uint64_t totalReactions = 0;
|
||||
for (std::uint64_t tick = 1; tick <= 10; ++tick) {
|
||||
for (int cy = 0; cy < 16; ++cy)
|
||||
for (int cx = 0; cx < 16; ++cx)
|
||||
world.setMaterial(cx * kPixelChunkSize, cy * kPixelChunkSize, "stone");
|
||||
const auto started = std::chrono::steady_clock::now();
|
||||
const auto stats = world.advanceScheduled(eve::SimulationTick(tick), scheduler)
|
||||
.expect("million-pixel scheduled tick");
|
||||
milliseconds.push_back(std::chrono::duration<double, std::milli>(
|
||||
std::chrono::steady_clock::now() - started).count());
|
||||
totalReactions += stats.reactions;
|
||||
CHECK_EQ(stats.chunksVisited, std::uint32_t(256));
|
||||
}
|
||||
std::sort(milliseconds.begin(), milliseconds.end());
|
||||
const double p50 = milliseconds[milliseconds.size() / 2];
|
||||
const double p95 = milliseconds.back();
|
||||
std::cout << "PIXELWORLD_BENCHMARK_JSON={\"occupiedPixels\":1048576,\"chunks\":256,"
|
||||
"\"workload\":\"uniform-stone-force-awake\",\"samples\":10,\"workers\":"
|
||||
<< scheduler.workerCount()
|
||||
<< ",\"reactions\":" << totalReactions << ",\"p50Ms\":" << p50
|
||||
<< ",\"p95Ms\":" << p95 << "}" << std::endl;
|
||||
CHECK(p50 > 0.0);
|
||||
CHECK(p95 >= p50);
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld.benchmark_million_mobile_pixels_reports_p50_p95") {
|
||||
if (!std::getenv("EVENGINE_PIXELWORLD_ACTIVE_BENCHMARK")) return;
|
||||
PixelWorld world(7003);
|
||||
for (int y = 0; y < 1024; ++y)
|
||||
for (int x = 0; x < 1024; ++x) world.setMaterial(x, y, "sand");
|
||||
std::unique_ptr<eve::thread::JobSystem> jobs(eve::thread::createJobSystem(4));
|
||||
eve::pixelworld_thread::JobSystemPixelScheduler scheduler(*jobs);
|
||||
std::vector<double> milliseconds;
|
||||
std::uint64_t totalVisited = 0;
|
||||
std::uint64_t totalMoved = 0;
|
||||
for (std::uint64_t tick = 1; tick <= 5; ++tick) {
|
||||
const auto started = std::chrono::steady_clock::now();
|
||||
const auto stats = world.advanceScheduled(eve::SimulationTick(tick), scheduler)
|
||||
.expect("million-mobile-pixel scheduled tick");
|
||||
milliseconds.push_back(std::chrono::duration<double, std::milli>(
|
||||
std::chrono::steady_clock::now() - started).count());
|
||||
totalVisited += stats.cellsVisited;
|
||||
totalMoved += stats.cellsMoved;
|
||||
CHECK(stats.chunksVisited > 0);
|
||||
CHECK(stats.parallelTasks > 1);
|
||||
}
|
||||
std::sort(milliseconds.begin(), milliseconds.end());
|
||||
const double p50 = milliseconds[milliseconds.size() / 2];
|
||||
const double p95 = milliseconds.back();
|
||||
std::cout << "PIXELWORLD_ACTIVE_BENCHMARK_JSON={\"mobilePixels\":1048576,\"chunks\":256,"
|
||||
"\"workload\":\"packed-sand-over-empty\",\"samples\":5,\"workers\":"
|
||||
<< scheduler.workerCount() << ",\"visited\":" << totalVisited
|
||||
<< ",\"moved\":" << totalMoved << ",\"p50Ms\":" << p50
|
||||
<< ",\"p95Ms\":" << p95 << "}" << std::endl;
|
||||
CHECK(p50 > 0.0);
|
||||
CHECK(p95 >= p50);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
#include "physics/Body.h"
|
||||
#include "physics/World.h"
|
||||
#include "pixelworld/PixelWorld.h"
|
||||
#include "pixelworld_physics/PixelWorldPhysics.h"
|
||||
|
||||
#include "zeroerr/assert.h"
|
||||
#include "zeroerr/unittest.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
using namespace eve::pixelworld;
|
||||
using namespace eve::pixelworld_physics;
|
||||
|
||||
namespace {
|
||||
|
||||
PixelFragment detachL(PixelWorld& world) {
|
||||
world.setMaterial(0, 0, "wood");
|
||||
world.setMaterial(1, 0, "wood");
|
||||
world.setMaterial(0, 1, "wood");
|
||||
auto fragments = world.extractUnsupportedFragments({-1, -1, 2, 2}, 10)
|
||||
.expect("detach L fragment");
|
||||
REQUIRE_EQ(fragments.size(), std::size_t(1));
|
||||
return std::move(fragments.front());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("pixelworld_physics.fragment_decomposition_and_sleep_settlement_are_transactional") {
|
||||
PixelWorld pixels(501);
|
||||
PixelFragment fragment = detachL(pixels);
|
||||
eve::physics::World physics(0.f, 100.f, true, 64.f);
|
||||
auto adapter = PixelFragmentBody::create(physics, std::move(fragment))
|
||||
.expect("create fragment body");
|
||||
|
||||
REQUIRE_EQ(adapter->collisionRects().size(), std::size_t(2));
|
||||
auto body = adapter->physicsLink().resolve(physics).expect("resolve fragment body");
|
||||
body->setPosition(20.f, 20.f);
|
||||
body->setAngle(1.57079632679f);
|
||||
pixels.setMaterial(19, 19, "stone");
|
||||
body->setAwake(false);
|
||||
|
||||
auto conflict = adapter->settleIfSleeping(physics, pixels);
|
||||
CHECK(!conflict.ok());
|
||||
CHECK(body->isValid());
|
||||
CHECK(!adapter->isRasterized());
|
||||
|
||||
body->setPosition(40.f, 40.f);
|
||||
body->setAwake(false);
|
||||
const auto settled = adapter->settleIfSleeping(physics, pixels).expect("settle fragment");
|
||||
CHECK_EQ(int(settled.disposition), int(FragmentSettleDisposition::Rasterized));
|
||||
CHECK_EQ(settled.cellsRasterized, std::uint32_t(3));
|
||||
CHECK_EQ(settled.quarterTurns, 1);
|
||||
CHECK(adapter->isRasterized());
|
||||
CHECK(!body->isValid());
|
||||
CHECK_EQ(pixels.getMaterial(39, 39), int(MaterialId::Wood));
|
||||
CHECK_EQ(pixels.getMaterial(40, 39), int(MaterialId::Wood));
|
||||
CHECK_EQ(pixels.getMaterial(40, 40), int(MaterialId::Wood));
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld_physics.both_destruction_orders_and_restore_staleness_are_safe") {
|
||||
PixelWorld pixels(502);
|
||||
eve::physics::World firstPhysics(0.f, 0.f, true, 64.f);
|
||||
auto first = PixelFragmentBody::create(firstPhysics, detachL(pixels))
|
||||
.expect("first fragment body");
|
||||
first->releasePhysics(firstPhysics).expect("body first release");
|
||||
CHECK(firstPhysics.isValid());
|
||||
|
||||
pixels.clear();
|
||||
auto secondFragment = detachL(pixels);
|
||||
const auto checkpoint = pixels.saveSnapshot().expect("pixel checkpoint");
|
||||
auto second = PixelFragmentBody::create(firstPhysics, std::move(secondFragment))
|
||||
.expect("second fragment body");
|
||||
auto secondBody = second->physicsLink().resolve(firstPhysics).expect("second body");
|
||||
secondBody->setAwake(false);
|
||||
pixels.restoreSnapshot(checkpoint).expect("restore invalidates pixel link");
|
||||
CHECK(!second->settleIfSleeping(firstPhysics, pixels).ok());
|
||||
CHECK(secondBody->isValid());
|
||||
|
||||
auto thirdPhysics = std::make_unique<eve::physics::World>(0.f, 0.f, true, 64.f);
|
||||
pixels.clear();
|
||||
auto third = PixelFragmentBody::create(*thirdPhysics, detachL(pixels))
|
||||
.expect("third fragment body");
|
||||
thirdPhysics.reset();
|
||||
eve::physics::World foreignPhysics(0.f, 0.f, true, 64.f);
|
||||
CHECK(!third->releasePhysics(foreignPhysics).ok());
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld_physics.terrain_cache_rebuilds_dirty_chunks_and_rolls_back_budget_failure") {
|
||||
PixelWorld pixels(503);
|
||||
for (int x = 0; x < 70; ++x) pixels.setMaterial(x, 20, "stone");
|
||||
pixels.setMaterial(5, 5, "water");
|
||||
eve::physics::World physics(0.f, 100.f, true, 64.f);
|
||||
PixelTerrainCollisionCache cache;
|
||||
|
||||
const auto initial = cache.sync(physics, pixels).expect("initial terrain collision sync");
|
||||
CHECK_EQ(initial.chunksRebuilt, std::uint32_t(2));
|
||||
CHECK_EQ(initial.fixturesCreated, std::uint32_t(4));
|
||||
CHECK_EQ(cache.bodyCount(), std::size_t(2));
|
||||
CHECK(physics.rayCast(10.f, 10.f, 10.f, 30.f) >= 0);
|
||||
CHECK(std::abs(physics.getRayHitY() - 20.f) < 0.01f);
|
||||
const auto represented = cache.sourceRevision();
|
||||
|
||||
pixels.setMaterial(2, 19, "stone");
|
||||
const auto incremental = cache.sync(physics, pixels).expect("dirty chunk collision sync");
|
||||
CHECK_EQ(incremental.chunksRebuilt, std::uint32_t(2));
|
||||
CHECK_EQ(incremental.bodiesRemoved, std::uint32_t(2));
|
||||
CHECK(cache.sourceRevision() > represented);
|
||||
|
||||
const auto beforeFailureRevision = cache.sourceRevision();
|
||||
const auto beforeFailureBodies = cache.bodyCount();
|
||||
for (int x = 0; x < 16; x += 2) pixels.setMaterial(x, 4, "stone");
|
||||
auto rejected = cache.sync(physics, pixels, 1);
|
||||
CHECK(!rejected.ok());
|
||||
CHECK_EQ(cache.sourceRevision(), beforeFailureRevision);
|
||||
CHECK_EQ(cache.bodyCount(), beforeFailureBodies);
|
||||
|
||||
cache.clearPhysics(physics).expect("clear terrain collision cache");
|
||||
CHECK_EQ(cache.bodyCount(), std::size_t(0));
|
||||
CHECK_EQ(cache.sourceRevision(), std::uint64_t(0));
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld_physics.terrain_contours_are_simplified_and_suppress_chunk_seams") {
|
||||
PixelWorld pixels(504);
|
||||
for (int y = 10; y < 12; ++y)
|
||||
for (int x = 10; x < 12; ++x) pixels.setMaterial(x, y, "stone");
|
||||
auto simple = extractTerrainContours(pixels, 0, 0).expect("simple terrain contour");
|
||||
REQUIRE_EQ(simple.size(), std::size_t(1));
|
||||
CHECK(simple.front().loop);
|
||||
CHECK_EQ(simple.front().vertices.size(), std::size_t(8));
|
||||
|
||||
pixels.setMaterial(63, 20, "stone");
|
||||
pixels.setMaterial(64, 20, "stone");
|
||||
const auto left = extractTerrainContours(pixels, 0, 0).expect("left seam contour");
|
||||
for (const auto& contour : left) {
|
||||
const std::size_t count = contour.vertices.size() / 2;
|
||||
for (std::size_t index = 0; index + 1 < count; ++index) {
|
||||
const float x1 = contour.vertices[index * 2];
|
||||
const float y1 = contour.vertices[index * 2 + 1];
|
||||
const float x2 = contour.vertices[(index + 1) * 2];
|
||||
const float y2 = contour.vertices[(index + 1) * 2 + 1];
|
||||
CHECK(!(x1 == 64.f && x2 == 64.f && y1 >= 20.f && y2 >= 20.f));
|
||||
}
|
||||
}
|
||||
CHECK(!extractTerrainContours(pixels, 0, 0, 3).ok());
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld_physics.character_probe_and_sweep_query_authoritative_material") {
|
||||
PixelWorld pixels(505);
|
||||
pixels.setMaterial(10, 10, "stone");
|
||||
pixels.setMaterial(8, 10, "water");
|
||||
|
||||
const auto probe = probeTerrainCircle(pixels, 9.75f, 10.5f, 0.5f)
|
||||
.expect("character terrain probe");
|
||||
REQUIRE(probe.hit);
|
||||
CHECK_EQ(int(probe.material), int(MaterialId::Stone));
|
||||
CHECK_EQ(probe.cellX, 10);
|
||||
CHECK(std::abs(probe.depth - 0.25f) < 0.0001f);
|
||||
CHECK_EQ(probe.normalX, -1.f);
|
||||
CHECK_EQ(probe.normalY, 0.f);
|
||||
|
||||
const auto sweep = sweepTerrainCircle(pixels, 5.5f, 10.5f, 15.5f, 10.5f, 0.25f)
|
||||
.expect("character terrain sweep");
|
||||
REQUIRE(sweep.hit);
|
||||
CHECK_EQ(int(sweep.material), int(MaterialId::Stone));
|
||||
CHECK_EQ(sweep.cellX, 10);
|
||||
CHECK(std::abs(sweep.fraction - 0.425f) < 0.0001f);
|
||||
CHECK_EQ(sweep.normalX, -1.f);
|
||||
|
||||
const auto corner = sweepTerrainCircle(pixels, 8.f, 8.f, 10.f, 10.f, 0.5f)
|
||||
.expect("rounded corner sweep");
|
||||
REQUIRE(corner.hit);
|
||||
CHECK(corner.normalX < -0.7f);
|
||||
CHECK(corner.normalY < -0.7f);
|
||||
const auto cornerMiss = sweepTerrainCircle(pixels, 8.f, 8.f, 9.6f, 9.6f, 0.5f)
|
||||
.expect("rounded corner miss");
|
||||
CHECK(!cornerMiss.hit);
|
||||
|
||||
const auto miss = sweepTerrainCircle(pixels, 1.f, 1.f, 2.f, 1.f, 0.25f)
|
||||
.expect("empty terrain sweep");
|
||||
CHECK(!miss.hit);
|
||||
CHECK_EQ(miss.fraction, 1.f);
|
||||
CHECK(!sweepTerrainCircle(pixels, 0.f, 0.f, 100.f, 100.f, 1.f, 4).ok());
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
#include "zeroerr/assert.h"
|
||||
#include "zeroerr/unittest.h"
|
||||
|
||||
#include "pixelworld/PixelWorld.h"
|
||||
#include "pixelworld_replay/PixelWorldReplay.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
using namespace eve::pixelworld;
|
||||
using namespace eve::pixelworld_replay;
|
||||
|
||||
namespace {
|
||||
|
||||
eve::SnapshotHashProvider replayHashProvider() {
|
||||
return [](std::string_view input) -> eve::Result<eve::ContentId> {
|
||||
std::uint64_t left = 14695981039346656037ULL;
|
||||
std::uint64_t right = 1099511628211ULL;
|
||||
for (const unsigned char byte : input) {
|
||||
left = (left ^ byte) * 1099511628211ULL;
|
||||
right = (right ^ (std::uint64_t(byte) + 0x9e3779b97f4a7c15ULL)) * 14029467366897019727ULL;
|
||||
}
|
||||
eve::ContentId::Bytes bytes{};
|
||||
for (int index = 0; index < 8; ++index) {
|
||||
bytes[std::size_t(index)] = std::uint8_t(left >> (56 - index * 8));
|
||||
bytes[std::size_t(index + 8)] = std::uint8_t(right >> (56 - index * 8));
|
||||
}
|
||||
return eve::Result<eve::ContentId>::success(eve::ContentId(bytes));
|
||||
};
|
||||
}
|
||||
|
||||
eve::LogicalId replaySchema() {
|
||||
const auto parsed = eve::LogicalId::parse("pixelworld:replay-log");
|
||||
REQUIRE(parsed.has_value());
|
||||
return *parsed;
|
||||
}
|
||||
|
||||
eve::Value versionOnePayload() {
|
||||
eve::Value::Object command{
|
||||
{"centerX", eve::Value(3)},
|
||||
{"centerY", eve::Value(4)},
|
||||
{"kind", eve::Value(int(PixelEditKind::PaintCircle))},
|
||||
{"material", eve::Value(int(MaterialId::Sand))},
|
||||
{"radius", eve::Value(2)},
|
||||
{"sequence", eve::Value("1")},
|
||||
{"strength", eve::Value(0)},
|
||||
{"temperatureDelta", eve::Value(0)},
|
||||
};
|
||||
eve::Value::Array entries;
|
||||
entries.emplace_back(eve::Value::Object{
|
||||
{"command", eve::Value(std::move(command))},
|
||||
{"tick", eve::Value("1")},
|
||||
});
|
||||
eve::Value::Array checkpoints;
|
||||
checkpoints.emplace_back(eve::Value::Object{
|
||||
{"revision", eve::Value("1")},
|
||||
{"tick", eve::Value("2")},
|
||||
{"worldDigest", eve::Value("42")},
|
||||
});
|
||||
return eve::Value(eve::Value::Object{
|
||||
{"checkpoints", eve::Value(std::move(checkpoints))},
|
||||
{"entries", eve::Value(std::move(entries))},
|
||||
});
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("pixelworld_replay.command_log_checkpoints_replay_bit_exact") {
|
||||
PixelWorld source(8101);
|
||||
PixelReplayLog log;
|
||||
for (std::uint64_t tick = 1; tick <= 80; ++tick) {
|
||||
if (tick == 1 || tick == 20 || tick == 45) {
|
||||
PixelEditCommand command;
|
||||
command.sequence = tick == 1 ? 1 : tick == 20 ? 2 : 3;
|
||||
command.kind = PixelEditKind::PaintCircle;
|
||||
command.centerX = int(tick) - 30;
|
||||
command.centerY = 5;
|
||||
command.radius = 8;
|
||||
command.material = tick == 45 ? MaterialId::Water : MaterialId::Sand;
|
||||
log.append({eve::SimulationTick(tick), command}).expect("append replay command");
|
||||
source.applyEdit(command).expect("apply source command");
|
||||
}
|
||||
source.advance(eve::SimulationTick(tick)).expect("advance source");
|
||||
if (tick % 20 == 0) log.captureCheckpoint(source).expect("capture checkpoint");
|
||||
}
|
||||
|
||||
PixelWorld replayed(8101);
|
||||
const auto receipt = log.replay(replayed, eve::SimulationTick(80)).expect("replay log");
|
||||
CHECK_EQ(int(receipt.match), int(PixelReplayMatch::Matched));
|
||||
CHECK_EQ(receipt.commandsApplied, std::uint64_t(3));
|
||||
const auto expected = source.saveSnapshot().expect("source snapshot");
|
||||
const auto actual = replayed.saveSnapshot().expect("replayed snapshot");
|
||||
REQUIRE_EQ(expected.size(), actual.size());
|
||||
CHECK(std::equal(expected.begin(), expected.end(), actual.begin()));
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld_replay_reports_first_tick_and_chunk_divergence") {
|
||||
PixelWorld source(8102);
|
||||
PixelReplayLog log;
|
||||
source.paintCircle(0, 0, 5, "stone"); // Deliberately absent from the command log.
|
||||
for (std::uint64_t tick = 1; tick <= 10; ++tick) source.advance(eve::SimulationTick(tick)).expect("step");
|
||||
log.captureCheckpoint(source).expect("checkpoint");
|
||||
|
||||
PixelWorld missingInput(8102);
|
||||
const auto receipt = log.replay(missingInput, eve::SimulationTick(10)).expect("divergent replay");
|
||||
CHECK_EQ(int(receipt.match), int(PixelReplayMatch::Diverged));
|
||||
REQUIRE(receipt.firstDivergence.has_value());
|
||||
CHECK_EQ(receipt.firstDivergence->tick.value(), std::uint64_t(10));
|
||||
const bool hasChunkDiagnostic = receipt.firstDivergence->expectedChunk.has_value() ||
|
||||
receipt.firstDivergence->actualChunk.has_value();
|
||||
CHECK(hasChunkDiagnostic);
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld_replay_chunk_digest_has_canonical_little_endian_encoding") {
|
||||
PixelWorld world(8104);
|
||||
world.setMaterial(-1, 0, "stone");
|
||||
world.advance(eve::SimulationTick(1)).expect("advance world");
|
||||
PixelReplayLog log;
|
||||
log.captureCheckpoint(world).expect("capture checkpoint");
|
||||
REQUIRE_EQ(log.checkpoints().size(), std::size_t(1));
|
||||
REQUIRE_EQ(log.checkpoints().front().chunks.size(), std::size_t(1));
|
||||
CHECK_EQ(log.checkpoints().front().chunks.front().digest, std::uint64_t(17357935190315814178ULL));
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld_replay_rejects_sequence_tick_and_nonempty_target_without_log_mutation") {
|
||||
PixelReplayLog log;
|
||||
PixelEditCommand command;
|
||||
command.sequence = 2;
|
||||
auto gap = log.append({eve::SimulationTick(1), command});
|
||||
CHECK(!gap.ok());
|
||||
CHECK(log.entries().empty());
|
||||
|
||||
command.sequence = 1;
|
||||
log.append({eve::SimulationTick(2), command}).expect("first entry");
|
||||
command.sequence = 2;
|
||||
auto backwards = log.append({eve::SimulationTick(1), command});
|
||||
CHECK(!backwards.ok());
|
||||
CHECK_EQ(log.entries().size(), std::size_t(1));
|
||||
|
||||
PixelWorld nonempty(8103);
|
||||
nonempty.setMaterial(0, 0, "stone");
|
||||
const auto before = nonempty.saveSnapshot().expect("before rejection");
|
||||
auto rejected = log.replay(nonempty, eve::SimulationTick(2));
|
||||
CHECK(!rejected.ok());
|
||||
const auto after = nonempty.saveSnapshot().expect("after rejection");
|
||||
REQUIRE_EQ(before.size(), after.size());
|
||||
CHECK(std::equal(before.begin(), before.end(), after.begin()));
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld_replay_snapshot_v2_round_trip_preserves_bit_exact_replay") {
|
||||
const auto hash = replayHashProvider();
|
||||
PixelWorld source(8201);
|
||||
PixelReplayLog original;
|
||||
PixelEditCommand command;
|
||||
command.sequence = 1;
|
||||
command.kind = PixelEditKind::PaintCircle;
|
||||
command.centerX = -9;
|
||||
command.centerY = 2;
|
||||
command.radius = 4;
|
||||
command.material = MaterialId::Water;
|
||||
original.append({eve::SimulationTick(1), command}).expect("append");
|
||||
source.applyEdit(command).expect("apply");
|
||||
for (std::uint64_t tick = 1; tick <= 6; ++tick) {
|
||||
source.advance(eve::SimulationTick(tick)).expect("advance");
|
||||
if (tick == 3 || tick == 6) original.captureCheckpoint(source).expect("checkpoint");
|
||||
}
|
||||
|
||||
const auto sealed = original.snapshot(eve::PersistentId::nil(), hash).expect("seal replay");
|
||||
CHECK_EQ(sealed.schemaVersion.value(), std::uint64_t(2));
|
||||
const auto json = eve::serializeSnapshotEnvelope(sealed).expect("serialize replay");
|
||||
const auto parsed = eve::parseSnapshotEnvelope(json, hash).expect("parse replay");
|
||||
PixelReplayLog restored;
|
||||
restored.restoreSnapshot(parsed, hash).expect("restore replay");
|
||||
CHECK_EQ(restored.entries().size(), original.entries().size());
|
||||
CHECK_EQ(restored.checkpoints().size(), original.checkpoints().size());
|
||||
CHECK_EQ(restored.checkpoints().back().chunks, original.checkpoints().back().chunks);
|
||||
|
||||
PixelWorld replayed(8201);
|
||||
const auto receipt = restored.replay(replayed, eve::SimulationTick(6)).expect("replay restored log");
|
||||
CHECK_EQ(int(receipt.match), int(PixelReplayMatch::Matched));
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld_replay_migrates_v1_checkpoint_without_chunk_diagnostics") {
|
||||
const auto hash = replayHashProvider();
|
||||
const auto legacy = eve::makeSnapshotEnvelope(
|
||||
"pixelworld.replay-log", replaySchema(), eve::SchemaVersion(1),
|
||||
eve::PersistentId::nil(), eve::Revision(2), eve::SimulationTick(2),
|
||||
versionOnePayload(), hash)
|
||||
.expect("seal v1 replay");
|
||||
PixelReplayLog restored;
|
||||
restored.restoreSnapshot(legacy, hash).expect("restore v1 replay");
|
||||
REQUIRE_EQ(restored.entries().size(), std::size_t(1));
|
||||
REQUIRE_EQ(restored.checkpoints().size(), std::size_t(1));
|
||||
CHECK(restored.checkpoints().front().chunks.empty());
|
||||
const auto upgraded = restored.snapshot(eve::PersistentId::nil(), hash).expect("upgrade replay");
|
||||
CHECK_EQ(upgraded.schemaVersion.value(), std::uint64_t(2));
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld_replay_bad_envelopes_leave_existing_log_unchanged") {
|
||||
const auto hash = replayHashProvider();
|
||||
PixelReplayLog existing;
|
||||
PixelEditCommand command;
|
||||
command.sequence = 1;
|
||||
existing.append({eve::SimulationTick(1), command}).expect("append existing");
|
||||
const auto before = existing.snapshot(eve::PersistentId::nil(), hash).expect("before");
|
||||
|
||||
auto badHash = before;
|
||||
badHash.contentHash = eve::ContentId::nil();
|
||||
CHECK(!existing.restoreSnapshot(badHash, hash).ok());
|
||||
CHECK_EQ(existing.entries().size(), std::size_t(1));
|
||||
|
||||
auto unknown = eve::makeSnapshotEnvelope(
|
||||
"pixelworld.replay-log", replaySchema(), eve::SchemaVersion(3),
|
||||
eve::PersistentId::nil(), before.revision, before.tick, before.payload, hash)
|
||||
.expect("seal unknown version");
|
||||
CHECK(!existing.restoreSnapshot(unknown, hash).ok());
|
||||
CHECK_EQ(existing.entries().size(), std::size_t(1));
|
||||
|
||||
auto malformedPayload = before.payload;
|
||||
malformedPayload.getIf<eve::Value::Object>()->emplace("unknown", eve::Value(true));
|
||||
auto malformed = eve::makeSnapshotEnvelope(
|
||||
"pixelworld.replay-log", replaySchema(), eve::SchemaVersion(2),
|
||||
eve::PersistentId::nil(), before.revision, before.tick,
|
||||
std::move(malformedPayload), hash)
|
||||
.expect("seal malformed payload");
|
||||
CHECK(!existing.restoreSnapshot(malformed, hash).ok());
|
||||
CHECK_EQ(existing.entries().size(), std::size_t(1));
|
||||
|
||||
std::string truncated = eve::serializeSnapshotEnvelope(before).expect("serialize before");
|
||||
truncated.resize(truncated.size() / 2);
|
||||
CHECK(!eve::parseSnapshotEnvelope(truncated, hash).ok());
|
||||
CHECK_EQ(existing.entries().size(), std::size_t(1));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
#include "zeroerr/assert.h"
|
||||
#include "zeroerr/unittest.h"
|
||||
|
||||
#include "pixelworld/PixelWorld.h"
|
||||
#include "pixelworld_streaming/PixelWorldStreaming.h"
|
||||
#include "pixelworld_streaming/PixelWorldTransport.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <string_view>
|
||||
|
||||
using namespace eve::pixelworld;
|
||||
using namespace eve::pixelworld_streaming;
|
||||
|
||||
namespace {
|
||||
|
||||
eve::SnapshotHashProvider archiveHashProvider() {
|
||||
return [](std::string_view input) -> eve::Result<eve::ContentId> {
|
||||
std::uint64_t left = 14695981039346656037ULL;
|
||||
std::uint64_t right = 1099511628211ULL;
|
||||
for (const unsigned char byte : input) {
|
||||
left = (left ^ byte) * 1099511628211ULL;
|
||||
right = (right ^ (std::uint64_t(byte) + 0x9e3779b97f4a7c15ULL)) * 14029467366897019727ULL;
|
||||
}
|
||||
eve::ContentId::Bytes bytes{};
|
||||
for (int index = 0; index < 8; ++index) {
|
||||
bytes[std::size_t(index)] = std::uint8_t(left >> (56 - index * 8));
|
||||
bytes[std::size_t(index + 8)] = std::uint8_t(right >> (56 - index * 8));
|
||||
}
|
||||
return eve::Result<eve::ContentId>::success(eve::ContentId(bytes));
|
||||
};
|
||||
}
|
||||
|
||||
eve::LogicalId chunkArchiveSchema() {
|
||||
return *eve::LogicalId::parse("pixelworld:chunk-batch");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
TEST_CASE("pixelworld_streaming_interest_move_emits_entry_and_eviction_correction") {
|
||||
PixelWorld authority(8301);
|
||||
authority.setMaterial(1, 1, "stone");
|
||||
authority.setMaterial(2 * kPixelChunkSize + 1, 1, "water");
|
||||
PixelChunkStreamCursor cursor;
|
||||
|
||||
auto first = cursor.capture(authority, {0, 0, 0, 0}).expect("capture first interest");
|
||||
CHECK(first.fullResync);
|
||||
REQUIRE_EQ(first.batch.chunks.size(), std::size_t(1));
|
||||
PixelWorld replica(8301);
|
||||
replica.applyChunkBatch(first.batch, 0).expect("apply first interest");
|
||||
CHECK_EQ(replica.getMaterial(1, 1), int(MaterialId::Stone));
|
||||
CHECK_EQ(replica.getMaterial(2 * kPixelChunkSize + 1, 1), int(MaterialId::Air));
|
||||
|
||||
auto moved = cursor.capture(authority, {2, 0, 2, 0}).expect("move interest");
|
||||
CHECK(!moved.fullResync);
|
||||
CHECK_EQ(moved.chunksEntered, std::uint32_t(1));
|
||||
CHECK_EQ(moved.chunksEvicted, std::uint32_t(1));
|
||||
REQUIRE_EQ(moved.batch.chunks.size(), std::size_t(2));
|
||||
replica.applyChunkBatch(moved.batch, replica.revision()).expect("apply moved interest");
|
||||
CHECK_EQ(replica.getMaterial(1, 1), int(MaterialId::Air));
|
||||
CHECK_EQ(replica.getMaterial(2 * kPixelChunkSize + 1, 1), int(MaterialId::Water));
|
||||
|
||||
const auto unchanged = cursor.capture(authority, {2, 0, 2, 0}).expect("unchanged interest");
|
||||
CHECK(unchanged.batch.chunks.empty());
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld_streaming_disconnect_reconnect_applies_authoritative_chunk_delta") {
|
||||
PixelWorld authority(8302);
|
||||
authority.setMaterial(4, 4, "wood");
|
||||
PixelChunkStreamCursor cursor;
|
||||
auto initial = cursor.capture(authority, {0, 0, 0, 0}).expect("initial sync");
|
||||
PixelWorld replica(8302);
|
||||
replica.applyChunkBatch(initial.batch, 0).expect("apply initial sync");
|
||||
|
||||
authority.setMaterial(4, 4, "fire");
|
||||
authority.setMaterial(5, 4, "oil");
|
||||
auto reconnect = cursor.capture(authority, {0, 0, 0, 0}).expect("reconnect delta");
|
||||
REQUIRE_EQ(reconnect.batch.chunks.size(), std::size_t(1));
|
||||
replica.applyChunkBatch(reconnect.batch, replica.revision()).expect("apply reconnect correction");
|
||||
CHECK_EQ(replica.getCell(4, 4), authority.getCell(4, 4));
|
||||
CHECK_EQ(replica.getCell(5, 4), authority.getCell(5, 4));
|
||||
CHECK_EQ(replica.revision(), authority.revision());
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld_streaming_epoch_change_forces_full_resync_and_stale_writer_rejects") {
|
||||
PixelWorld authority(8303);
|
||||
authority.setMaterial(3, 3, "sand");
|
||||
const auto saved = authority.saveSnapshot().expect("save authority");
|
||||
PixelChunkStreamCursor cursor;
|
||||
auto initial = cursor.capture(authority, {0, 0, 0, 0}).expect("initial");
|
||||
CHECK(initial.fullResync);
|
||||
authority.restoreSnapshot(saved).expect("restore changes epoch");
|
||||
auto rebuilt = cursor.capture(authority, {0, 0, 0, 0}).expect("full rebuild");
|
||||
CHECK(rebuilt.fullResync);
|
||||
REQUIRE_EQ(rebuilt.batch.chunks.size(), std::size_t(1));
|
||||
|
||||
PixelWorld replica(8303);
|
||||
replica.setMaterial(10, 10, "stone");
|
||||
const auto before = replica.saveSnapshot().expect("before stale correction");
|
||||
CHECK(!replica.applyChunkBatch(rebuilt.batch, 0).ok());
|
||||
const auto after = replica.saveSnapshot().expect("after stale correction");
|
||||
REQUIRE_EQ(before.size(), after.size());
|
||||
CHECK(std::equal(before.begin(), before.end(), after.begin()));
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld_streaming_full_resync_atomically_clears_projection_and_accepts_rewind") {
|
||||
PixelWorld authority(8304);
|
||||
authority.setMaterial(3, 3, "stone");
|
||||
PixelChunkStreamCursor cursor;
|
||||
const auto populated = cursor.capture(authority, {0, 0, 0, 0}).expect("populated sync");
|
||||
REQUIRE(populated.batch.fullResync);
|
||||
PixelWorld replica(8304);
|
||||
replica.applyChunkBatch(populated.batch, 0).expect("apply populated projection");
|
||||
CHECK_EQ(replica.getMaterial(3, 3), int(MaterialId::Stone));
|
||||
|
||||
PixelWorld emptyAuthority(8304);
|
||||
const auto emptySnapshot = emptyAuthority.saveSnapshot().expect("empty authority snapshot");
|
||||
authority.restoreSnapshot(emptySnapshot).expect("rewind authority to empty epoch");
|
||||
const auto reset = cursor.capture(authority, {0, 0, 0, 0}).expect("rewound full sync");
|
||||
CHECK(reset.fullResync);
|
||||
CHECK(reset.batch.fullResync);
|
||||
CHECK_EQ(reset.batch.sourceRevision, std::uint64_t(0));
|
||||
CHECK(reset.batch.chunks.empty());
|
||||
replica.applyChunkBatch(reset.batch, replica.revision()).expect("replace projection after rewind");
|
||||
CHECK_EQ(replica.revision(), std::uint64_t(0));
|
||||
CHECK_EQ(replica.chunkCount(), std::size_t(0));
|
||||
CHECK_EQ(replica.getMaterial(3, 3), int(MaterialId::Air));
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld_streaming_zstd_chunk_archive_round_trips_and_is_smaller_than_raw") {
|
||||
const auto hash = archiveHashProvider();
|
||||
PixelWorld authority(8401);
|
||||
for (int y = 0; y < kPixelChunkSize; ++y)
|
||||
for (int x = 0; x < kPixelChunkSize; ++x)
|
||||
authority.setCell(x, y, {MaterialId::Stone, 20, 0});
|
||||
authority.setCell(17, 23, {MaterialId::Stone, 20, 0, 73});
|
||||
PixelChunkStreamCursor cursor;
|
||||
const auto update = cursor.capture(authority, {0, 0, 0, 0}).expect("capture archive batch");
|
||||
const auto compressed = archiveChunkBatch(update.batch, eve::PersistentId::nil(),
|
||||
PixelChunkArchiveCodec::Zstd, hash)
|
||||
.expect("compress archive");
|
||||
const auto raw = archiveChunkBatch(update.batch, eve::PersistentId::nil(),
|
||||
PixelChunkArchiveCodec::None, hash)
|
||||
.expect("raw archive");
|
||||
const auto compressedJson = eve::serializeSnapshotEnvelope(compressed).expect("serialize compressed");
|
||||
const auto rawJson = eve::serializeSnapshotEnvelope(raw).expect("serialize raw");
|
||||
CHECK(compressedJson.size() < rawJson.size() / 4);
|
||||
|
||||
const auto parsed = eve::parseSnapshotEnvelope(compressedJson, hash).expect("parse compressed");
|
||||
const auto decoded = decodeChunkBatchArchive(parsed, hash).expect("decode archive");
|
||||
PixelWorld replica(8401);
|
||||
replica.applyChunkBatch(decoded, 0).expect("apply decoded archive");
|
||||
CHECK_EQ(replica.getCell(17, 23), authority.getCell(17, 23));
|
||||
CHECK_EQ(replica.getCell(17, 23).thermalRemainder, std::uint16_t(73));
|
||||
CHECK_EQ(replica.revision(), authority.revision());
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld_streaming_chunk_archive_v1_migrates_and_corruption_is_isolated") {
|
||||
const auto hash = archiveHashProvider();
|
||||
PixelWorld authority(8402);
|
||||
authority.setMaterial(3, 4, "water");
|
||||
PixelChunkStreamCursor cursor;
|
||||
const auto update = cursor.capture(authority, {0, 0, 0, 0}).expect("capture");
|
||||
const auto current = archiveChunkBatch(update.batch, eve::PersistentId::nil(),
|
||||
PixelChunkArchiveCodec::Zstd, hash)
|
||||
.expect("archive");
|
||||
|
||||
auto legacyPayload = current.payload;
|
||||
legacyPayload.getIf<eve::Value::Object>()->erase("sourceLastEditSequence");
|
||||
legacyPayload.getIf<eve::Value::Object>()->erase("fullResync");
|
||||
const auto legacy = eve::makeSnapshotEnvelope(
|
||||
"pixelworld.chunk-batch", chunkArchiveSchema(), eve::SchemaVersion(1),
|
||||
eve::PersistentId::nil(), current.revision, current.tick,
|
||||
std::move(legacyPayload), hash)
|
||||
.expect("seal legacy");
|
||||
const auto migrated = decodeChunkBatchArchive(legacy, hash).expect("decode legacy");
|
||||
CHECK_EQ(migrated.sourceLastEditSequence, std::uint64_t(0));
|
||||
REQUIRE_EQ(migrated.chunks.size(), std::size_t(1));
|
||||
|
||||
auto corruptPayload = current.payload;
|
||||
auto& chunks = *corruptPayload.getIf<eve::Value::Object>()->at("chunks").getIf<eve::Value::Array>();
|
||||
auto& stored = *chunks.front().getIf<eve::Value::Object>()->at("stored").getIf<std::string>();
|
||||
REQUIRE(!stored.empty());
|
||||
stored[0] = stored[0] == '0' ? '1' : '0';
|
||||
const auto resealedCorrupt = eve::makeSnapshotEnvelope(
|
||||
"pixelworld.chunk-batch", chunkArchiveSchema(), eve::SchemaVersion(2),
|
||||
eve::PersistentId::nil(), current.revision, current.tick,
|
||||
std::move(corruptPayload), hash)
|
||||
.expect("seal corrupt physical payload");
|
||||
PixelWorld untouched(8402);
|
||||
untouched.setMaterial(9, 9, "wood");
|
||||
const auto before = untouched.saveSnapshot().expect("before corrupt decode");
|
||||
CHECK(!decodeChunkBatchArchive(resealedCorrupt, hash).ok());
|
||||
const auto after = untouched.saveSnapshot().expect("after corrupt decode");
|
||||
CHECK(std::equal(before.begin(), before.end(), after.begin()));
|
||||
|
||||
auto badOuterHash = current;
|
||||
badOuterHash.contentHash = eve::ContentId::nil();
|
||||
CHECK(!decodeChunkBatchArchive(badOuterHash, hash).ok());
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld_transport_retransmits_loss_and_commits_complete_transfer_atomically") {
|
||||
PixelWorld authority(8501);
|
||||
authority.setMaterial(1, 1, "stone");
|
||||
authority.setMaterial(kPixelChunkSize + 1, 1, "water");
|
||||
authority.setMaterial(2 * kPixelChunkSize + 1, 1, "wood");
|
||||
auto sender = ReliablePixelChunkSender::create(91).expect("create sender");
|
||||
auto receiver = ReliablePixelChunkReceiver::create(91).expect("create receiver");
|
||||
const auto parts = sender.capture(authority, {0, 0, 2, 0}).expect("capture transfer");
|
||||
REQUIRE_EQ(parts.size(), std::size_t(3));
|
||||
PixelWorld replica(8501);
|
||||
|
||||
CHECK_EQ(receiver.receive(parts[2], replica).expect("part 2").transfersApplied,
|
||||
std::uint32_t(0));
|
||||
CHECK_EQ(receiver.receive(parts[0], replica).expect("part 0").transfersApplied,
|
||||
std::uint32_t(0));
|
||||
CHECK(receiver.receive(parts[2], replica).expect("duplicate part 2").duplicate);
|
||||
CHECK_EQ(replica.chunkCount(), std::size_t(0));
|
||||
REQUIRE_EQ(sender.pendingParts().size(), std::size_t(3));
|
||||
|
||||
const auto completed = receiver.receive(sender.pendingParts()[1], replica)
|
||||
.expect("retransmitted missing part");
|
||||
CHECK_EQ(completed.transfersApplied, std::uint32_t(1));
|
||||
CHECK_EQ(completed.ack.acknowledgedThrough, std::uint64_t(1));
|
||||
CHECK_EQ(replica.getCell(2 * kPixelChunkSize + 1, 1),
|
||||
authority.getCell(2 * kPixelChunkSize + 1, 1));
|
||||
const auto released = sender.acknowledge(completed.ack).expect("application ACK");
|
||||
CHECK_EQ(released.transfersReleased, std::uint32_t(1));
|
||||
CHECK_EQ(sender.inFlightTransferCount(), std::size_t(0));
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld_transport_buffers_transfer_order_and_retains_tombstones_until_ACK") {
|
||||
PixelWorld authority(8502);
|
||||
authority.setMaterial(1, 1, "stone");
|
||||
authority.setMaterial(kPixelChunkSize + 1, 1, "water");
|
||||
auto sender = ReliablePixelChunkSender::create(92).expect("create sender");
|
||||
auto receiver = ReliablePixelChunkReceiver::create(92).expect("create receiver");
|
||||
PixelWorld replica(8502);
|
||||
const auto initial = sender.capture(authority, {0, 0, 1, 0}).expect("initial");
|
||||
PixelChunkReceiveReceipt receipt;
|
||||
for (const auto& part : initial)
|
||||
receipt = receiver.receive(part, replica).expect("receive initial");
|
||||
sender.acknowledge(receipt.ack).expect("ACK initial");
|
||||
|
||||
authority.setMaterial(2, 2, "oil");
|
||||
const auto second = sender.capture(authority, {0, 0, 1, 0}).expect("second");
|
||||
authority.setMaterial(kPixelChunkSize + 2, 2, "wood");
|
||||
const auto third = sender.capture(authority, {0, 0, 1, 0}).expect("third");
|
||||
REQUIRE_EQ(second.size(), std::size_t(1));
|
||||
REQUIRE_EQ(third.size(), std::size_t(1));
|
||||
CHECK_EQ(receiver.receive(third.front(), replica).expect("third early").transfersApplied,
|
||||
std::uint32_t(0));
|
||||
const auto ordered = receiver.receive(second.front(), replica).expect("fill transfer gap");
|
||||
CHECK_EQ(ordered.transfersApplied, std::uint32_t(2));
|
||||
CHECK_EQ(ordered.ack.acknowledgedThrough, std::uint64_t(3));
|
||||
CHECK_EQ(replica.getCell(kPixelChunkSize + 2, 2),
|
||||
authority.getCell(kPixelChunkSize + 2, 2));
|
||||
sender.acknowledge(ordered.ack).expect("ACK ordered transfers");
|
||||
|
||||
const auto evicted = sender.capture(authority, {0, 0, 0, 0}).expect("evict interest");
|
||||
REQUIRE_EQ(evicted.size(), std::size_t(1));
|
||||
CHECK(evicted.front().batch.chunks.front().removed);
|
||||
CHECK_EQ(sender.retainedTombstoneCount(), std::size_t(1));
|
||||
const auto tombstone = receiver.receive(evicted.front(), replica).expect("apply tombstone");
|
||||
CHECK_EQ(replica.getMaterial(kPixelChunkSize + 1, 1), int(MaterialId::Air));
|
||||
sender.acknowledge(tombstone.ack).expect("ACK tombstone");
|
||||
CHECK_EQ(sender.retainedTombstoneCount(), std::size_t(0));
|
||||
CHECK_EQ(sender.acknowledge(tombstone.ack).expect("duplicate ACK").transfersReleased,
|
||||
std::uint32_t(0));
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld_transport_window_failure_does_not_advance_capture_cursor") {
|
||||
PixelChunkTransportConfig config;
|
||||
config.maximumInFlightTransfers = 1;
|
||||
PixelWorld authority(8503);
|
||||
authority.setMaterial(1, 1, "stone");
|
||||
auto sender = ReliablePixelChunkSender::create(93, config).expect("create sender");
|
||||
auto receiver = ReliablePixelChunkReceiver::create(93, config).expect("create receiver");
|
||||
PixelWorld replica(8503);
|
||||
const auto initial = sender.capture(authority, {0, 0, 0, 0}).expect("initial");
|
||||
authority.setMaterial(2, 2, "water");
|
||||
CHECK(!sender.capture(authority, {0, 0, 0, 0}).ok());
|
||||
const auto first = receiver.receive(initial.front(), replica).expect("receive initial");
|
||||
sender.acknowledge(first.ack).expect("free window");
|
||||
const auto retry = sender.capture(authority, {0, 0, 0, 0}).expect("retry capture");
|
||||
REQUIRE_EQ(retry.size(), std::size_t(1));
|
||||
CHECK_EQ(retry.front().batch.sourceRevision, authority.revision());
|
||||
CHECK_EQ(int(retry.front().batch.chunks.front().cells[2 * kPixelChunkSize + 2].material),
|
||||
int(MaterialId::Water));
|
||||
}
|
||||
|
||||
TEST_CASE("pixelworld_transport_rejects_conflicting_duplicate_and_invalid_ACK") {
|
||||
PixelWorld authority(8504);
|
||||
authority.setMaterial(1, 1, "stone");
|
||||
authority.setMaterial(kPixelChunkSize + 1, 1, "water");
|
||||
auto sender = ReliablePixelChunkSender::create(94).expect("create sender");
|
||||
auto receiver = ReliablePixelChunkReceiver::create(94).expect("create receiver");
|
||||
const auto parts = sender.capture(authority, {0, 0, 1, 0}).expect("capture");
|
||||
REQUIRE_EQ(parts.size(), std::size_t(2));
|
||||
PixelWorld replica(8504);
|
||||
receiver.receive(parts.front(), replica).expect("receive partial");
|
||||
auto conflict = parts.front();
|
||||
conflict.batch.sourceRevision += 1;
|
||||
CHECK(!receiver.receive(std::move(conflict), replica).ok());
|
||||
CHECK(!sender.acknowledge({94, 1, authority.revision() + 1}).ok());
|
||||
CHECK_EQ(sender.inFlightTransferCount(), std::size_t(1));
|
||||
}
|
||||
Reference in new issue
Block a user