diff --git a/docs/usr/modules/procgen.md b/docs/usr/modules/procgen.md index 4ed6f5c5..c3f122db 100644 --- a/docs/usr/modules/procgen.md +++ b/docs/usr/modules/procgen.md @@ -6,6 +6,12 @@ ## Result 投影约定 +PointGraph 的 `executeResult`、`validateResult` 以及 RuntimeGeneration 的 +`nextGenerationJob`、`completeGenerationJob`、`failGenerationJob`、`nextCleanupRequest`、 +`completeCleanupRequest` 已使用结构化 Result。领取成功但暂无任务时 `value == null`,不代表失败。 +旧图执行和调度方法只作为兼容投影保留;具体错误码、所有权和迁移窗口见 +[PointSet 管线](procgen/pointset-pipeline.md)。 + Procgen 的创建、生成、输出和事务提交 API 都返回统一的 Squirrel Result 表: `{ ok, code, hasValue, status, diagnostics, value }`。调用方必须先读取 `ok`;只有 `ok == true` 时才读取 `value`。失败信息使用 `status.summary` 或结构化 diff --git a/docs/usr/modules/procgen/pointset-pipeline.md b/docs/usr/modules/procgen/pointset-pipeline.md index d2926005..63411287 100644 --- a/docs/usr/modules/procgen/pointset-pipeline.md +++ b/docs/usr/modules/procgen/pointset-pipeline.md @@ -392,6 +392,36 @@ CPU 节点段与无分支的 GPU transform 段;脚本仍然只需要构造和 计划不参与序列化;不可变计划不代表图实例线程安全,构图、执行与缓存管理仍由图所属线程负责。 `getCompiledSegmentCount()` 和 `getExecutionPlanBuildCount()` 可用于确认计划划分与复用情况。 +执行与验证的 C++ 主入口为 `executeResult(outputId) -> Result` 与 +`validateResult() -> Result`,都必须检查返回值。编译、递归求值与子图失败直接传播 +结构化诊断,不从 `getError()` 文本反推错误。诊断携带节点 `path` 和 `procgen.pointGraph` +来源;缺失输出为 `NotFound`,环为 `Conflict`,配置或点数预算错误为 `InvalidArgument`, +取消及执行节点预算中止为 `Cancelled`。旧 biome/grammar 生成器失败在调用边界转成 `Failed`; +本次未改写这些生成器及 PointCompute 自身的历史接口。 +成功结果独立拥有点数据;失败不返回部分输出,但已完成的中间节点缓存可以保留。 +GPU 失败仍按已声明契约回退 CPU,并通过 `getComputeFallbackReason()` 保持可观察。 + +Squirrel 同名入口返回公共 Result 表,`executeResult().value` 是 VM 拥有的 PointSet: + +```nut +local execution = graph.executeResult("output"); +if (!execution.ok) throw execution.status.summary; +local generated = execution.value; +``` + +`execute()/validate()/getError()` 仅作历史兼容投影;内部执行、子图和编辑器预览不依赖它们。 +迁移跟踪为 PR #345,owner 为 procgen;本兼容窗口保留现有脚本及旧 C++ 调用点,待这些调用点 +迁移且兼容测试退役后删除。它们不作为新代码的默认入口,`getError()` 只描述最近一次兼容调用。 + +RuntimeGeneration 的主调度入口为 `nextGenerationJob`、`completeGenerationJob`、 +`failGenerationJob`、`nextCleanupRequest`、`completeCleanupRequest`。C++ 返回带 `[[nodiscard]]` +的 Result;两个领取接口以成功的空 optional 表示暂时无工作,而错误线程返回失败。 +清理 request 自持 ticket 和 scheduler 身份,复制不延长 scheduler 生命周期;重复、跨 scheduler +或过期提交失败且不删除 cell。所有领取与提交均在 owner thread,worker 只传递自持数据。 +Squirrel 的五个同名方法返回公共 Result 表,成功但无工作为 `value == null`;生成提交与清理提交的 +`value` 分别为十进制 revision 和清理数量字符串。旧 `nextGenerate/nextCleanup/completeGeneration/ +failGeneration/completeCleanup` 是同一 PR #345 迁移窗口内的兼容投影,不是另一套调度实现。 + 子图节点将首个输入写入嵌套图指定的 input node,并返回指定 output node: ```nut diff --git a/src/modules/procgen/PointGraph.cpp b/src/modules/procgen/PointGraph.cpp index 138cd8aa..45e99635 100644 --- a/src/modules/procgen/PointGraph.cpp +++ b/src/modules/procgen/PointGraph.cpp @@ -86,56 +86,6 @@ const OperationParam* operationParam(const OperationSpec* spec, const std::strin return found == spec->params.end() ? nullptr : &*found; } -uint64_t nowNanoseconds() { - return uint64_t(std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()) - .count()); -} - -PointGraphNodeMetric makeMetric(const std::string& id, const PointSet& points, float milliseconds, bool cacheHit, - const std::string& backend = "cpu") { - PointGraphNodeMetric metric; - metric.id = id; - metric.outputCount = points.getCount(); - metric.milliseconds = milliseconds; - metric.cacheHit = cacheHit; - metric.backend = backend; - if (points.empty()) return metric; - const auto& first = points.points().front(); - metric.minX = metric.maxX = first.x; - metric.minY = metric.maxY = first.y; - metric.minZ = metric.maxZ = first.z; - double densitySum = 0.0; - for (const auto& point : points.points()) { - metric.minX = std::min(metric.minX, point.x); - metric.minY = std::min(metric.minY, point.y); - metric.minZ = std::min(metric.minZ, point.z); - metric.maxX = std::max(metric.maxX, point.x); - metric.maxY = std::max(metric.maxY, point.y); - metric.maxZ = std::max(metric.maxZ, point.z); - densitySum += point.density; - } - metric.averageDensity = float(densitySum / double(points.getCount())); - return metric; -} - -uint64_t spatialSampleUpperBound(const SpatialData& spatial, float spacing) { - if (!spatial.hasBounds() || spacing <= 0.f) return 0; - const auto axisCount = [spacing](float minimum, float maximum) { - if (maximum <= minimum) return uint64_t(1); - return uint64_t(std::floor(double(maximum - minimum) / double(spacing) + 0.001)) + 1; - }; - const uint64_t x = axisCount(spatial.getMinX(), spatial.getMaxX()); - const uint64_t y = spatial.getKind() == "surface.heightfield" - ? uint64_t(1) - : axisCount(spatial.getMinY(), spatial.getMaxY()); - const uint64_t z = axisCount(spatial.getMinZ(), spatial.getMaxZ()); - const uint64_t maximum = std::numeric_limits::max(); - if (x > maximum / y) return maximum; - const uint64_t xy = x * y; - return xy > maximum / z ? maximum : xy * z; -} - } // namespace PointGraph::PointGraph() : pointCompute_(std::make_shared()) {} @@ -382,7 +332,14 @@ bool PointGraph::setNodeSubgraph(const std::string& id, PointGraph* graph, } PointSet* PointGraph::execute(const std::string& outputId) { - error_.clear(); + auto result = executeResult(outputId); + error_ = result.ok() ? std::string{} : result.status().describe(); + if (!result.ok()) return nullptr; + return new PointSet(std::move(result).takeValue()); +} + +Result PointGraph::executeResult(std::string_view outputId) { + const std::string output(outputId); metrics_.clear(); cacheHitCount_ = 0; evaluatedNodes_ = 0; @@ -390,15 +347,19 @@ PointSet* PointGraph::execute(const std::string& outputId) { computeFallbackReason_.clear(); ++executionCount_; if (cancelRequested_) { - error_ = "execution cancelled"; lastCancelled_ = true; - return nullptr; + return Result::failure( + Diagnostic::error(DiagnosticCode::Cancelled, "execution cancelled", output, {}, "procgen.pointGraph")); } - compileExecutionPlan(outputId); - if (!error_.empty()) return nullptr; + auto compiled = compileExecutionPlan(output); + if (!compiled.ok()) return Result::failure(compiled.status()); std::unordered_map states; - const PointSet* result = evaluate(outputId, states); - return result ? new PointSet(*result) : nullptr; + auto result = evaluate(output, states); + if (!result.ok()) { + lastCancelled_ = result.status().code() == StatusCode::Cancelled; + return Result::failure(result.status()); + } + return Result::success(result.value().get()); } void PointGraph::setExecutionNodeBudget(int nodes) { executionNodeBudget_ = std::max(0, nodes); } @@ -433,11 +394,18 @@ void PointGraph::resetCancellation() { bool PointGraph::wasCancelled() const { return lastCancelled_; } bool PointGraph::validate() { - error_.clear(); + auto result = validateResult(); + error_ = result.ok() ? std::string{} : result.status().describe(); + return result.ok(); +} + +Result PointGraph::validateResult() const { std::unordered_map states; - for (const auto& id : nodeOrder_) - if (!validateNode(id, states)) return false; - return true; + for (const auto& id : nodeOrder_) { + auto result = validateNode(id, states); + if (!result.ok()) return result; + } + return Result::success(); } std::string PointGraph::getError() const { return error_; } @@ -560,375 +528,6 @@ std::string PointGraph::getOperationParamDefault(const std::string& operation, i : std::string(); } -const PointSet* PointGraph::evaluate(const std::string& id, - std::unordered_map& states) { - const auto found = nodes_.find(id); - if (found == nodes_.end()) { - error_ = "unknown node: " + id; - return nullptr; - } - Node& node = found->second; - if (node.cacheValid) { - ++cacheHitCount_; - PointGraphNodeMetric metric = node.cacheMetric; - metric.milliseconds = 0.f; - metric.cacheHit = true; - metrics_.push_back(std::move(metric)); - return &node.cache; - } - if (states[id] == 1) { - error_ = "cycle at node: " + id; - return nullptr; - } - if (cancelRequested_) { - error_ = "execution cancelled at node: " + id; - lastCancelled_ = true; - return nullptr; - } - if (node.operation == "transform") { - const PointSet* segment = evaluateTransformSegment(id, states); - if (segment || !error_.empty()) return segment; - } - states[id] = 1; - const uint64_t started = nowNanoseconds(); - std::string nodeBackend = "cpu"; - const PointSet* first = nullptr; - const PointSet* second = nullptr; - if (!node.inputs[0].empty()) { - first = evaluate(node.inputs[0], states); - if (!first) return nullptr; - } - if (!node.inputs[1].empty()) { - second = evaluate(node.inputs[1], states); - if (!second) return nullptr; - } - if (executionNodeBudget_ > 0 && evaluatedNodes_ >= executionNodeBudget_) { - error_ = "execution node budget exceeded at node: " + id; - lastCancelled_ = true; - return nullptr; - } - ++evaluatedNodes_; - - if (node.operation == "input") { - if (!node.hasPoints) error_ = "input node has no points: " + id; - else node.cache = node.points; - } else if (node.operation == "spatial.sample") { - const float spacing = floatValue(node, "spacing", 1.f); - if (!node.spatial) error_ = "spatial.sample has no spatial data: " + id; - else if (maxNodeOutputPoints_ > 0 && - spatialSampleUpperBound(*node.spatial, spacing) > uint64_t(maxNodeOutputPoints_)) - error_ = "spatial.sample exceeds node point budget at node: " + id; - else - node.cache = node.spatial->sample(spacing, uint32_t(intValue(node, "seed", 1)), - floatValue(node, "jitter", 0.f)); - } else if (node.operation == "spatial.filter") { - if (!first || !node.spatial) error_ = "spatial.filter requires input and spatial data: " + id; - else node.cache = node.spatial->filter(*first, intValue(node, "invert", 0) != 0); - } else if (node.operation == "spatial.project") { - if (!first || !node.spatial) error_ = "spatial.project requires input and spatial data: " + id; - else node.cache = node.spatial->project(*first); - } else if (node.operation == "biome.generate") { - const float spacing = floatValue(node, "spacing", 1.f); - if (!node.spatial || !node.biomeRules) - error_ = "biome.generate requires spatial data and biome rules: " + id; - else if (maxNodeOutputPoints_ > 0 && - spatialSampleUpperBound(*node.spatial, spacing) > uint64_t(maxNodeOutputPoints_)) - error_ = "biome.generate exceeds node point budget at node: " + id; - else { - std::unique_ptr result(node.biomeRules->generate( - node.spatial.get(), spacing, uint32_t(intValue(node, "seed", 1)), - floatValue(node, "jitter", 0.f))); - if (!result) - error_ = "biome.generate failed at " + id + ": " + node.biomeRules->getError(); - else - node.cache = *result; - } - } else if (node.operation == "grammar.generate") { - if (!first || !node.shapeGrammar) - error_ = "grammar.generate requires input and shape grammar: " + id; - else { - std::unique_ptr result(node.shapeGrammar->generate( - stringValue(node, "grammar"), const_cast(first), - uint32_t(intValue(node, "seed", 1)), - intValue(node, "acceptIncomplete", 1) != 0)); - if (!result) - error_ = "grammar.generate failed at " + id + ": " + - node.shapeGrammar->getError(); - else - node.cache = *result; - } - } else if (node.operation == "merge") { - const uint64_t outputCount = first && second - ? uint64_t(first->getCount()) + uint64_t(second->getCount()) - : 0; - if (!first || !second) error_ = "merge requires two inputs: " + id; - else if (maxNodeOutputPoints_ > 0 && outputCount > uint64_t(maxNodeOutputPoints_)) - error_ = "merge exceeds node point budget at node: " + id; - else node.cache = mergePointSets(*first, *second); - } else if (node.operation == "copy.points") { - const int maxPoints = intValue(node, "maxPoints", 100000); - const uint64_t outputCount = first && second - ? uint64_t(first->getCount()) * uint64_t(second->getCount()) - : 0; - if (!first || !second) error_ = "copy.points requires source and target inputs: " + id; - else if (maxPoints < 0 || outputCount > uint64_t(maxPoints)) - error_ = "copy.points output exceeds maxPoints at node: " + id; - else if (maxNodeOutputPoints_ > 0 && outputCount > uint64_t(maxNodeOutputPoints_)) - error_ = "copy.points exceeds node point budget at node: " + id; - else - node.cache = copyPointsToTargets( - *first, *second, intValue(node, "inheritTargetAttributes", 1) != 0); - } else if (node.operation == "density.remap") { - const float inputMin = floatValue(node, "inputMin", 0.f); - const float inputMax = floatValue(node, "inputMax", 1.f); - if (!first) error_ = "density.remap requires input: " + id; - else if (inputMin == inputMax) - error_ = "density.remap requires a non-zero input range: " + id; - else - node.cache = remapPointDensity( - *first, inputMin, inputMax, floatValue(node, "outputMin", 0.f), - floatValue(node, "outputMax", 1.f), intValue(node, "clamp", 1) != 0); - } else if (node.operation == "attribute.math.float") { - const std::string attribute = stringValue(node, "attribute"); - std::string outputAttribute = stringValue(node, "outputAttribute"); - if (outputAttribute.empty()) outputAttribute = attribute; - const std::string operation = stringValue(node, "operation", "multiply"); - const float operand = floatValue(node, "operand", 1.f); - const bool operationKnown = operation == "add" || operation == "subtract" || - operation == "multiply" || operation == "divide" || - operation == "min" || operation == "max"; - if (!first) error_ = "attribute.math.float requires input: " + id; - else if (attribute.empty()) error_ = "attribute.math.float requires an attribute: " + id; - else if (!operationKnown) - error_ = "attribute.math.float has invalid operation at node: " + id; - else if (operation == "divide" && operand == 0.f) - error_ = "attribute.math.float cannot divide by zero at node: " + id; - else - node.cache = mathPointFloatAttribute( - *first, attribute, outputAttribute, operation, operand, - floatValue(node, "defaultValue", 0.f)); - } else if (node.operation == "transform") { - if (!first) error_ = "transform requires input: " + id; - else { - const bool gpuEligible = - !first->empty() && - (computePolicy_ == "gpu" || (computePolicy_ == "auto" && first->getCount() >= computeMinimumPoints_)); - const bool gpuComplete = - gpuEligible && - pointCompute_->transform(*first, node.cache, floatValue(node, "x", 0.f), floatValue(node, "y", 0.f), - floatValue(node, "z", 0.f), floatValue(node, "yaw", 0.f), - floatValue(node, "scaleX", 1.f), floatValue(node, "scaleY", 1.f), - floatValue(node, "scaleZ", 1.f)); - if (gpuComplete) { -#ifdef EVENGINE_WEBGPU - nodeBackend = "webgpu"; -#else - nodeBackend = "vulkan"; -#endif - } else { - if (gpuEligible) computeFallbackReason_ = pointCompute_->getError(); - node.cache = transformPointSet(*first, floatValue(node, "x", 0.f), floatValue(node, "y", 0.f), - floatValue(node, "z", 0.f), floatValue(node, "yaw", 0.f), - floatValue(node, "scaleX", 1.f), floatValue(node, "scaleY", 1.f), - floatValue(node, "scaleZ", 1.f)); - } - } - } else if (node.operation == "filter.float") { - if (!first) error_ = "filter.float requires input: " + id; - else - node.cache = filterPointFloatAttribute( - *first, stringValue(node, "attribute"), floatValue(node, "min", 0.f), - floatValue(node, "max", 1.f), intValue(node, "invert", 0) != 0); - } else if (node.operation == "filter.string") { - if (!first) error_ = "filter.string requires input: " + id; - else - node.cache = filterPointStringAttribute( - *first, stringValue(node, "attribute"), stringValue(node, "value"), - intValue(node, "invert", 0) != 0); - } else if (node.operation == "filter.slope") { - if (!first) error_ = "filter.slope requires input: " + id; - else - node.cache = filterPointSlope(*first, floatValue(node, "minDegrees", 0.f), - floatValue(node, "maxDegrees", 90.f)); - } else if (node.operation == "attribute.set.float") { - if (!first) error_ = "attribute.set.float requires input: " + id; - else { - node.cache = *first; - const std::string attribute = stringValue(node, "attribute"); - if (attribute.empty()) error_ = "attribute.set.float requires attribute: " + id; - else - for (int i = 0; i < node.cache.getCount(); ++i) - node.cache.setFloatAttribute(i, attribute, floatValue(node, "value", 0.f)); - } - } else if (node.operation == "attribute.set.string") { - if (!first) error_ = "attribute.set.string requires input: " + id; - else { - node.cache = *first; - const std::string attribute = stringValue(node, "attribute"); - if (attribute.empty()) error_ = "attribute.set.string requires attribute: " + id; - else - for (int i = 0; i < node.cache.getCount(); ++i) - node.cache.setStringAttribute(i, attribute, stringValue(node, "value")); - } - } else if (node.operation == "density.cull") { - if (!first) error_ = "density.cull requires input: " + id; - else - node.cache = densityCullPoints(*first, uint32_t(intValue(node, "seed", 1)), - floatValue(node, "multiplier", 1.f)); - } else if (node.operation == "self.prune") { - if (!first) error_ = "self.prune requires input: " + id; - else node.cache = selfPrunePoints(*first, floatValue(node, "radius", 1.f)); - } else if (node.operation == "jitter") { - if (!first) error_ = "jitter requires input: " + id; - else - node.cache = jitterPointPositions(*first, uint32_t(intValue(node, "seed", 1)), - floatValue(node, "x", 0.f), - floatValue(node, "z", 0.f)); - } else if (node.operation == "branch") { - const bool condition = intValue(node, "condition", 0) != 0; - if ((condition && !first) || (!condition && !second)) - error_ = "branch requires both selected inputs: " + id; - else node.cache = condition ? *first : *second; - } else if (node.operation == "subgraph") { - if (!first || !node.subgraph) error_ = "subgraph requires input and graph: " + id; - else { - node.subgraph->setMaxNodeOutputPoints(maxNodeOutputPoints_); - node.subgraph->setComputePolicy(computePolicy_); - node.subgraph->setComputeMinimumPoints(computeMinimumPoints_); - if (!node.subgraph->setNodePoints(node.subgraphInput, const_cast(first))) - error_ = "subgraph input failed at " + id + ": " + node.subgraph->getError(); - else { - std::unique_ptr result(node.subgraph->execute(node.subgraphOutput)); - if (!result) - error_ = "subgraph failed at " + id + ": " + node.subgraph->getError(); - else - node.cache = *result; - } - } - } - - if (error_.empty() && maxNodeOutputPoints_ > 0 && - node.cache.getCount() > maxNodeOutputPoints_) { - node.cache.clear(); - error_ = node.operation + " exceeds node point budget at node: " + id; - } - if (!error_.empty()) return nullptr; - node.cacheValid = true; - states[id] = 2; - const float elapsed = float(nowNanoseconds() - started) * 0.000001f; - node.cacheMetric = makeMetric(id, node.cache, elapsed, false, nodeBackend); - metrics_.push_back(node.cacheMetric); - return &node.cache; -} - -const PointSet* PointGraph::evaluateTransformSegment(const std::string& id, - std::unordered_map& states) { - if (!executionPlan_) return nullptr; - const auto segmentEntry = executionPlan_->segmentByOutput.find(id); - if (segmentEntry == executionPlan_->segmentByOutput.end()) return nullptr; - const ExecutionSegment& planned = executionPlan_->segments[segmentEntry->second]; - if (!planned.gpuTransformChain || planned.nodes.size() < 2) return nullptr; - const std::vector& chain = planned.nodes; - if (std::any_of(chain.begin(), chain.end(), - [&](const std::string& nodeId) { return nodes_.at(nodeId).cacheValid; })) - return nullptr; - const std::string& cursor = nodes_.at(chain.front()).inputs[0]; - - const PointSet* input = evaluate(cursor, states); - if (!input) return nullptr; - const bool gpuEligible = - !input->empty() && - (computePolicy_ == "gpu" || (computePolicy_ == "auto" && input->getCount() >= computeMinimumPoints_)); - if (!gpuEligible) return nullptr; - if (executionNodeBudget_ > 0 && evaluatedNodes_ + int(chain.size()) > executionNodeBudget_) { - error_ = "execution node budget exceeded at node: " + id; - lastCancelled_ = true; - return nullptr; - } - - std::vector transforms; - transforms.reserve(chain.size()); - for (const std::string& nodeId : chain) { - const Node& transformNode = nodes_.at(nodeId); - transforms.push_back({floatValue(transformNode, "x", 0.f), floatValue(transformNode, "y", 0.f), - floatValue(transformNode, "z", 0.f), floatValue(transformNode, "yaw", 0.f), - floatValue(transformNode, "scaleX", 1.f), floatValue(transformNode, "scaleY", 1.f), - floatValue(transformNode, "scaleZ", 1.f)}); - } - - PointSet output; - const uint64_t started = nowNanoseconds(); - if (!pointCompute_->transformChain(*input, output, transforms)) { - computeFallbackReason_ = pointCompute_->getError(); - return nullptr; - } - - evaluatedNodes_ += int(chain.size()); - for (const std::string& nodeId : chain) { - Node& transformNode = nodes_.at(nodeId); - transformNode.deferredTransformValid = true; - states[nodeId] = 2; - } - Node& finalNode = nodes_.at(id); - finalNode.cache = std::move(output); - finalNode.cacheValid = true; - finalNode.deferredTransformValid = false; -#ifdef EVENGINE_WEBGPU - const std::string backend = "webgpu"; -#else - const std::string backend = "vulkan"; -#endif - const float elapsed = float(nowNanoseconds() - started) * 0.000001f; - finalNode.cacheMetric = makeMetric(id, finalNode.cache, elapsed, false, backend); - metrics_.push_back(finalNode.cacheMetric); - return &finalNode.cache; -} - -bool PointGraph::validateNode(const std::string& id, std::unordered_map& states) { - if (states[id] == 2) return true; - if (states[id] == 1) { - error_ = "cycle at node: " + id; - return false; - } - const auto found = nodes_.find(id); - if (found == nodes_.end() || !operationKnown(found->second.operation)) { - error_ = "invalid node: " + id; - return false; - } - states[id] = 1; - for (const auto& input : found->second.inputs) { - if (!input.empty() && !validateNode(input, states)) return false; - } - const auto& node = found->second; - if (node.operation == "input" && !node.hasPoints) error_ = "input node has no points: " + id; - else if ((node.operation == "spatial.sample" || node.operation == "spatial.filter" || - node.operation == "spatial.project" || node.operation == "biome.generate") && - !node.spatial) - error_ = "node has no spatial data: " + id; - else if (node.operation == "biome.generate" && !node.biomeRules) - error_ = "node has no biome rules: " + id; - else if (node.operation == "grammar.generate" && !node.shapeGrammar) - error_ = "node has no shape grammar: " + id; - else if (node.operation == "subgraph" && (!node.subgraph || node.inputs[0].empty())) - error_ = "subgraph is not configured: " + id; - else if (node.operation == "subgraph") { - PointGraph nested = *node.subgraph; - PointSet externalInput; - if (!nested.setNodePoints(node.subgraphInput, &externalInput) || !nested.validate()) - error_ = "invalid subgraph at " + id + ": " + nested.getError(); - } else { - const auto* spec = operationSpec(node.operation); - for (int index = 0; spec && index < spec->inputs; ++index) - if (node.inputs[index].empty()) { - error_ = node.operation + " requires input " + std::to_string(index) + ": " + id; - break; - } - } - if (!error_.empty()) return false; - states[id] = 2; - return true; -} - void PointGraph::invalidate() { clearCache(); } void PointGraph::invalidateFrom(const std::string& id) { diff --git a/src/modules/procgen/PointGraph.h b/src/modules/procgen/PointGraph.h index c703ec43..2e77635c 100644 --- a/src/modules/procgen/PointGraph.h +++ b/src/modules/procgen/PointGraph.h @@ -1,5 +1,6 @@ #pragma once +#include "common/BorrowedRef.h" #include "procgen/PointSet.h" #include "procgen/SpatialData.h" @@ -7,6 +8,7 @@ #include #include #include +#include #include #include @@ -102,7 +104,21 @@ public: bool setNodeSubgraph(const std::string& id, PointGraph* graph, const std::string& inputNode, const std::string& outputNode); - /** @brief Evaluate one node and return a caller-owned result, or nullptr on failure. */ + /** + * @brief Evaluate an output and return an independent owning point snapshot. + * @param outputId Output node identity. + * @return Owning points or structured node/path diagnostics; failure publishes no output. + * @thread Graph-owning thread only; not reentrant. Successful intermediate caches may survive failure. + * @ownership The value and diagnostics remain valid after graph mutation or destruction. + */ + [[nodiscard]] Result executeResult(std::string_view outputId); + /** + * @brief Validate configured nodes and nested graphs without executing them. + * @return Structured topology/configuration diagnostics, independent of getError(). + * @thread Graph-owning thread only; not reentrant. Does not mutate node results. + */ + [[nodiscard]] Result validateResult() const; + /** @brief Compatibility-only projection of executeResult; caller deletes the result, nullptr means failure. */ PointSet* execute(const std::string& outputId); /** @brief Limit uncached nodes evaluated by one execute call; zero disables the limit. */ void setExecutionNodeBudget(int nodes); @@ -123,8 +139,10 @@ public: void requestCancel(); void resetCancellation(); bool wasCancelled() const; - /** @brief Validate ids, operations, required inputs and graph acyclicity. */ + /** @brief Compatibility-only bool projection of validateResult; diagnostics are rendered by getError(). */ bool validate(); + /** @brief Render the last compatibility execute/validate or legacy authoring error; canonical Result calls do not + * update it. */ std::string getError() const; void clearCache(); /** @brief Monotonic topology/parameter revision used by asset and preview caches. */ @@ -240,15 +258,16 @@ private: std::unordered_map segmentByOutput; }; - const PointSet* evaluate(const std::string& id, std::unordered_map& states); - const PointSet* evaluateTransformSegment(const std::string& id, std::unordered_map& states); + [[nodiscard]] ResultRef evaluate(const std::string& id, + std::unordered_map& states); + [[nodiscard]] Result> evaluateTransformSegment( + const std::string& id, std::unordered_map& states); PointSet* materializeNodeOutput(const std::string& id) const; - bool validateNode(const std::string& id, - std::unordered_map& states); + [[nodiscard]] Result validateNode(const std::string& id, std::unordered_map& states) const; void invalidate(); void invalidateFrom(const std::string& id); void invalidateTopology(const std::string& changedNode); - void compileExecutionPlan(const std::string& outputId); + [[nodiscard]] Result compileExecutionPlan(const std::string& outputId); float floatValue(const Node& node, const std::string& key, float fallback) const; int intValue(const Node& node, const std::string& key, int fallback) const; std::string stringValue(const Node& node, const std::string& key, diff --git a/src/modules/procgen/PointGraphExecution.cpp b/src/modules/procgen/PointGraphExecution.cpp new file mode 100644 index 00000000..7cbb7d7e --- /dev/null +++ b/src/modules/procgen/PointGraphExecution.cpp @@ -0,0 +1,500 @@ +#include +#include +#include +#include +#include "procgen/Biome.h" +#include "procgen/PointCompute.h" +#include "procgen/PointGraph.h" +#include "procgen/ShapeGrammar.h" + +namespace eve::procgen { +namespace { +Status nestedFailure(const std::string& id, const Status& status) { + std::vector diagnostics; + for (const auto& diagnostic : status.diagnostics()) + diagnostics.emplace_back(diagnostic.code(), diagnostic.severity(), diagnostic.message(), + id + "/" + diagnostic.path(), diagnostic.details(), diagnostic.source()); + return Status(status.code(), std::move(diagnostics)); +} + +template +Result nodeFailure(DiagnosticCode code, const std::string& id, std::string message) { + return Result::failure(Diagnostic::error(code, std::move(message), id, {}, "procgen.pointGraph")); +} + +uint64_t nowNanoseconds() { + return uint64_t( + std::chrono::duration_cast(std::chrono::steady_clock::now().time_since_epoch()) + .count()); +} + +PointGraphNodeMetric makeMetric(const std::string& id, const PointSet& points, float milliseconds, bool cacheHit, + const std::string& backend = "cpu") { + PointGraphNodeMetric metric; + metric.id = id; + metric.outputCount = points.getCount(); + metric.milliseconds = milliseconds; + metric.cacheHit = cacheHit; + metric.backend = backend; + if (points.empty()) return metric; + const auto& first = points.points().front(); + metric.minX = metric.maxX = first.x; + metric.minY = metric.maxY = first.y; + metric.minZ = metric.maxZ = first.z; + double densitySum = 0.0; + for (const auto& point : points.points()) { + metric.minX = std::min(metric.minX, point.x); + metric.minY = std::min(metric.minY, point.y); + metric.minZ = std::min(metric.minZ, point.z); + metric.maxX = std::max(metric.maxX, point.x); + metric.maxY = std::max(metric.maxY, point.y); + metric.maxZ = std::max(metric.maxZ, point.z); + densitySum += point.density; + } + metric.averageDensity = float(densitySum / double(points.getCount())); + return metric; +} + +uint64_t spatialSampleUpperBound(const SpatialData& spatial, float spacing) { + if (!spatial.hasBounds() || spacing <= 0.f) return 0; + const auto axisCount = [spacing](float minimum, float maximum) { + if (maximum <= minimum) return uint64_t(1); + return uint64_t(std::floor(double(maximum - minimum) / double(spacing) + 0.001)) + 1; + }; + const uint64_t x = axisCount(spatial.getMinX(), spatial.getMaxX()); + const uint64_t y = + spatial.getKind() == "surface.heightfield" ? uint64_t(1) : axisCount(spatial.getMinY(), spatial.getMaxY()); + const uint64_t z = axisCount(spatial.getMinZ(), spatial.getMaxZ()); + const uint64_t maximum = std::numeric_limits::max(); + if (x > maximum / y) return maximum; + const uint64_t xy = x * y; + return xy > maximum / z ? maximum : xy * z; +} + +} // namespace + +ResultRef PointGraph::evaluate(const std::string& id, std::unordered_map& states) { + const auto found = nodes_.find(id); + if (found == nodes_.end()) { + return nodeFailure>(DiagnosticCode::NotFound, id, "unknown node: " + id); + } + Node& node = found->second; + if (node.cacheValid) { + ++cacheHitCount_; + PointGraphNodeMetric metric = node.cacheMetric; + metric.milliseconds = 0.f; + metric.cacheHit = true; + metrics_.push_back(std::move(metric)); + return ResultRef::success(std::cref(node.cache)); + } + if (states[id] == 1) { + return nodeFailure>(DiagnosticCode::Conflict, id, + "cycle at node: " + id); + } + if (cancelRequested_) { + lastCancelled_ = true; + return nodeFailure>(DiagnosticCode::Cancelled, id, + "execution cancelled at node: " + id); + } + if (node.operation == "transform") { + auto segment = evaluateTransformSegment(id, states); + if (!segment.ok()) return ResultRef::failure(segment.status()); + auto value = std::move(segment).takeValue(); + if (value) return ResultRef::success(*value); + } + states[id] = 1; + const uint64_t started = nowNanoseconds(); + std::string nodeBackend = "cpu"; + const PointSet* first = nullptr; + const PointSet* second = nullptr; + if (!node.inputs[0].empty()) { + auto input = evaluate(node.inputs[0], states); + if (!input.ok()) return ResultRef::failure(input.status()); + first = &input.value().get(); + } + if (!node.inputs[1].empty()) { + auto input = evaluate(node.inputs[1], states); + if (!input.ok()) return ResultRef::failure(input.status()); + second = &input.value().get(); + } + if (executionNodeBudget_ > 0 && evaluatedNodes_ >= executionNodeBudget_) { + lastCancelled_ = true; + return nodeFailure>(DiagnosticCode::Cancelled, id, + "execution node budget exceeded at node: " + id); + } + ++evaluatedNodes_; + + if (node.operation == "input") { + if (!node.hasPoints) + return nodeFailure>(DiagnosticCode::InvalidArgument, id, + "input node has no points: " + id); + else + node.cache = node.points; + } else if (node.operation == "spatial.sample") { + const float spacing = floatValue(node, "spacing", 1.f); + if (!node.spatial) + return nodeFailure>(DiagnosticCode::InvalidArgument, id, + "spatial.sample has no spatial data: " + id); + else if (maxNodeOutputPoints_ > 0 && + spatialSampleUpperBound(*node.spatial, spacing) > uint64_t(maxNodeOutputPoints_)) + return nodeFailure>( + DiagnosticCode::InvalidArgument, id, "spatial.sample exceeds node point budget at node: " + id); + else + node.cache = + node.spatial->sample(spacing, uint32_t(intValue(node, "seed", 1)), floatValue(node, "jitter", 0.f)); + } else if (node.operation == "spatial.filter") { + if (!first || !node.spatial) + return nodeFailure>( + DiagnosticCode::InvalidArgument, id, "spatial.filter requires input and spatial data: " + id); + else + node.cache = node.spatial->filter(*first, intValue(node, "invert", 0) != 0); + } else if (node.operation == "spatial.project") { + if (!first || !node.spatial) + return nodeFailure>( + DiagnosticCode::InvalidArgument, id, "spatial.project requires input and spatial data: " + id); + else + node.cache = node.spatial->project(*first); + } else if (node.operation == "biome.generate") { + const float spacing = floatValue(node, "spacing", 1.f); + if (!node.spatial || !node.biomeRules) + return nodeFailure>( + DiagnosticCode::InvalidArgument, id, "biome.generate requires spatial data and biome rules: " + id); + else if (maxNodeOutputPoints_ > 0 && + spatialSampleUpperBound(*node.spatial, spacing) > uint64_t(maxNodeOutputPoints_)) + return nodeFailure>( + DiagnosticCode::InvalidArgument, id, "biome.generate exceeds node point budget at node: " + id); + else { + std::unique_ptr result(node.biomeRules->generate( + node.spatial.get(), spacing, uint32_t(intValue(node, "seed", 1)), floatValue(node, "jitter", 0.f))); + if (!result) + return nodeFailure>( + DiagnosticCode::Failed, id, "biome.generate failed at " + id + ": " + node.biomeRules->getError()); + else + node.cache = *result; + } + } else if (node.operation == "grammar.generate") { + if (!first || !node.shapeGrammar) + return nodeFailure>( + DiagnosticCode::InvalidArgument, id, "grammar.generate requires input and shape grammar: " + id); + else { + std::unique_ptr result(node.shapeGrammar->generate( + stringValue(node, "grammar"), const_cast(first), uint32_t(intValue(node, "seed", 1)), + intValue(node, "acceptIncomplete", 1) != 0)); + if (!result) + return nodeFailure>( + DiagnosticCode::Failed, id, + "grammar.generate failed at " + id + ": " + node.shapeGrammar->getError()); + else + node.cache = *result; + } + } else if (node.operation == "merge") { + const uint64_t outputCount = first && second ? uint64_t(first->getCount()) + uint64_t(second->getCount()) : 0; + if (!first || !second) + return nodeFailure>(DiagnosticCode::InvalidArgument, id, + "merge requires two inputs: " + id); + else if (maxNodeOutputPoints_ > 0 && outputCount > uint64_t(maxNodeOutputPoints_)) + return nodeFailure>( + DiagnosticCode::InvalidArgument, id, "merge exceeds node point budget at node: " + id); + else + node.cache = mergePointSets(*first, *second); + } else if (node.operation == "copy.points") { + const int maxPoints = intValue(node, "maxPoints", 100000); + const uint64_t outputCount = first && second ? uint64_t(first->getCount()) * uint64_t(second->getCount()) : 0; + if (!first || !second) + return nodeFailure>( + DiagnosticCode::InvalidArgument, id, "copy.points requires source and target inputs: " + id); + else if (maxPoints < 0 || outputCount > uint64_t(maxPoints)) + return nodeFailure>( + DiagnosticCode::InvalidArgument, id, "copy.points output exceeds maxPoints at node: " + id); + else if (maxNodeOutputPoints_ > 0 && outputCount > uint64_t(maxNodeOutputPoints_)) + return nodeFailure>( + DiagnosticCode::InvalidArgument, id, "copy.points exceeds node point budget at node: " + id); + else + node.cache = copyPointsToTargets(*first, *second, intValue(node, "inheritTargetAttributes", 1) != 0); + } else if (node.operation == "density.remap") { + const float inputMin = floatValue(node, "inputMin", 0.f); + const float inputMax = floatValue(node, "inputMax", 1.f); + if (!first) + return nodeFailure>(DiagnosticCode::InvalidArgument, id, + "density.remap requires input: " + id); + else if (inputMin == inputMax) + return nodeFailure>( + DiagnosticCode::InvalidArgument, id, "density.remap requires a non-zero input range: " + id); + else + node.cache = remapPointDensity(*first, inputMin, inputMax, floatValue(node, "outputMin", 0.f), + floatValue(node, "outputMax", 1.f), intValue(node, "clamp", 1) != 0); + } else if (node.operation == "attribute.math.float") { + const std::string attribute = stringValue(node, "attribute"); + std::string outputAttribute = stringValue(node, "outputAttribute"); + if (outputAttribute.empty()) outputAttribute = attribute; + const std::string operation = stringValue(node, "operation", "multiply"); + const float operand = floatValue(node, "operand", 1.f); + const bool operationKnown = operation == "add" || operation == "subtract" || operation == "multiply" || + operation == "divide" || operation == "min" || operation == "max"; + if (!first) + return nodeFailure>(DiagnosticCode::InvalidArgument, id, + "attribute.math.float requires input: " + id); + else if (attribute.empty()) + return nodeFailure>( + DiagnosticCode::InvalidArgument, id, "attribute.math.float requires an attribute: " + id); + else if (!operationKnown) + return nodeFailure>( + DiagnosticCode::InvalidArgument, id, "attribute.math.float has invalid operation at node: " + id); + else if (operation == "divide" && operand == 0.f) + return nodeFailure>( + DiagnosticCode::InvalidArgument, id, "attribute.math.float cannot divide by zero at node: " + id); + else + node.cache = mathPointFloatAttribute(*first, attribute, outputAttribute, operation, operand, + floatValue(node, "defaultValue", 0.f)); + } else if (node.operation == "transform") { + if (!first) + return nodeFailure>(DiagnosticCode::InvalidArgument, id, + "transform requires input: " + id); + else { + const bool gpuEligible = + !first->empty() && + (computePolicy_ == "gpu" || (computePolicy_ == "auto" && first->getCount() >= computeMinimumPoints_)); + const bool gpuComplete = + gpuEligible && + pointCompute_->transform(*first, node.cache, floatValue(node, "x", 0.f), floatValue(node, "y", 0.f), + floatValue(node, "z", 0.f), floatValue(node, "yaw", 0.f), + floatValue(node, "scaleX", 1.f), floatValue(node, "scaleY", 1.f), + floatValue(node, "scaleZ", 1.f)); + if (gpuComplete) { +#ifdef EVENGINE_WEBGPU + nodeBackend = "webgpu"; +#else + nodeBackend = "vulkan"; +#endif + } else { + if (gpuEligible) computeFallbackReason_ = pointCompute_->getError(); + node.cache = transformPointSet(*first, floatValue(node, "x", 0.f), floatValue(node, "y", 0.f), + floatValue(node, "z", 0.f), floatValue(node, "yaw", 0.f), + floatValue(node, "scaleX", 1.f), floatValue(node, "scaleY", 1.f), + floatValue(node, "scaleZ", 1.f)); + } + } + } else if (node.operation == "filter.float") { + if (!first) + return nodeFailure>(DiagnosticCode::InvalidArgument, id, + "filter.float requires input: " + id); + else + node.cache = filterPointFloatAttribute(*first, stringValue(node, "attribute"), floatValue(node, "min", 0.f), + floatValue(node, "max", 1.f), intValue(node, "invert", 0) != 0); + } else if (node.operation == "filter.string") { + if (!first) + return nodeFailure>(DiagnosticCode::InvalidArgument, id, + "filter.string requires input: " + id); + else + node.cache = filterPointStringAttribute(*first, stringValue(node, "attribute"), stringValue(node, "value"), + intValue(node, "invert", 0) != 0); + } else if (node.operation == "filter.slope") { + if (!first) + return nodeFailure>(DiagnosticCode::InvalidArgument, id, + "filter.slope requires input: " + id); + else + node.cache = + filterPointSlope(*first, floatValue(node, "minDegrees", 0.f), floatValue(node, "maxDegrees", 90.f)); + } else if (node.operation == "attribute.set.float") { + if (!first) + return nodeFailure>(DiagnosticCode::InvalidArgument, id, + "attribute.set.float requires input: " + id); + else { + node.cache = *first; + const std::string attribute = stringValue(node, "attribute"); + if (attribute.empty()) + return nodeFailure>( + DiagnosticCode::InvalidArgument, id, "attribute.set.float requires attribute: " + id); + else + for (int i = 0; i < node.cache.getCount(); ++i) + node.cache.setFloatAttribute(i, attribute, floatValue(node, "value", 0.f)); + } + } else if (node.operation == "attribute.set.string") { + if (!first) + return nodeFailure>(DiagnosticCode::InvalidArgument, id, + "attribute.set.string requires input: " + id); + else { + node.cache = *first; + const std::string attribute = stringValue(node, "attribute"); + if (attribute.empty()) + return nodeFailure>( + DiagnosticCode::InvalidArgument, id, "attribute.set.string requires attribute: " + id); + else + for (int i = 0; i < node.cache.getCount(); ++i) + node.cache.setStringAttribute(i, attribute, stringValue(node, "value")); + } + } else if (node.operation == "density.cull") { + if (!first) + return nodeFailure>(DiagnosticCode::InvalidArgument, id, + "density.cull requires input: " + id); + else + node.cache = + densityCullPoints(*first, uint32_t(intValue(node, "seed", 1)), floatValue(node, "multiplier", 1.f)); + } else if (node.operation == "self.prune") { + if (!first) + return nodeFailure>(DiagnosticCode::InvalidArgument, id, + "self.prune requires input: " + id); + else + node.cache = selfPrunePoints(*first, floatValue(node, "radius", 1.f)); + } else if (node.operation == "jitter") { + if (!first) + return nodeFailure>(DiagnosticCode::InvalidArgument, id, + "jitter requires input: " + id); + else + node.cache = jitterPointPositions(*first, uint32_t(intValue(node, "seed", 1)), floatValue(node, "x", 0.f), + floatValue(node, "z", 0.f)); + } else if (node.operation == "branch") { + const bool condition = intValue(node, "condition", 0) != 0; + if ((condition && !first) || (!condition && !second)) + return nodeFailure>(DiagnosticCode::InvalidArgument, id, + "branch requires both selected inputs: " + id); + else + node.cache = condition ? *first : *second; + } else if (node.operation == "subgraph") { + if (!first || !node.subgraph) + return nodeFailure>(DiagnosticCode::InvalidArgument, id, + "subgraph requires input and graph: " + id); + else { + node.subgraph->setMaxNodeOutputPoints(maxNodeOutputPoints_); + node.subgraph->setComputePolicy(computePolicy_); + node.subgraph->setComputeMinimumPoints(computeMinimumPoints_); + if (!node.subgraph->setNodePoints(node.subgraphInput, const_cast(first))) + return nodeFailure>( + DiagnosticCode::InvalidArgument, id, "subgraph input is invalid: " + node.subgraphInput); + else { + auto result = node.subgraph->executeResult(node.subgraphOutput); + if (!result.ok()) return ResultRef::failure(nestedFailure(id, result.status())); + node.cache = std::move(result).takeValue(); + } + } + } + + if (maxNodeOutputPoints_ > 0 && node.cache.getCount() > maxNodeOutputPoints_) { + node.cache.clear(); + return nodeFailure>( + DiagnosticCode::InvalidArgument, id, node.operation + " exceeds node point budget at node: " + id); + } + node.cacheValid = true; + states[id] = 2; + const float elapsed = float(nowNanoseconds() - started) * 0.000001f; + node.cacheMetric = makeMetric(id, node.cache, elapsed, false, nodeBackend); + metrics_.push_back(node.cacheMetric); + return ResultRef::success(std::cref(node.cache)); +} + +Result> PointGraph::evaluateTransformSegment(const std::string& id, + std::unordered_map& states) { + if (!executionPlan_) return Result>::success(std::nullopt); + const auto segmentEntry = executionPlan_->segmentByOutput.find(id); + if (segmentEntry == executionPlan_->segmentByOutput.end()) + return Result>::success(std::nullopt); + const ExecutionSegment& planned = executionPlan_->segments[segmentEntry->second]; + if (!planned.gpuTransformChain || planned.nodes.size() < 2) + return Result>::success(std::nullopt); + const std::vector& chain = planned.nodes; + if (std::any_of(chain.begin(), chain.end(), + [&](const std::string& nodeId) { return nodes_.at(nodeId).cacheValid; })) + return Result>::success(std::nullopt); + const std::string& cursor = nodes_.at(chain.front()).inputs[0]; + + auto evaluated = evaluate(cursor, states); + if (!evaluated.ok()) return Result>::failure(evaluated.status()); + const PointSet* input = &evaluated.value().get(); + const bool gpuEligible = + !input->empty() && + (computePolicy_ == "gpu" || (computePolicy_ == "auto" && input->getCount() >= computeMinimumPoints_)); + if (!gpuEligible) return Result>::success(std::nullopt); + if (executionNodeBudget_ > 0 && evaluatedNodes_ + int(chain.size()) > executionNodeBudget_) { + lastCancelled_ = true; + return nodeFailure>(DiagnosticCode::Cancelled, id, + "execution node budget exceeded at node: " + id); + } + + std::vector transforms; + transforms.reserve(chain.size()); + for (const std::string& nodeId : chain) { + const Node& transformNode = nodes_.at(nodeId); + transforms.push_back({floatValue(transformNode, "x", 0.f), floatValue(transformNode, "y", 0.f), + floatValue(transformNode, "z", 0.f), floatValue(transformNode, "yaw", 0.f), + floatValue(transformNode, "scaleX", 1.f), floatValue(transformNode, "scaleY", 1.f), + floatValue(transformNode, "scaleZ", 1.f)}); + } + + PointSet output; + const uint64_t started = nowNanoseconds(); + if (!pointCompute_->transformChain(*input, output, transforms)) { + computeFallbackReason_ = pointCompute_->getError(); + return Result>::success(std::nullopt); + } + + evaluatedNodes_ += int(chain.size()); + for (const std::string& nodeId : chain) { + Node& transformNode = nodes_.at(nodeId); + transformNode.deferredTransformValid = true; + states[nodeId] = 2; + } + Node& finalNode = nodes_.at(id); + finalNode.cache = std::move(output); + finalNode.cacheValid = true; + finalNode.deferredTransformValid = false; +#ifdef EVENGINE_WEBGPU + const std::string backend = "webgpu"; +#else + const std::string backend = "vulkan"; +#endif + const float elapsed = float(nowNanoseconds() - started) * 0.000001f; + finalNode.cacheMetric = makeMetric(id, finalNode.cache, elapsed, false, backend); + metrics_.push_back(finalNode.cacheMetric); + return Result>::success(std::cref(finalNode.cache)); +} + +Result PointGraph::validateNode(const std::string& id, std::unordered_map& states) const { + if (states[id] == 2) return Result::success(); + if (states[id] == 1) { + return nodeFailure(DiagnosticCode::Conflict, id, "cycle at node: " + id); + } + const auto found = nodes_.find(id); + if (found == nodes_.end() || getOperationInputCount(found->second.operation) < 0) { + return nodeFailure(DiagnosticCode::InvalidArgument, id, "invalid node: " + id); + } + states[id] = 1; + for (const auto& input : found->second.inputs) { + if (!input.empty()) { + auto result = validateNode(input, states); + if (!result.ok()) return result; + } + } + const auto& node = found->second; + if (node.operation == "input" && !node.hasPoints) + return nodeFailure(DiagnosticCode::InvalidArgument, id, "input node has no points: " + id); + else if ((node.operation == "spatial.sample" || node.operation == "spatial.filter" || + node.operation == "spatial.project" || node.operation == "biome.generate") && + !node.spatial) + return nodeFailure(DiagnosticCode::InvalidArgument, id, "node has no spatial data: " + id); + else if (node.operation == "biome.generate" && !node.biomeRules) + return nodeFailure(DiagnosticCode::InvalidArgument, id, "node has no biome rules: " + id); + else if (node.operation == "grammar.generate" && !node.shapeGrammar) + return nodeFailure(DiagnosticCode::InvalidArgument, id, "node has no shape grammar: " + id); + else if (node.operation == "subgraph" && (!node.subgraph || node.inputs[0].empty())) + return nodeFailure(DiagnosticCode::InvalidArgument, id, "subgraph is not configured: " + id); + else if (node.operation == "subgraph") { + PointGraph nested = *node.subgraph; + PointSet externalInput; + if (!nested.setNodePoints(node.subgraphInput, &externalInput)) + return nodeFailure(DiagnosticCode::InvalidArgument, id, + "invalid subgraph input: " + node.subgraphInput); + auto result = nested.validateResult(); + if (!result.ok()) return Result::failure(nestedFailure(id, result.status())); + } else { + const int inputCount = getOperationInputCount(node.operation); + for (int index = 0; index < inputCount; ++index) + if (node.inputs[index].empty()) { + return nodeFailure(DiagnosticCode::InvalidArgument, id, + node.operation + " requires input " + std::to_string(index) + ": " + id); + } + } + states[id] = 2; + return Result::success(); +} + +} // namespace eve::procgen diff --git a/src/modules/procgen/PointGraphPlan.cpp b/src/modules/procgen/PointGraphPlan.cpp index 96f47c47..6dafd487 100644 --- a/src/modules/procgen/PointGraphPlan.cpp +++ b/src/modules/procgen/PointGraphPlan.cpp @@ -18,43 +18,46 @@ void PointGraph::invalidateTopology(const std::string& changedNode) { } } -void PointGraph::compileExecutionPlan(const std::string& outputId) { +Result PointGraph::compileExecutionPlan(const std::string& outputId) { if (const auto found = executionPlans_.find(outputId); found != executionPlans_.end()) { executionPlan_ = found->second; std::erase(executionPlanRecency_, outputId); executionPlanRecency_.push_back(outputId); - return; + return Result::success(); } executionPlan_.reset(); ExecutionPlan plan; std::unordered_map states; - std::function visit = [&](const std::string& id) { - if (!error_.empty() || states[id] == 2) return; + std::function(const std::string&)> visit = [&](const std::string& id) -> Result { + if (states[id] == 2) return Result::success(); if (states[id] == 1) { - error_ = "cycle at node: " + id; - return; + return Result::failure( + Diagnostic::error(DiagnosticCode::Conflict, "cycle at node: " + id, id, {}, "procgen.pointGraph")); } const auto found = nodes_.find(id); if (found == nodes_.end()) { - error_ = "unknown node: " + id; - return; + return Result::failure( + Diagnostic::error(DiagnosticCode::NotFound, "unknown node: " + id, id, {}, "procgen.pointGraph")); } states[id] = 1; const int inputCount = getOperationInputCount(found->second.operation); for (int input = 0; input < inputCount; ++input) { if (found->second.inputs[input].empty()) { - error_ = found->second.operation + " requires input " + std::to_string(input) + ": " + id; - return; + return Result::failure( + Diagnostic::error(DiagnosticCode::InvalidArgument, + found->second.operation + " requires input " + std::to_string(input) + ": " + id, + id, {}, "procgen.pointGraph")); } - visit(found->second.inputs[input]); + auto result = visit(found->second.inputs[input]); + if (!result.ok()) return result; } - if (!error_.empty()) return; states[id] = 2; plan.topologicalOrder.push_back(id); + return Result::success(); }; - visit(outputId); - if (!error_.empty()) return; + auto visited = visit(outputId); + if (!visited.ok()) return visited; std::unordered_map> consumers; for (const std::string& id : plan.topologicalOrder) { @@ -91,6 +94,7 @@ void PointGraph::compileExecutionPlan(const std::string& outputId) { executionPlanRecency_.erase(executionPlanRecency_.begin()); } ++executionPlanBuildCount_; + return Result::success(); } } // namespace eve::procgen diff --git a/src/modules/procgen/PointGraphScript.cpp b/src/modules/procgen/PointGraphScript.cpp index b1cdced2..3d62d065 100644 --- a/src/modules/procgen/PointGraphScript.cpp +++ b/src/modules/procgen/PointGraphScript.cpp @@ -1,5 +1,7 @@ #include "procgen/PointGraphScript.h" +#include "common/SquirrelBinding.h" +#include "common/SquirrelOwnership.h" #include "procgen/PointGraph.h" #include @@ -42,6 +44,19 @@ void exposePointGraph(ssq::Table& table) { graph.addFunc("clearParameterOverride", &PointGraph::clearParameterOverride); graph.addFunc("setNodeSubgraph", &PointGraph::setNodeSubgraph); graph.addFunc("execute", &PointGraph::execute); + graph.addFunc("executeResult", [vm = graph.getHandle()](PointGraph* self, const std::string& output) { + auto result = self->executeResult(output); + if (!result.ok()) return eve::script::projectStatusResult(vm, result.status(), false, false); + auto instance = eve::script::makeOwnedSquirrelInstance( + vm, std::make_unique(std::move(result).takeValue())); + if (!instance.ok()) return eve::script::projectStatusResult(vm, instance.status(), false, false); + auto projected = eve::script::projectStatusResult(vm, Status::success(), true, true); + projected.set("value", std::move(instance).takeValue()); + return projected; + }); + graph.addFunc("validateResult", [vm = graph.getHandle()](PointGraph* self) { + return eve::script::projectResult(vm, self->validateResult()); + }); graph.addFunc("setExecutionNodeBudget", &PointGraph::setExecutionNodeBudget); graph.addFunc("getExecutionNodeBudget", &PointGraph::getExecutionNodeBudget); graph.addFunc("setMaxNodeOutputPoints", &PointGraph::setMaxNodeOutputPoints); diff --git a/src/modules/procgen/Procgen.cpp b/src/modules/procgen/Procgen.cpp index 34c02b1c..42087563 100644 --- a/src/modules/procgen/Procgen.cpp +++ b/src/modules/procgen/Procgen.cpp @@ -7,6 +7,7 @@ #include "procgen/BiomeScript.h" #include "procgen/PointGraphScript.h" #include "procgen/ProcgenCapabilities.h" +#include "procgen/RuntimeGenerationScript.h" #include "procgen/ShapeGrammarScript.h" #include "image/ImageData.h" @@ -3007,137 +3008,7 @@ void Procgen::expose(ssq::Table &table) { pointDelta.addFunc("getTargetFingerprint", [](PointDelta* value) { return std::to_string(value->targetFingerprint); }); - auto cellRequest = table.addClass( - "ProcgenCellRequest", std::function([]() -> ProcgenCellRequest* { return nullptr; }), - true); - cellRequest.addFunc("getLevel", &ProcgenCellRequest::getLevel); - cellRequest.addFunc("getX", &ProcgenCellRequest::getX); - cellRequest.addFunc("getZ", &ProcgenCellRequest::getZ); - cellRequest.addFunc("getSeed", &ProcgenCellRequest::getSeed); - cellRequest.addFunc("getTicket", &ProcgenCellRequest::getTicket); - cellRequest.addFunc("getMinX", &ProcgenCellRequest::getMinX); - cellRequest.addFunc("getMinZ", &ProcgenCellRequest::getMinZ); - cellRequest.addFunc("getMaxX", &ProcgenCellRequest::getMaxX); - cellRequest.addFunc("getMaxZ", &ProcgenCellRequest::getMaxZ); - - auto runtimeGeneration = table.addClass( - "ProcgenRuntimeGeneration", std::function([]() -> RuntimeGeneration* { return nullptr; }), - true); - runtimeGeneration.addFunc("clear", &RuntimeGeneration::clear); - runtimeGeneration.addFunc("addLevel", &RuntimeGeneration::addLevel); - runtimeGeneration.addFunc("getLevelCount", &RuntimeGeneration::getLevelCount); - runtimeGeneration.addFunc("getLevelCellSize", &RuntimeGeneration::getLevelCellSize); - runtimeGeneration.addFunc("getLevelGenerationRadius", &RuntimeGeneration::getLevelGenerationRadius); - runtimeGeneration.addFunc("getLevelCleanupRadius", &RuntimeGeneration::getLevelCleanupRadius); - runtimeGeneration.addFunc("setDirectionWeight", &RuntimeGeneration::setDirectionWeight); - runtimeGeneration.addFunc("getDirectionWeight", &RuntimeGeneration::getDirectionWeight); - runtimeGeneration.addFunc("setMaxGenerating", &RuntimeGeneration::setMaxGenerating); - runtimeGeneration.addFunc("getMaxGenerating", &RuntimeGeneration::getMaxGenerating); - runtimeGeneration.addFunc("setMaxActiveCells", &RuntimeGeneration::setMaxActiveCells); - runtimeGeneration.addFunc("getMaxActiveCells", &RuntimeGeneration::getMaxActiveCells); - runtimeGeneration.addFunc("setMaxPointsPerCell", &RuntimeGeneration::setMaxPointsPerCell); - runtimeGeneration.addFunc("getMaxPointsPerCell", &RuntimeGeneration::getMaxPointsPerCell); - runtimeGeneration.addFunc("setMaxResidentPoints", &RuntimeGeneration::setMaxResidentPoints); - runtimeGeneration.addFunc("getMaxResidentPoints", &RuntimeGeneration::getMaxResidentPoints); - runtimeGeneration.addFunc("getResidentPointCount", &RuntimeGeneration::getResidentPointCount); - runtimeGeneration.addFunc("getRejectedOutputCount", &RuntimeGeneration::getRejectedOutputCount); - runtimeGeneration.addFunc("trimToResidentPoints", &RuntimeGeneration::trimToResidentPoints); - runtimeGeneration.addFunc("setMaxGenerationRetries", &RuntimeGeneration::setMaxGenerationRetries); - runtimeGeneration.addFunc("getMaxGenerationRetries", &RuntimeGeneration::getMaxGenerationRetries); - runtimeGeneration.addFunc("setFrameTimeBudget", &RuntimeGeneration::setFrameTimeBudget); - runtimeGeneration.addFunc("getFrameTimeBudget", &RuntimeGeneration::getFrameTimeBudget); - runtimeGeneration.addFunc("beginFrame", &RuntimeGeneration::beginFrame); - runtimeGeneration.addFunc("setRefreshWorkBudget", &RuntimeGeneration::setRefreshWorkBudget); - runtimeGeneration.addFunc("getRefreshWorkBudget", &RuntimeGeneration::getRefreshWorkBudget); - runtimeGeneration.addFunc("isRefreshPending", &RuntimeGeneration::isRefreshPending); - runtimeGeneration.addFunc("getCommittedRefreshRevision", &RuntimeGeneration::getCommittedRefreshRevision); - runtimeGeneration.addFunc( - "continueGenerationRefresh", [vm = runtimeGeneration.getHandle()](RuntimeGeneration* value) { - return eve::script::projectResult( - vm, - value ? value->continueGenerationRefresh() - : procgenBindingFailure(eve::DiagnosticCode::InvalidArgument, - "continueGenerationRefresh requires RuntimeGeneration", - "runtimeGeneration"), - [](std::uint64_t processed) { return eve::Value(std::to_string(processed)); }); - }); - runtimeGeneration.addFunc("updateSource", &RuntimeGeneration::updateSource); - runtimeGeneration.addFunc("setGenerationSource", &RuntimeGeneration::setGenerationSource); - runtimeGeneration.addFunc("removeGenerationSource", &RuntimeGeneration::removeGenerationSource); - runtimeGeneration.addFunc("clearGenerationSources", &RuntimeGeneration::clearGenerationSources); - runtimeGeneration.addFunc("getGenerationSourceCount", &RuntimeGeneration::getGenerationSourceCount); - runtimeGeneration.addFunc("getGenerationSourceId", &RuntimeGeneration::getGenerationSourceId); - runtimeGeneration.addFunc("refreshGenerationSources", &RuntimeGeneration::refreshGenerationSources); - runtimeGeneration.addFunc("setFrustumCulling", &RuntimeGeneration::setFrustumCulling); - runtimeGeneration.addFunc("isFrustumCullingEnabled", &RuntimeGeneration::isFrustumCullingEnabled); - runtimeGeneration.addFunc("getFrustumHalfAngle", &RuntimeGeneration::getFrustumHalfAngle); - runtimeGeneration.addFunc("getFrustumBehindRadius", &RuntimeGeneration::getFrustumBehindRadius); - runtimeGeneration.addFunc("getPendingGenerateCount", &RuntimeGeneration::getPendingGenerateCount); - runtimeGeneration.addFunc("getGeneratingCount", &RuntimeGeneration::getGeneratingCount); - runtimeGeneration.addFunc("getActiveCellCount", &RuntimeGeneration::getActiveCellCount); - runtimeGeneration.addFunc("getPendingCleanupCount", &RuntimeGeneration::getPendingCleanupCount); - runtimeGeneration.addFunc("getCancelledGenerationCount", &RuntimeGeneration::getCancelledGenerationCount); - runtimeGeneration.addFunc("getFailedCellCount", &RuntimeGeneration::getFailedCellCount); - runtimeGeneration.addFunc("retryFailedCells", &RuntimeGeneration::retryFailedCells); - runtimeGeneration.addFunc("nextGenerate", &RuntimeGeneration::nextGenerate); - runtimeGeneration.addFunc("nextCleanup", &RuntimeGeneration::nextCleanup); - runtimeGeneration.addFunc("isRequestCurrent", &RuntimeGeneration::isRequestCurrent); - runtimeGeneration.addFunc("completeGeneration", &RuntimeGeneration::completeGeneration); - runtimeGeneration.addFunc("failGeneration", &RuntimeGeneration::failGeneration); - runtimeGeneration.addFunc("completeCleanup", &RuntimeGeneration::completeCleanup); - runtimeGeneration.addFunc("completeCleanupsAtomic", [vm = runtimeGeneration.getHandle()](RuntimeGeneration* value, - ssq::Array requestArray) { - std::vector requests; - requests.reserve(requestArray.size()); - for (size_t index = 0; index < requestArray.size(); ++index) - requests.push_back(requestArray.get(index)); - return eve::script::projectResult( - vm, - value - ? value->completeCleanupsAtomic(requests) - : procgenBindingFailure(eve::DiagnosticCode::InvalidArgument, - "completeCleanupsAtomic requires RuntimeGeneration", "runtime"), - [](std::uint64_t completed) { return eve::Value(std::to_string(completed)); }); - }); - runtimeGeneration.addFunc("hasCell", &RuntimeGeneration::hasCell); - runtimeGeneration.addFunc("getCellOutput", &RuntimeGeneration::getCellOutput); - runtimeGeneration.addFunc("getCellRevision", &RuntimeGeneration::getCellRevision); - runtimeGeneration.addFunc( - "applyCellUpdate", [vm = runtimeGeneration.getHandle()](RuntimeGeneration* value, int level, int x, int z, - const std::string& revisionText, PointSet* output) { - std::uint64_t revision = 0; - const auto [end, error] = - std::from_chars(revisionText.data(), revisionText.data() + revisionText.size(), revision); - if (!output || error != std::errc{} || end != revisionText.data() + revisionText.size() || revision == 0) - return eve::script::projectResult( - vm, procgenBindingFailure( - eve::DiagnosticCode::InvalidArgument, - "applyCellUpdate requires an output and a non-zero decimal revision", "revision"), - [](std::uint64_t committed) { return eve::Value(std::to_string(committed)); }); - return eve::script::projectResult( - vm, value->applyCellUpdate(level, x, z, revision, *output), - [](std::uint64_t committed) { return eve::Value(std::to_string(committed)); }); - }); - runtimeGeneration.addFunc( - "migrateCellPointIds", [vm = runtimeGeneration.getHandle()](RuntimeGeneration* value, int level, int x, int z, - const std::string& revisionText) { - std::uint64_t revision = 0; - const auto [end, error] = - std::from_chars(revisionText.data(), revisionText.data() + revisionText.size(), revision); - if (error != std::errc{} || end != revisionText.data() + revisionText.size() || revision == 0) - return eve::script::projectResult( - vm, procgenBindingFailure(eve::DiagnosticCode::InvalidArgument, - "migrateCellPointIds requires a non-zero decimal revision", - "revision"), - [](std::uint64_t committed) { return eve::Value(std::to_string(committed)); }); - return eve::script::projectResult( - vm, value->migrateCellPointIds(level, x, z, revision), - [](std::uint64_t committed) { return eve::Value(std::to_string(committed)); }); - }); - runtimeGeneration.addFunc("getCellDelta", &RuntimeGeneration::getCellDelta); - runtimeGeneration.addFunc("serializeCell", &RuntimeGeneration::serializeCell); - runtimeGeneration.addFunc("deserializeCell", &RuntimeGeneration::deserializeCell); - runtimeGeneration.addFunc("debugReport", &RuntimeGeneration::debugReport); + exposeRuntimeGeneration(table); auto context = table.addClass( "ProcgenContext", std::function([]() -> ProcgenContext* { return nullptr; }), true); diff --git a/src/modules/procgen/RuntimeGeneration.cpp b/src/modules/procgen/RuntimeGeneration.cpp index 3e9d7ce0..530f539a 100644 --- a/src/modules/procgen/RuntimeGeneration.cpp +++ b/src/modules/procgen/RuntimeGeneration.cpp @@ -518,16 +518,29 @@ Result RuntimeGeneration::failGenerationJob(const ProcgenGenerationJob& jo } ProcgenCellRequest* RuntimeGeneration::nextCleanup() { - if (!isOwnerThread()) return nullptr; + auto result = nextCleanupRequest(); + if (!result.ok()) return nullptr; + auto request = std::move(result).takeValue(); + return request ? new ProcgenCellRequest(*request) : nullptr; +} + +Result> RuntimeGeneration::nextCleanupRequest() { + if (!isOwnerThread()) + return Result>::failure(Diagnostic::error( + DiagnosticCode::Conflict, "runtime-generation scheduler called from a non-owner thread", "thread")); while (!cleanupQueue_.empty()) { const CellKey key = cleanupQueue_.front(); cleanupQueue_.pop_front(); const auto found = cells_.find(key); if (found == cells_.end() || found->second.state != State::Cleanup) continue; found->second.ticket = ++nextTicket_; - return makeRequest(key); + return Result>::success(makeRequest(key)); } - return nullptr; + return Result>::success(std::nullopt); +} + +Result RuntimeGeneration::completeCleanupRequest(const ProcgenCellRequest& request) { + return completeCleanupsAtomic({&request}); } bool RuntimeGeneration::isRequestCurrent(const ProcgenCellRequest* request) const { @@ -710,16 +723,16 @@ size_t RuntimeGeneration::CellKeyHash::operator()(const CellKey& key) const { return hash; } -ProcgenCellRequest* RuntimeGeneration::makeRequest(const CellKey& key) const { - auto* request = new ProcgenCellRequest(); - request->level_ = key.level; - request->x_ = key.x; - request->z_ = key.z; - request->seed_ = cellSeed(key); +ProcgenCellRequest RuntimeGeneration::makeRequest(const CellKey& key) const { + ProcgenCellRequest request; + request.level_ = key.level; + request.x_ = key.x; + request.z_ = key.z; + request.seed_ = cellSeed(key); const auto found = cells_.find(key); - request->ticket_ = found == cells_.end() ? 0 : found->second.ticket; - request->schedulerId_ = schedulerId_; - request->cellSize_ = levels_[size_t(key.level)].cellSize; + request.ticket_ = found == cells_.end() ? 0 : found->second.ticket; + request.schedulerId_ = schedulerId_; + request.cellSize_ = levels_[size_t(key.level)].cellSize; return request; } diff --git a/src/modules/procgen/RuntimeGeneration.h b/src/modules/procgen/RuntimeGeneration.h index 0d9cd38f..48642525 100644 --- a/src/modules/procgen/RuntimeGeneration.h +++ b/src/modules/procgen/RuntimeGeneration.h @@ -249,11 +249,26 @@ public: * @reentrant Not reentrant for this RuntimeGeneration. */ [[nodiscard]] Result failGenerationJob(const ProcgenGenerationJob& job); + /** + * @brief Claim an owning cleanup ticket, or an empty optional when no cleanup is pending. + * @return Independent request value or a structured wrong-thread diagnostic. + * @thread Scheduler-owning thread only; not reentrant. No callbacks are invoked. + * @ownership Copies survive scheduler destruction, but cannot commit to another scheduler. + */ + [[nodiscard]] Result> nextCleanupRequest(); + /** + * @brief Commit one cleanup ticket after consumers have removed its generated content. + * @param request Owning ticket borrowed only for this call; never retained. + * @return Erased cell count or structured stale/foreign/thread failure without mutation. + * @thread Scheduler-owning thread only; not reentrant. No callbacks are invoked. + */ + [[nodiscard]] Result completeCleanupRequest(const ProcgenCellRequest& request); /** @brief Return whether the caller is the thread that constructed this scheduler. */ bool isOwnerThread() const noexcept; /** @brief Compatibility facade returning a heap request; prefer nextGenerationJob(). */ ProcgenCellRequest* nextGenerate(); + /** @brief Compatibility-only heap projection of nextCleanupRequest; caller owns the returned request. */ ProcgenCellRequest* nextCleanup(); /** * @brief Test whether an issued generation or cleanup request still owns its scheduler ticket. @@ -262,11 +277,11 @@ public: * @thread Call on the scheduler-owning thread between cooperative generation stages. */ bool isRequestCurrent(const ProcgenCellRequest* request) const; - /** @brief Publish generated points for an issued request. */ + /** @brief Compatibility-only bool projection of completeGenerationJob; request and output are borrowed. */ bool completeGeneration(ProcgenCellRequest* request, PointSet* output); - /** @brief Return an issued request to the pending queue after failure or cancellation. */ + /** @brief Compatibility-only bool projection of failGenerationJob; request is borrowed. */ bool failGeneration(ProcgenCellRequest* request); - /** @brief Acknowledge cleanup after consumers have removed spawned content. */ + /** @brief Compatibility-only bool projection of completeCleanupsAtomic; request is borrowed. */ bool completeCleanup(ProcgenCellRequest* request); /** * @brief Atomically acknowledge several cleanup tickets owned by this scheduler. @@ -369,7 +384,7 @@ private: bool rangeReady = false; }; - ProcgenCellRequest* makeRequest(const CellKey& key) const; + ProcgenCellRequest makeRequest(const CellKey& key) const; ProcgenGenerationJob makeGenerationJob(const CellKey& key) const; std::optional issueGenerationJob(); [[nodiscard]] Result> validateGenerationJob(const ProcgenGenerationJob& job); diff --git a/src/modules/procgen/RuntimeGenerationScript.cpp b/src/modules/procgen/RuntimeGenerationScript.cpp new file mode 100644 index 00000000..051903a1 --- /dev/null +++ b/src/modules/procgen/RuntimeGenerationScript.cpp @@ -0,0 +1,215 @@ +#include "procgen/RuntimeGenerationScript.h" +#include +#include "common/SquirrelBinding.h" +#include "common/SquirrelOwnership.h" +#include "procgen/RuntimeGeneration.h" + +namespace eve::procgen { +namespace { +template +Result procgenBindingFailure(DiagnosticCode code, std::string message, std::string path) { + return Result::failure( + Diagnostic::error(code, std::move(message), std::move(path), {}, "procgen.runtimeGeneration")); +} +} // namespace +void exposeRuntimeGeneration(ssq::Table& table) { + auto job = table.addClass( + "ProcgenGenerationJob", std::function([] { return nullptr; }), true); + job.addFunc("getLevel", &ProcgenGenerationJob::getLevel); + job.addFunc("getX", &ProcgenGenerationJob::getX); + job.addFunc("getZ", &ProcgenGenerationJob::getZ); + job.addFunc("getSeed", &ProcgenGenerationJob::getSeed); + job.addFunc("getTicket", &ProcgenGenerationJob::getTicket); + job.addFunc("getMinX", &ProcgenGenerationJob::getMinX); + job.addFunc("getMinZ", &ProcgenGenerationJob::getMinZ); + job.addFunc("getMaxX", &ProcgenGenerationJob::getMaxX); + job.addFunc("getMaxZ", &ProcgenGenerationJob::getMaxZ); + auto cellRequest = table.addClass( + "ProcgenCellRequest", std::function([]() -> ProcgenCellRequest* { return nullptr; }), + true); + cellRequest.addFunc("getLevel", &ProcgenCellRequest::getLevel); + cellRequest.addFunc("getX", &ProcgenCellRequest::getX); + cellRequest.addFunc("getZ", &ProcgenCellRequest::getZ); + cellRequest.addFunc("getSeed", &ProcgenCellRequest::getSeed); + cellRequest.addFunc("getTicket", &ProcgenCellRequest::getTicket); + cellRequest.addFunc("getMinX", &ProcgenCellRequest::getMinX); + cellRequest.addFunc("getMinZ", &ProcgenCellRequest::getMinZ); + cellRequest.addFunc("getMaxX", &ProcgenCellRequest::getMaxX); + cellRequest.addFunc("getMaxZ", &ProcgenCellRequest::getMaxZ); + + auto runtimeGeneration = table.addClass( + "ProcgenRuntimeGeneration", std::function([]() -> RuntimeGeneration* { return nullptr; }), + true); + runtimeGeneration.addFunc("clear", &RuntimeGeneration::clear); + runtimeGeneration.addFunc("addLevel", &RuntimeGeneration::addLevel); + runtimeGeneration.addFunc("getLevelCount", &RuntimeGeneration::getLevelCount); + runtimeGeneration.addFunc("getLevelCellSize", &RuntimeGeneration::getLevelCellSize); + runtimeGeneration.addFunc("getLevelGenerationRadius", &RuntimeGeneration::getLevelGenerationRadius); + runtimeGeneration.addFunc("getLevelCleanupRadius", &RuntimeGeneration::getLevelCleanupRadius); + runtimeGeneration.addFunc("setDirectionWeight", &RuntimeGeneration::setDirectionWeight); + runtimeGeneration.addFunc("getDirectionWeight", &RuntimeGeneration::getDirectionWeight); + runtimeGeneration.addFunc("setMaxGenerating", &RuntimeGeneration::setMaxGenerating); + runtimeGeneration.addFunc("getMaxGenerating", &RuntimeGeneration::getMaxGenerating); + runtimeGeneration.addFunc("setMaxActiveCells", &RuntimeGeneration::setMaxActiveCells); + runtimeGeneration.addFunc("getMaxActiveCells", &RuntimeGeneration::getMaxActiveCells); + runtimeGeneration.addFunc("setMaxPointsPerCell", &RuntimeGeneration::setMaxPointsPerCell); + runtimeGeneration.addFunc("getMaxPointsPerCell", &RuntimeGeneration::getMaxPointsPerCell); + runtimeGeneration.addFunc("setMaxResidentPoints", &RuntimeGeneration::setMaxResidentPoints); + runtimeGeneration.addFunc("getMaxResidentPoints", &RuntimeGeneration::getMaxResidentPoints); + runtimeGeneration.addFunc("getResidentPointCount", &RuntimeGeneration::getResidentPointCount); + runtimeGeneration.addFunc("getRejectedOutputCount", &RuntimeGeneration::getRejectedOutputCount); + runtimeGeneration.addFunc("trimToResidentPoints", &RuntimeGeneration::trimToResidentPoints); + runtimeGeneration.addFunc("setMaxGenerationRetries", &RuntimeGeneration::setMaxGenerationRetries); + runtimeGeneration.addFunc("getMaxGenerationRetries", &RuntimeGeneration::getMaxGenerationRetries); + runtimeGeneration.addFunc("setFrameTimeBudget", &RuntimeGeneration::setFrameTimeBudget); + runtimeGeneration.addFunc("getFrameTimeBudget", &RuntimeGeneration::getFrameTimeBudget); + runtimeGeneration.addFunc("beginFrame", &RuntimeGeneration::beginFrame); + runtimeGeneration.addFunc("setRefreshWorkBudget", &RuntimeGeneration::setRefreshWorkBudget); + runtimeGeneration.addFunc("getRefreshWorkBudget", &RuntimeGeneration::getRefreshWorkBudget); + runtimeGeneration.addFunc("isRefreshPending", &RuntimeGeneration::isRefreshPending); + runtimeGeneration.addFunc("getCommittedRefreshRevision", &RuntimeGeneration::getCommittedRefreshRevision); + runtimeGeneration.addFunc( + "continueGenerationRefresh", [vm = runtimeGeneration.getHandle()](RuntimeGeneration* value) { + return eve::script::projectResult( + vm, + value ? value->continueGenerationRefresh() + : procgenBindingFailure(eve::DiagnosticCode::InvalidArgument, + "continueGenerationRefresh requires RuntimeGeneration", + "runtimeGeneration"), + [](std::uint64_t processed) { return eve::Value(std::to_string(processed)); }); + }); + runtimeGeneration.addFunc("updateSource", &RuntimeGeneration::updateSource); + runtimeGeneration.addFunc("setGenerationSource", &RuntimeGeneration::setGenerationSource); + runtimeGeneration.addFunc("removeGenerationSource", &RuntimeGeneration::removeGenerationSource); + runtimeGeneration.addFunc("clearGenerationSources", &RuntimeGeneration::clearGenerationSources); + runtimeGeneration.addFunc("getGenerationSourceCount", &RuntimeGeneration::getGenerationSourceCount); + runtimeGeneration.addFunc("getGenerationSourceId", &RuntimeGeneration::getGenerationSourceId); + runtimeGeneration.addFunc("refreshGenerationSources", &RuntimeGeneration::refreshGenerationSources); + runtimeGeneration.addFunc("setFrustumCulling", &RuntimeGeneration::setFrustumCulling); + runtimeGeneration.addFunc("isFrustumCullingEnabled", &RuntimeGeneration::isFrustumCullingEnabled); + runtimeGeneration.addFunc("getFrustumHalfAngle", &RuntimeGeneration::getFrustumHalfAngle); + runtimeGeneration.addFunc("getFrustumBehindRadius", &RuntimeGeneration::getFrustumBehindRadius); + runtimeGeneration.addFunc("getPendingGenerateCount", &RuntimeGeneration::getPendingGenerateCount); + runtimeGeneration.addFunc("getGeneratingCount", &RuntimeGeneration::getGeneratingCount); + runtimeGeneration.addFunc("getActiveCellCount", &RuntimeGeneration::getActiveCellCount); + runtimeGeneration.addFunc("getPendingCleanupCount", &RuntimeGeneration::getPendingCleanupCount); + runtimeGeneration.addFunc("getCancelledGenerationCount", &RuntimeGeneration::getCancelledGenerationCount); + runtimeGeneration.addFunc("getFailedCellCount", &RuntimeGeneration::getFailedCellCount); + runtimeGeneration.addFunc("retryFailedCells", &RuntimeGeneration::retryFailedCells); + runtimeGeneration.addFunc("nextGenerate", &RuntimeGeneration::nextGenerate); + runtimeGeneration.addFunc("nextGenerationJob", [vm = runtimeGeneration.getHandle()](RuntimeGeneration* self) { + auto object = + eve::script::makeOwnedSquirrelInstance(vm, std::make_unique()); + if (!object.ok()) return eve::script::projectStatusResult(vm, object.status(), false, false); + auto result = self->nextGenerationJob(); + if (!result.ok()) return eve::script::projectStatusResult(vm, result.status(), false, false); + auto value = std::move(result).takeValue(); + auto projected = eve::script::projectStatusResult(vm, Status::success(), true, true); + if (!value) return projected; + auto owned = std::move(object).takeValue(); + *owned.to() = *value; + projected.set("value", owned); + return projected; + }); + runtimeGeneration.addFunc( + "completeGenerationJob", + [vm = runtimeGeneration.getHandle()](RuntimeGeneration* self, ProcgenGenerationJob* job, PointSet* output) { + return eve::script::projectResult( + vm, + job && output ? self->completeGenerationJob({*job, *output}) + : procgenBindingFailure(DiagnosticCode::InvalidArgument, + "job and output are required", "completion"), + [](uint64_t revision) { return Value(std::to_string(revision)); }); + }); + runtimeGeneration.addFunc( + "failGenerationJob", [vm = runtimeGeneration.getHandle()](RuntimeGeneration* self, ProcgenGenerationJob* job) { + return eve::script::projectResult( + vm, job ? self->failGenerationJob(*job) + : procgenBindingFailure(DiagnosticCode::InvalidArgument, "job is required", "job")); + }); + runtimeGeneration.addFunc("nextCleanupRequest", [vm = runtimeGeneration.getHandle()](RuntimeGeneration* self) { + // Allocate the registered script wrapper before consuming a queue entry. + auto object = + eve::script::makeOwnedSquirrelInstance(vm, std::make_unique()); + if (!object.ok()) return eve::script::projectStatusResult(vm, object.status(), false, false); + auto result = self->nextCleanupRequest(); + if (!result.ok()) return eve::script::projectStatusResult(vm, result.status(), false, false); + auto value = std::move(result).takeValue(); + auto projected = eve::script::projectStatusResult(vm, Status::success(), true, true); + if (!value) return projected; + auto owned = std::move(object).takeValue(); + *owned.to() = *value; + projected.set("value", owned); + return projected; + }); + runtimeGeneration.addFunc("completeCleanupRequest", [vm = runtimeGeneration.getHandle()]( + RuntimeGeneration* self, ProcgenCellRequest* request) { + return eve::script::projectResult( + vm, + request ? self->completeCleanupRequest(*request) + : procgenBindingFailure(DiagnosticCode::InvalidArgument, "cleanup request is required", + "request"), + [](uint64_t count) { return Value(std::to_string(count)); }); + }); + runtimeGeneration.addFunc("nextCleanup", &RuntimeGeneration::nextCleanup); + runtimeGeneration.addFunc("isRequestCurrent", &RuntimeGeneration::isRequestCurrent); + runtimeGeneration.addFunc("completeGeneration", &RuntimeGeneration::completeGeneration); + runtimeGeneration.addFunc("failGeneration", &RuntimeGeneration::failGeneration); + runtimeGeneration.addFunc("completeCleanup", &RuntimeGeneration::completeCleanup); + runtimeGeneration.addFunc("completeCleanupsAtomic", [vm = runtimeGeneration.getHandle()](RuntimeGeneration* value, + ssq::Array requestArray) { + std::vector requests; + requests.reserve(requestArray.size()); + for (size_t index = 0; index < requestArray.size(); ++index) + requests.push_back(requestArray.get(index)); + return eve::script::projectResult( + vm, + value + ? value->completeCleanupsAtomic(requests) + : procgenBindingFailure(eve::DiagnosticCode::InvalidArgument, + "completeCleanupsAtomic requires RuntimeGeneration", "runtime"), + [](std::uint64_t completed) { return eve::Value(std::to_string(completed)); }); + }); + runtimeGeneration.addFunc("hasCell", &RuntimeGeneration::hasCell); + runtimeGeneration.addFunc("getCellOutput", &RuntimeGeneration::getCellOutput); + runtimeGeneration.addFunc("getCellRevision", &RuntimeGeneration::getCellRevision); + runtimeGeneration.addFunc( + "applyCellUpdate", [vm = runtimeGeneration.getHandle()](RuntimeGeneration* value, int level, int x, int z, + const std::string& revisionText, PointSet* output) { + std::uint64_t revision = 0; + const auto [end, error] = + std::from_chars(revisionText.data(), revisionText.data() + revisionText.size(), revision); + if (!output || error != std::errc{} || end != revisionText.data() + revisionText.size() || revision == 0) + return eve::script::projectResult( + vm, + procgenBindingFailure( + eve::DiagnosticCode::InvalidArgument, + "applyCellUpdate requires an output and a non-zero decimal revision", "revision"), + [](std::uint64_t committed) { return eve::Value(std::to_string(committed)); }); + return eve::script::projectResult( + vm, value->applyCellUpdate(level, x, z, revision, *output), + [](std::uint64_t committed) { return eve::Value(std::to_string(committed)); }); + }); + runtimeGeneration.addFunc( + "migrateCellPointIds", [vm = runtimeGeneration.getHandle()](RuntimeGeneration* value, int level, int x, int z, + const std::string& revisionText) { + std::uint64_t revision = 0; + const auto [end, error] = + std::from_chars(revisionText.data(), revisionText.data() + revisionText.size(), revision); + if (error != std::errc{} || end != revisionText.data() + revisionText.size() || revision == 0) + return eve::script::projectResult( + vm, + procgenBindingFailure(eve::DiagnosticCode::InvalidArgument, + "migrateCellPointIds requires a non-zero decimal revision", + "revision"), + [](std::uint64_t committed) { return eve::Value(std::to_string(committed)); }); + return eve::script::projectResult( + vm, value->migrateCellPointIds(level, x, z, revision), + [](std::uint64_t committed) { return eve::Value(std::to_string(committed)); }); + }); + runtimeGeneration.addFunc("getCellDelta", &RuntimeGeneration::getCellDelta); + runtimeGeneration.addFunc("serializeCell", &RuntimeGeneration::serializeCell); + runtimeGeneration.addFunc("deserializeCell", &RuntimeGeneration::deserializeCell); + runtimeGeneration.addFunc("debugReport", &RuntimeGeneration::debugReport); +} +} // namespace eve::procgen diff --git a/src/modules/procgen/RuntimeGenerationScript.h b/src/modules/procgen/RuntimeGenerationScript.h new file mode 100644 index 00000000..7b18db21 --- /dev/null +++ b/src/modules/procgen/RuntimeGenerationScript.h @@ -0,0 +1,8 @@ +#pragma once +namespace ssq { +class Table; +} +namespace eve::procgen { +/** @brief Register owner-thread runtime-generation script interfaces. */ +void exposeRuntimeGeneration(ssq::Table& table); +} // namespace eve::procgen diff --git a/src/modules/procgen/editing/PcgGraph.cpp b/src/modules/procgen/editing/PcgGraph.cpp index b9323502..cc66835c 100644 --- a/src/modules/procgen/editing/PcgGraph.cpp +++ b/src/modules/procgen/editing/PcgGraph.cpp @@ -385,17 +385,16 @@ PcgGraphPreviewResult PcgPointGraphDomain::preview(const GraphDocumentData& grap : runtime.getError())); return result; } - std::unique_ptr output(runtime.execute(outputNode)); - if (!output) { - result.status = runtime.wasCancelled() ? EditorStatus::Cancelled : EditorStatus::Failed; + auto output = runtime.executeResult(outputNode); + if (!output.ok()) { + const bool cancelled = output.status().code() == eve::StatusCode::Cancelled; + result.status = cancelled ? EditorStatus::Cancelled : EditorStatus::Failed; result.diagnostics.push_back(pcgDiagnostic( - runtime.wasCancelled() ? "editor.pcg.preview-cancelled" - : "editor.pcg.preview-execution-failed", - runtime.wasCancelled() ? DiagnosticSeverity::Info : DiagnosticSeverity::Error, - runtime.getError())); + cancelled ? "editor.pcg.preview-cancelled" : "editor.pcg.preview-execution-failed", + cancelled ? DiagnosticSeverity::Info : DiagnosticSeverity::Error, output.status().describe())); return result; } - result.outputCount = output->getCount(); + result.outputCount = output.value().getCount(); result.metrics.reserve(size_t(runtime.getMetricCount())); for (int index = 0; index < runtime.getMetricCount(); ++index) { PcgGraphPreviewMetric metric; diff --git a/test/procgen_result_contract.cpp b/test/procgen_result_contract.cpp new file mode 100644 index 00000000..eadfdf61 --- /dev/null +++ b/test/procgen_result_contract.cpp @@ -0,0 +1,197 @@ +#include +#include +#include "procgen/PointGraph.h" +#include "procgen/Procgen.h" +#include "procgen/RuntimeGeneration.h" +#include "zeroerr/unittest.h" + +using namespace eve; +using namespace eve::procgen; + +namespace { +void configure(PointGraph& graph) { + REQUIRE(graph.addNode("source", "input")); + REQUIRE(graph.addNode("out", "transform")); + REQUIRE(graph.connect("source", "out")); + REQUIRE(graph.setComputePolicy("cpu")); + PointSet points; + points.add(1.f, 0.f, 2.f); + REQUIRE(graph.setNodePoints("source", &points)); +} +template +void requireFailure(const Result& result, DiagnosticCode code, const std::string& path) { + REQUIRE(!result.ok()); + const auto* diagnostic = result.status().primaryDiagnostic(); + REQUIRE(diagnostic != nullptr); + REQUIRE(diagnostic->code() == code); + REQUIRE_EQ(diagnostic->path(), path); +} +} // namespace + +TEST_CASE("procgen.result.executionOwnsDiagnosticsAndOutput") { + PointGraph graph; + configure(graph); + auto missing = graph.executeResult("missing"); + requireFailure(missing, DiagnosticCode::NotFound, "missing"); + auto output = graph.executeResult("out"); + REQUIRE(output.ok()); + auto points = std::move(output).takeValue(); + REQUIRE_EQ(points.getCount(), 1); + REQUIRE(graph.removeNode("source")); + auto invalid = graph.executeResult("out"); + requireFailure(invalid, DiagnosticCode::InvalidArgument, "out"); + requireFailure(missing, DiagnosticCode::NotFound, "missing"); + REQUIRE_EQ(points.getX(0), 1.f); + std::unique_ptr legacy(graph.execute("out")); + REQUIRE(!legacy); + REQUIRE(!graph.getError().empty()); +} + +TEST_CASE("procgen.result.validationCycleCancellationAndBudgets") { + PointGraph graph; + configure(graph); + auto valid = graph.validateResult(); + REQUIRE(valid.ok()); + graph.requestCancel(); + auto cancelled = graph.executeResult("out"); + requireFailure(cancelled, DiagnosticCode::Cancelled, "out"); + graph.resetCancellation(); + graph.setExecutionNodeBudget(1); + auto budget = graph.executeResult("out"); + requireFailure(budget, DiagnosticCode::Cancelled, "out"); + graph.setExecutionNodeBudget(0); + REQUIRE(graph.addNode("loop", "transform")); + REQUIRE(graph.connect("out", "loop")); + REQUIRE(graph.connect("loop", "out")); + auto cycle = graph.executeResult("out"); + requireFailure(cycle, DiagnosticCode::Conflict, "out"); + auto invalid = graph.validateResult(); + requireFailure(invalid, DiagnosticCode::Conflict, "out"); +} + +TEST_CASE("procgen.result.nodeFailuresAndPointBudgetHavePaths") { + PointGraph graph; + REQUIRE(graph.addNode("source", "input")); + auto unset = graph.executeResult("source"); + requireFailure(unset, DiagnosticCode::InvalidArgument, "source"); + PointSet points; + points.add(1.f, 0.f, 0.f); + points.add(2.f, 0.f, 0.f); + REQUIRE(graph.setNodePoints("source", &points)); + graph.setMaxNodeOutputPoints(1); + auto budget = graph.executeResult("source"); + requireFailure(budget, DiagnosticCode::InvalidArgument, "source"); + graph.setMaxNodeOutputPoints(0); + auto recovered = graph.executeResult("source"); + REQUIRE(recovered.ok()); + REQUIRE_EQ(recovered.value().getCount(), 2); +} + +TEST_CASE("procgen.result.cleanupDistinguishesEmptyThreadAndStale") { + RuntimeGeneration runtime(17), other(17); + auto empty = runtime.nextCleanupRequest(); + REQUIRE(empty.ok()); + REQUIRE(!empty.value().has_value()); + const int level = runtime.addLevel(10.f, 4.f, 2.f); + REQUIRE_EQ(level, 0); + runtime.updateSource(5.f, 5.f, 1.f, 0.f); + auto issued = runtime.nextGenerationJob(); + REQUIRE(issued.ok()); + REQUIRE(issued.value().has_value()); + auto completed = runtime.completeGenerationJob({*issued.value(), PointSet{}}); + REQUIRE(completed.ok()); + runtime.updateSource(1000.f, 1000.f, 1.f, 0.f); + auto wrongThread = std::async(std::launch::async, [&] { return runtime.nextCleanupRequest(); }); + auto rejected = wrongThread.get(); + requireFailure(rejected, DiagnosticCode::Conflict, "thread"); + auto cleanup = runtime.nextCleanupRequest(); + REQUIRE(cleanup.ok()); + REQUIRE(cleanup.value().has_value()); + const auto request = *cleanup.value(); + auto foreign = other.completeCleanupRequest(request); + REQUIRE(!foreign.ok()); + auto cleaned = runtime.completeCleanupRequest(request); + REQUIRE(cleaned.ok()); + REQUIRE_EQ(cleaned.value(), uint64_t(1)); + auto replay = runtime.completeCleanupRequest(request); + REQUIRE(!replay.ok()); +} + +TEST_CASE("procgen.result.nestedFailurePreservesNodePath") { + PointGraph nested; + configure(nested); + REQUIRE(nested.addNode("math", "density.remap")); + REQUIRE(nested.connect("out", "math")); + REQUIRE(nested.setNodeFloat("math", "inputMax", 0.f)); + PointGraph graph; + configure(graph); + REQUIRE(graph.addNode("nested", "subgraph")); + REQUIRE(graph.connect("out", "nested")); + REQUIRE(graph.setNodeSubgraph("nested", &nested, "source", "math")); + auto failed = graph.executeResult("nested"); + requireFailure(failed, DiagnosticCode::InvalidArgument, "nested/math"); +} + +TEST_CASE("procgen.result.workerGraphResultCommitsOnOwner") { + RuntimeGeneration runtime(41); + const int level = runtime.addLevel(10.f, 4.f, 2.f); + REQUIRE_EQ(level, 0); + runtime.updateSource(5.f, 5.f, 1.f, 0.f); + auto issued = runtime.nextGenerationJob(); + REQUIRE(issued.ok()); + REQUIRE(issued.value().has_value()); + const auto job = *issued.value(); + auto worker = std::async(std::launch::async, [job] { + PointGraph graph; + const bool configured = graph.addNode("source", "input") && graph.setComputePolicy("cpu"); + if (!configured) + return Result::failure(Diagnostic::error(DiagnosticCode::Failed, "worker setup failed")); + PointSet points; + points.add(job.getMinX(), 0.f, job.getMinZ()); + if (!graph.setNodePoints("source", &points)) + return Result::failure(Diagnostic::error(DiagnosticCode::Failed, "worker input failed")); + return graph.executeResult("source"); + }); + auto generated = worker.get(); + REQUIRE(generated.ok()); + auto committed = runtime.completeGenerationJob({job, std::move(generated).takeValue()}); + REQUIRE(committed.ok()); + REQUIRE_EQ(committed.value(), uint64_t(1)); + REQUIRE_EQ(runtime.getActiveCellCount(), 1); +} + +TEST_CASE("procgen.result.squirrelExecutionAndSchedulerShareProjection") { + ssq::VM vm(1024, ssq::Libs::ALL); + eve::ModuleManager::expose(vm); + vm.run(vm.compileSource(R"( + local procgen = eve.Procgen(); + local graph = procgen.newPointGraph().value; + local points = procgen.sampleGrid(1, 1, 1.0, 18, 0.0).value; + assert(graph.addNode("source", "input")); + assert(graph.setNodePoints("source", points)); + assert(graph.validateResult().ok); + local result = graph.executeResult("source"); + assert(result.ok && result.hasValue && result.value.getCount() == 1); + local missing = graph.executeResult("missing"); + assert(!missing.ok && missing.value == null && missing.diagnostics[0].path == "missing"); + local runtime = procgen.newRuntimeGeneration(19).value; + local empty = runtime.nextCleanupRequest(); + assert(empty.ok && empty.value == null); + runtime.addLevel(10.0, 4.0, 2.0); + runtime.updateSource(5.0, 5.0, 1.0, 0.0); + local job = runtime.nextGenerationJob(); + assert(job.ok && job.value != null); + assert(runtime.completeGenerationJob(job.value, result.value).ok); + assert(!runtime.completeGenerationJob(job.value, result.value).ok); + runtime.updateSource(1000.0, 1000.0, 1.0, 0.0); + local cleanup = runtime.nextCleanupRequest(); + assert(cleanup.ok && cleanup.value != null); + assert(runtime.completeCleanupRequest(cleanup.value).ok); + assert(!runtime.completeCleanupRequest(cleanup.value).ok); + runtime.updateSource(1005.0, 1005.0, 1.0, 0.0); + local retry = runtime.nextGenerationJob(); + assert(retry.ok && retry.value != null); + assert(runtime.failGenerationJob(retry.value).ok); + assert(!runtime.failGenerationJob(retry.value).ok); + )")); +} diff --git a/test/procgen_worker_contract.cpp b/test/procgen_worker_contract.cpp index c624c2f2..550a4de4 100644 --- a/test/procgen_worker_contract.cpp +++ b/test/procgen_worker_contract.cpp @@ -81,7 +81,8 @@ TEST_CASE("procgen.worker.legacyIssueRejectsWrongThread") { TEST_CASE("procgen.runtimeGeneration.movesValueJobsAcrossWorkerBoundary") { RuntimeGeneration runtime(4201); REQUIRE(runtime.isOwnerThread()); - REQUIRE_EQ(runtime.addLevel(10.f, 4.f, 2.f), 0); + const int addedLevel = runtime.addLevel(10.f, 4.f, 2.f); + REQUIRE_EQ(addedLevel, 0); runtime.updateSource(5.f, 5.f, 1.f, 0.f); auto issued = runtime.nextGenerationJob(); @@ -107,7 +108,8 @@ TEST_CASE("procgen.runtimeGeneration.movesValueJobsAcrossWorkerBoundary") { TEST_CASE("procgen.runtimeGeneration.rejectsWorkerThreadSchedulerMutation") { RuntimeGeneration runtime(4202); - REQUIRE_EQ(runtime.addLevel(10.f, 4.f, 2.f), 0); + const int addedLevel = runtime.addLevel(10.f, 4.f, 2.f); + REQUIRE_EQ(addedLevel, 0); runtime.updateSource(5.f, 5.f, 1.f, 0.f); auto issued = runtime.nextGenerationJob(); REQUIRE(issued.ok());