diff --git a/docs/usr/modules/mouse.md b/docs/usr/modules/mouse.md index c6064630..f4648a1c 100644 --- a/docs/usr/modules/mouse.md +++ b/docs/usr/modules/mouse.md @@ -33,7 +33,8 @@ mouse.setRelativeMode(true); // 光标锁定并隐藏,只报告相对移动 mouse.setRelativeMode(false); // 恢复绝对坐标 ``` -相对模式下 `getX()` / `getY()` 返回累积位置;如需逐帧增量,应在 `eve_update` 内记录上一帧值求差。 +相对模式下不要用 `setPosition` 回中再对 `getX()` 求差:Windows 上 warp 会注入反向位移,视角会先转再弹回。 +每帧在 `eve_update` 里先调 `getMovementX()` 再调 `getMovementY()`(前者从 SDL 取相对位移并锁存 Y)。 `setGrabbed(bool)` / `isGrabbed()` 控制窗口鼠标抓取(平台行为差异见 `mouse/sdl/Mouse.cpp`)。 ## 常见问题 @@ -46,7 +47,7 @@ mouse.setRelativeMode(false); // 恢复绝对坐标 下列方法名来自当前 Squirrel 绑定;同一模块创建的辅助对象(例如 `World`、`Body`、`Source`)的方法也列在这里。 -- `getName()`、`getX()`、`getY()`、`setX()`、`setY()`、`setPosition()`、`isDown()`、 +- `getName()`、`getX()`、`getY()`、`getMovementX()`、`getMovementY()`、`setX()`、`setY()`、`setPosition()`、`isDown()`、 `setVisible()`、`isVisible()`、`setGrabbed()`、`isGrabbed()`、`setRelativeMode()`、`getRelativeMode()` ## 使用要点 diff --git a/docs/usr/modules/voxel.md b/docs/usr/modules/voxel.md index e20f75c1..2263f555 100644 --- a/docs/usr/modules/voxel.md +++ b/docs/usr/modules/voxel.md @@ -67,12 +67,15 @@ local playerChunkX = floor(player.x / 32.0); local playerChunkY = floor(player.y / 32.0); local playerChunkZ = floor(player.z / 32.0); world.streamAround(playerChunkX, playerChunkY, playerChunkZ, 4); // 半径 4 chunk +// 每帧限额(可选第 5 参):只补最近的 N 个缺失 chunk,其余留到后续帧,避免一次填满半径卡顿。 +// world.streamAround(playerChunkX, playerChunkY, playerChunkZ, 4, 2); world.selectVisible(viewProj, player.x, player.y, player.z, 160.0, true); world.drawVisible(gfx, atlasTex, 16); ``` -`streamAround` 返回本次新建 chunk 数(卸载数可用 `getChunkCount` 差值计算)。 -地形是确定性的:同一世界坐标 + 同一 seed 永远得到相同高度,跨 chunk 无缝衔接。 +`streamAround` 返回本次**插入世界、可立即绘制**的 chunk 数(卸载数可用 `getChunkCount` 差值计算)。 +第 5 个参数 `maxCreates` 为 0(默认)时在调用线程一次补齐半径内全部缺失 chunk。大于 0 且使用内置噪声地形(无脚本 generator、未加载 EVTR)时,主线程只入队最近 N 个任务:工作线程填充体素并网格化,后续某帧 `streamAround` 再把已完成的 chunk 插入世界并绘制。脚本 generator 与 EVTR 资产路径仍在调用线程执行。 +`getInflightStreamCount()` 是尚未插入世界的后台任务数。`setStreamCacheChunks(extra)` 在加载球外再留一圈:离开时不立刻卸载,飞回来可直接绘制;超出 `radius + extra` 才回收。默认 extra=0(与旧行为一致)。地形是确定性的:同一世界坐标 + 同一 seed 永远得到相同高度,跨 chunk 无缝衔接。 ### 使用烘焙 EVTR 地形 @@ -134,7 +137,9 @@ if (world.raycast(eye.x, eye.y, eye.z, dir.x, dir.y, dir.z, 8.0)) { - 网格:`remeshDirty()`(自动并行)、`getChunkCount()`、`hasChunk`、`removeChunk`、`clear`。 - 存档/流式:`saveWorld()`(返回 `data.newByteData` 可直接落盘)、`loadWorld(byteData)`、 `unloadChunksOutside(cx,cy,cz,radiusChunks)`(卸载半径外的 chunk,返回卸载数)、 - `streamAround(cx,cy,cz,radiusChunks)`(自动补建+生成+卸载+重建,返回新建数)、 + `setStreamCacheChunks(extra)` / `getStreamCacheChunks()`(`streamAround` 卸载半径 = 加载半径 + extra)、 + `streamAround(cx,cy,cz,radiusChunks [,maxCreates])`(自动补建+生成+卸载+重建,返回本帧插入世界的数量;内置噪声 + maxCreates>0 时后台填充,就绪帧插入)、 + `getDirtyCount()`、`getInflightStreamCount()`、 `setTerrain(seed, top, sub, stone, base, amp, scale)`(快速配置)、 `setTerrainParam(key, value)`(细粒度配置,见下方参数表)、 `loadTerrainAsset(bytes, offset, scale)`、 diff --git a/examples/cardgame/main.nut b/examples/cardgame/main.nut index 74426e9d..652be633 100644 --- a/examples/cardgame/main.nut +++ b/examples/cardgame/main.nut @@ -107,6 +107,10 @@ function configureLayout(cfg, cardW, cardH, spacing, x, y) { function createPokerTable() { pokerCard = eve.Card(); + // Playing-card PNGs have rounded transparent corners. Built-in Card::render + // submits opaque dark rectangles first; 2D solids keep that first fragment, + // so the PNG would composite onto black instead of the felt. + pokerCard.setBuiltInVisuals(false); registerCards(); playerCfg = pokerCard.newConfig(); @@ -506,29 +510,39 @@ function handContains(hand, instanceId) { return hand != null && hand.findCard(instanceId) != null; } +function drawPlayingCardBack(cx, cy, backW, backH, alpha) { + // Solid 2D keeps the first fragment, so submit the inner pattern before the outer fill. + gfx.drawSolidRect(cx - backW * 0.25, cy - 3.0, backW * 0.5, 6.0, 0.82, 0.62, 0.30, alpha); + gfx.drawSolidRect(cx - backW * 0.5 + 9.0, cy - backH * 0.5 + 9.0, + backW - 18.0, backH - 18.0, 0.12, 0.035, 0.075, alpha); + gfx.drawSolidRect(cx - backW * 0.5 + 6.0, cy - backH * 0.5 + 6.0, + backW - 12.0, backH - 12.0, 0.82, 0.62, 0.30, alpha); + gfx.drawSolidRect(cx - backW * 0.5, cy - backH * 0.5, backW, backH, 0.34, 0.055, 0.10, alpha); +} + function renderPlayingCardBacks() { pokerCard.capturePresentation(); local aiHand = pokerCard.findHand("ai"); local aiHidden = aiHand != null && aiHand.isFaceDown() && !aiHand.isPeek(); - if (!aiHidden) return; - - for (local i = 0; i < pokerCard.getPresentationCount(); i += 1) { - local snap = pokerCard.getPresentation(i); - local instanceId = snap.getInstanceId(); - local inAi = handContains(aiHand, instanceId); - if (inAi) { - local backW = snap.getW() * snap.getScale(); - local backH = snap.getH() * snap.getScale(); - gfx.drawSolidRect(snap.getX() - backW * 0.25, snap.getY() - 3.0, - backW * 0.5, 6.0, 0.82, 0.62, 0.30, snap.getAlpha()); - gfx.drawSolidRect(snap.getX() - backW * 0.5 + 9.0, snap.getY() - backH * 0.5 + 9.0, - backW - 18.0, backH - 18.0, 0.12, 0.035, 0.075, snap.getAlpha()); - gfx.drawSolidRect(snap.getX() - backW * 0.5 + 6.0, snap.getY() - backH * 0.5 + 6.0, - backW - 12.0, backH - 12.0, 0.82, 0.62, 0.30, snap.getAlpha()); - gfx.drawSolidRect(snap.getX() - backW * 0.5, snap.getY() - backH * 0.5, - backW, backH, 0.34, 0.055, 0.10, snap.getAlpha()); + if (aiHidden) { + for (local i = 0; i < pokerCard.getPresentationCount(); i += 1) { + local snap = pokerCard.getPresentation(i); + if (handContains(aiHand, snap.getInstanceId())) { + drawPlayingCardBack(snap.getX(), snap.getY(), + snap.getW() * snap.getScale(), snap.getH() * snap.getScale(), snap.getAlpha()); + } } } + + local remaining = pokerCard.getDeck().count(); + if (remaining <= 0) return; + local backW = playerCfg.getCardW() * 0.9; + local backH = playerCfg.getCardH() * 0.9; + local layers = remaining < 3 ? remaining : 3; + for (local i = 0; i < layers; i += 1) { + local off = i.tofloat() * 3.0; + drawPlayingCardBack(playerCfg.getDeckX() + off, playerCfg.getDeckY() - off, backW, backH, 1.0); + } } function renderPlayingCardFaces() { @@ -636,8 +650,6 @@ eve_update = function(dt) { eve_render = function() { gfx.clear(); renderPlayingCardBacks(); - pokerCard.render(gfx); - pokerCard.renderDeck(gfx); renderPokerTable(); renderPlayingCardFaces(); ui.beginFrameAndRender(); diff --git a/examples/voxel-terrain/main.nut b/examples/voxel-terrain/main.nut index 4ad97802..7eb5f69f 100644 --- a/examples/voxel-terrain/main.nut +++ b/examples/voxel-terrain/main.nut @@ -54,6 +54,8 @@ const FLY_SPEED = 18.0; // world units / second const FLY_FAST = 48.0; const LOOK_SENS = 0.0032; // radians per mouse pixel const VIEW_RANGE = 180.0; +const STREAM_CREATE_PER_FRAME = 8; // enqueue up to N async fill+remesh jobs per frame +const STREAM_CACHE_CHUNKS = 3; // keep this many extra chunk radii after leaving function maxf(a, b) { return a > b ? a : b; } @@ -186,6 +188,7 @@ function setupWorld() { } if (world == null) { world = voxel.newWorldWithTypes(types); + world.setStreamCacheChunks(STREAM_CACHE_CHUNKS); applyTerrainPreset(0, seed); // 出生点放在本地最高点斜后方,朝向岛屿,保证第一眼能看到地形 local bestH = 0; @@ -257,7 +260,7 @@ function streamWorld() { local pcx = floor(playerPos[0] / 32.0).tointeger(); local pcy = floor(playerPos[1] / 32.0).tointeger(); local pcz = floor(playerPos[2] / 32.0).tointeger(); - world.streamAround(pcx, pcy, pcz, streamRadius); + world.streamAround(pcx, pcy, pcz, streamRadius, STREAM_CREATE_PER_FRAME); } // --------------------------------------------------------------------------- @@ -300,23 +303,20 @@ function updateCamera(dt) { function setMouseCaptured(captured) { mouseCaptured = captured; mouse.setVisible(!captured); - local cx = config.width * 0.5; - local cy = config.height * 0.5; - if (captured) mouse.setPosition(cx, cy); + // Relative mode reports true motion; warping to the window centre on + // Windows injects a reverse SDL event (look left, then snap back). + mouse.setRelativeMode(captured); + mouse.getMovementX(); // drain pending delta } function updateMouseLook() { if (key_just_pressed("escape", "Escape")) setMouseCaptured(!mouseCaptured); + local dx = mouse.getMovementX(); + local dy = mouse.getMovementY(); if (!mouseCaptured) return; - - local cx = config.width * 0.5; - local cy = config.height * 0.5; - local dx = mouse.getX() - cx; - local dy = mouse.getY() - cy; yaw -= dx * LOOK_SENS; pitch += dy * LOOK_SENS; // 屏幕 Y 向下 pitch = clampf(pitch, -1.5, 1.5); - mouse.setPosition(cx, cy); } function mouseLeftPressed() { @@ -397,11 +397,12 @@ function refreshHud() { ui.setText("stats", "voxel-terrain 预设[" + (presetIdx + 1) + "/" + presets.len() + "] " + p.name + " seed " + seed + "\n" + - format("位置 (%.0f, %.0f, %.0f) 流式半径 %d chunk", playerPos[0], playerPos[1], - playerPos[2], streamRadius) + "\n" + - format("chunks %d 可见 %d 批次 %d 矩形 %d FPS %.0f", - world.getChunkCount(), world.getVisibleChunkCount(), - world.getVisibleBatchCount(), world.getVisibleRectCount(), fpsAvg) + + format("位置 (%.0f, %.0f, %.0f) 流式半径 %d 缓存 +%d", playerPos[0], playerPos[1], + playerPos[2], streamRadius, world.getStreamCacheChunks()) + "\n" + + format("chunks %d 加载中 %d dirty %d 可见 %d 批次 %d 矩形 %d FPS %.0f", + world.getChunkCount(), world.getInflightStreamCount(), world.getDirtyCount(), + world.getVisibleChunkCount(), world.getVisibleBatchCount(), + world.getVisibleRectCount(), fpsAvg) + (lastEdit == "" ? "" : "\n" + lastEdit)); ui.setText("help", "WASD 飞行 Space/C 升降 Shift 加速 鼠标视角 Esc释放/捕获 左键放置 右键破坏 Z/Y撤销重做\n" + diff --git a/examples/voxel/main.nut b/examples/voxel/main.nut index e28d39ae..33487536 100644 --- a/examples/voxel/main.nut +++ b/examples/voxel/main.nut @@ -24,7 +24,8 @@ const TILE = 32; // atlas tile size in pixels const TILES_PER_ROW = 4; // --------------------------------------------------------------------------- -// 小型列主序矩阵工具(脚本侧合成 proj * view,供 selectVisible 使用) +// 列主序 view / proj:体素裁剪和 setMesh3DViewProj 要 16 个 float, +// 从唯一的 Camera3D 现算,不是第二台相机。 // --------------------------------------------------------------------------- function mat4Perspective(fovyDeg, aspect, near, far) { local f = 1.0 / tan(fovyDeg * 0.5 * PI / 180.0); @@ -114,12 +115,22 @@ function setupWorld() { cam.setTarget(0.0, 8.0, 0.0); cam.setUp(0.0, 1.0, 0.0); cam.setFov(50.0); - cam.setActive(true); + cam.setClipPlanes(0.1, 500.0); + // Voxel draws via setMesh3DViewProj; keep Camera3D inactive so + // RenderSystem3D cannot composite a second view. + cam.setActive(false); } if (atlas == null) atlas = buildAtlas(); } // 鼠标按键编号:1 = 左键,2 = 右键(与 engine mouse::isDown 一致)。 +function updateOrbitCamera() { + local dist = 46.0; + local height = 30.0 + sin(elapsed * 0.18) * 6.0; + cam.setEye(cos(elapsed * 0.22) * dist, height, sin(elapsed * 0.22) * dist); + cam.setTarget(0.0, 8.0, 0.0); +} + function mousePressed(button) { local down = mouse.isDown(button); local was = (button == 1) ? prevMouse.left : prevMouse.right; @@ -131,7 +142,7 @@ function mousePressed(button) { function handlePick() { local mx = mouse.getX(); local my = mouse.getY(); - cam.screenToRay(mx, my, gfx.getWidth().tofloat(), gfx.getHeight().tofloat()); + cam.screenToRay(mx.tofloat(), my.tofloat(), gfx.getWidth().tofloat(), gfx.getHeight().tofloat()); local ox = cam.getScreenRayOriginX(); local oy = cam.getScreenRayOriginY(); local oz = cam.getScreenRayOriginZ(); @@ -144,10 +155,12 @@ function handlePick() { local hy = world.getRaycastHitY(); local hz = world.getRaycastHitZ(); if (mousePressed(1)) { - // 放置:命中方块表面法线方向放木头 - world.setVoxelByName(hx + world.getRaycastFaceX(), - hy + world.getRaycastFaceY(), - hz + world.getRaycastFaceZ(), "wood"); + // 放置:射线进入实心块之前的空格(点击的那一面外侧) + local px = world.getRaycastPrevX(); + local py = world.getRaycastPrevY(); + local pz = world.getRaycastPrevZ(); + if (px != hx || py != hy || pz != hz) + world.setVoxelByName(px, py, pz, "wood"); world.remeshDirty(); } else if (mousePressed(2)) { // 破坏 @@ -166,19 +179,21 @@ eve_init = function() { }; eve_update = function(dt) { - elapsed += dt; + // Wall-clock orbit so a hitch does not add a large dt then appear to jump back. + if (has_module("timer")) elapsed = timer.getTime(); + else elapsed += dt; + updateOrbitCamera(); handlePick(); if (keyboard.isDown("r")) world.remeshDirty(); }; eve_render = function() { - // 轨道相机:绕目标点缓慢旋转 - local dist = 46.0; - local height = 30.0 + sin(elapsed * 0.18) * 6.0; - local eye = [cos(elapsed * 0.22) * dist, height, sin(elapsed * 0.22) * dist]; - local target = [0.0, 8.0, 0.0]; + updateOrbitCamera(); + local eye = [cam.getEyeX(), cam.getEyeY(), cam.getEyeZ()]; + local target = [cam.getTargetX(), cam.getTargetY(), cam.getTargetZ()]; local view = mat4LookAt(eye, target, [0.0, 1.0, 0.0]); - local proj = mat4Perspective(50.0, config.width.tofloat() / config.height.tofloat(), 0.1, 500.0); + local aspect = gfx.getWidth().tofloat() / gfx.getHeight().tofloat(); + local proj = mat4Perspective(cam.getFov(), aspect, 0.1, 500.0); local viewProj = proj.multiplied(view); local vp = []; diff --git a/scripts/capture_root.nut b/scripts/capture_root.nut index 2ad87f8c..2b5ce196 100644 --- a/scripts/capture_root.nut +++ b/scripts/capture_root.nut @@ -22,6 +22,14 @@ function file_exists(path) { } } +function format_script_error(e) { + if ("lastScriptError" in eve) { + local stacked = eve.lastScriptError(); + if (typeof stacked == "string" && stacked.len() > 0) return stacked; + } + return "" + e; +} + function has_module(slot) { return slot in getroottable() && getroottable()[slot] != null; } @@ -146,7 +154,7 @@ while (true) { async_pump(); eve_render(); } catch (e) { - print("frame error: " + e + "\n"); + print("frame error: " + format_script_error(e) + "\n"); } try { @@ -154,7 +162,7 @@ while (true) { if (has_module("ui")) ui.dispatchEvents(); } catch (e) { - print("present error: " + e + "\n"); + print("present error: " + format_script_error(e) + "\n"); } frame++; diff --git a/src/engine/common/Runtime.cpp b/src/engine/common/Runtime.cpp index 8597d8f0..52f91028 100644 --- a/src/engine/common/Runtime.cpp +++ b/src/engine/common/Runtime.cpp @@ -7,6 +7,8 @@ #include "common/ScriptError.h" #include "common/ScriptModule.h" +#include + #include #include #include @@ -421,6 +423,9 @@ void Runtime::initialize() { try { ModuleManager::expose(*this); exposeReflection(*this, table("eve")); + table("eve").addFunc("lastScriptError", [this]() { + return script::formatLastScriptError(handle()); + }); initialized_ = true; } catch (const std::exception& error) { ModuleManager::detach(this); @@ -431,6 +436,11 @@ void Runtime::initialize() { void Runtime::installErrorHandler() { if (!vm_) return; HSQUIRRELVM vm = handle(); + // Line numbers in sq_stackinfos require debug info at compile time. + sq_enabledebuginfo(vm, SQTrue); + // Call the hook for try/catch as well, while the throw-site stack is live. + // load.nut's eve_frame catch would otherwise print only the error string. + sq_notifyallexceptions(vm, SQTrue); sq_newclosure(vm, &scriptErrorHook, 0); // pushes the closure sq_seterrorhandler(vm); // pops the closure } @@ -552,7 +562,8 @@ const ScriptInfo& Runtime::execute(ScriptId id) { sq_pushroottable(vm); const SQRESULT result = sq_call(vm, 1, SQFalse, SQTrue); if (SQ_FAILED(result)) { - script::ScriptErrorContext ctx = script::takeLastScriptError(vm); + script::ScriptErrorContext ctx; + if (const auto* pending = script::peekLastScriptError(vm)) ctx = *pending; if (ctx.empty()) ctx.message = "script error"; try { discoverClasses(record, before); @@ -1206,7 +1217,16 @@ script::ScriptErrorContext Runtime::compileErrorContext(const std::string& what, [[noreturn]] void Runtime::fail(ScriptStage stage, const std::string& source, ScriptId id, const std::exception& error) { script::ScriptErrorContext ctx = script::takeLastScriptError(handle()); - if (ctx.empty()) ctx.message = error.what(); + const char* what = error.what(); + const std::string message = what ? what : ""; + // notifyallexceptions records every caught throw (e.g. file_exists). Only + // keep that snapshot when it actually describes this failure. + if (ctx.empty() || + (ctx.message != message && message.find(ctx.message) == std::string::npos && + ctx.message.find(message) == std::string::npos)) { + ctx = {}; + ctx.message = message; + } fail(stage, source, id, std::move(ctx)); } diff --git a/src/engine/common/ScriptError.cpp b/src/engine/common/ScriptError.cpp index 47b2fc1b..b614346a 100644 --- a/src/engine/common/ScriptError.cpp +++ b/src/engine/common/ScriptError.cpp @@ -92,7 +92,18 @@ ScriptErrorContext captureScriptError(HSQUIRRELVM vm) { frame.line = static_cast(si.line); ctx.stack.push_back(std::move(frame)); } - if (!ctx.stack.empty()) { + // Native binding frames (simplesquirrel type checks, etc.) are often + // "NATIVE" with line <= 0. Prefer the first script frame that still has a + // line number so reports point at the caller in main.nut, not the stub. + for (const ScriptFrame& frame : ctx.stack) { + if (frame.line > 0) { + ctx.source = frame.source; + ctx.function = frame.function; + ctx.line = frame.line; + break; + } + } + if (ctx.line <= 0 && !ctx.stack.empty()) { ctx.source = ctx.stack.front().source; ctx.function = ctx.stack.front().function; ctx.line = ctx.stack.front().line; @@ -228,4 +239,10 @@ void clearLastScriptError(HSQUIRRELVM vm) { g_last_errors.erase(vm); } +std::string formatLastScriptError(HSQUIRRELVM vm) { + const ScriptErrorContext* ctx = peekLastScriptError(vm); + if (!ctx || ctx->empty()) return {}; + return formatScriptError(*ctx); +} + } // namespace eve::script diff --git a/src/engine/common/ScriptError.h b/src/engine/common/ScriptError.h index 7aa6b2a9..74cde18f 100644 --- a/src/engine/common/ScriptError.h +++ b/src/engine/common/ScriptError.h @@ -111,4 +111,11 @@ EVENGINE_API const ScriptErrorContext* peekLastScriptError(HSQUIRRELVM vm); */ EVENGINE_API void clearLastScriptError(HSQUIRRELVM vm); +/** + * @brief Formats the last captured error for a VM without consuming it. + * @param vm VM to read. + * @return Multi-line report, or empty when no error is pending. + */ +EVENGINE_API std::string formatLastScriptError(HSQUIRRELVM vm); + } // namespace eve::script diff --git a/src/engine/devtools/DevTool.cpp b/src/engine/devtools/DevTool.cpp index d5bc18ed..2c099b42 100644 --- a/src/engine/devtools/DevTool.cpp +++ b/src/engine/devtools/DevTool.cpp @@ -88,12 +88,19 @@ void nativeDebugHook(HSQUIRRELVM v, SQInteger type, const SQChar* sourcename, SQ static_cast(line), funcname ? funcname : ""); } -/** Runtime error handler: uncaught script errors are routed to DevTool. */ +/** Runtime error handler: captures the live stack; notifies DevTool when needed. */ SQInteger runtimeErrorHook(HSQUIRRELVM v) { if (!g_active) return 0; - // Capture the full context (message + live call stack) so Runtime::execute - // can enrich the ScriptException it throws once the call unwinds. + // Capture while the throw-site stack is still intact (including caught + // throws when sq_notifyallexceptions is on). eve::script::ScriptErrorContext ctx = eve::script::captureScriptError(v); + // When attached through Runtime, uncaught errors are reported by the + // Runtime error sink after unwind. Notifying here as well would treat + // expected catches (file_exists, migrate_instance) as debugger errors. + if (g_active->runtimeBound()) { + eve::script::setLastScriptError(v, std::move(ctx)); + return 0; + } const std::string msg = ctx.empty() ? std::string("script error") : eve::script::formatScriptError(ctx); try { @@ -129,9 +136,9 @@ void DevTool::attach(eve::Runtime& runtime, bool sampleLocals) { attach(runtime.vm(), sampleLocals); runtime_ = &runtime; // Route Runtime-boundary errors into the slicer/report. The VM error hook - // covers uncaught script errors; this sink catches the rest — compile, - // reflect and unload failures, plus any uncaught error the hook already - // marked reported() so we skip it and avoid slicing twice. + // only captures the stack when this Runtime sink is bound; this handler + // reports compile / reflect / unload failures and uncaught execute errors + // exactly once. runtime.setErrorHandler([this](const eve::ScriptException& error) { if (error.reported()) return; try { @@ -175,6 +182,7 @@ void DevTool::attach(HSQUIRRELVM vm, bool sampleLocals) { Debugger::instance().setPump([this]() { pumpWhilePaused(); }); sq_enabledebuginfo(vm_, SQTrue); + sq_notifyallexceptions(vm_, SQTrue); sq_setnativedebughook(vm_, nativeDebugHook); // Route uncaught script errors into the debugger (break-on-error aware). sq_newclosure(vm_, runtimeErrorHook, 0); @@ -713,17 +721,25 @@ std::string DevTool::notifyError(const std::string& errorMessage, const std::vector& hintVars) { SourceLoc site; if (vm_) { - // When called from the uncaught-error hook, level 0 is the native hook - // itself and level 1 is the throwing script frame, so this already - // lands on the exact throw site (before the stack unwinds). When called - // from a script catch (eve.dev.reportError), level 1 is the catch - // statement that reported the error (the game script when load.nut - // calls the native reporter directly). - SQStackInfos si; - if (SQ_SUCCEEDED(sq_stackinfos(vm_, 1, &si))) { - if (si.source) site.source = si.source; - site.line = static_cast(si.line); - if (si.funcname) site.function = si.funcname; + // Prefer the stack captured at throw time. reportError runs from a + // script catch, so sq_stackinfos(level 1) is the catch site (load.nut) + // rather than the original throw in main.nut. + if (const auto* ctx = eve::script::peekLastScriptError(vm_)) { + if (!ctx->stack.empty() && + (errorMessage.find(ctx->message) != std::string::npos || + ctx->message.find(errorMessage) != std::string::npos)) { + site.source = ctx->source; + site.line = ctx->line; + site.function = ctx->function; + } + } + if (site.empty()) { + SQStackInfos si; + if (SQ_SUCCEEDED(sq_stackinfos(vm_, 1, &si))) { + if (si.source) site.source = si.source; + site.line = static_cast(si.line); + if (si.funcname) site.function = si.funcname; + } } } markErrorUses(site, hintVars); diff --git a/src/engine/devtools/DevTool.hpp b/src/engine/devtools/DevTool.hpp index 6a5a64b1..f504eb21 100644 --- a/src/engine/devtools/DevTool.hpp +++ b/src/engine/devtools/DevTool.hpp @@ -85,6 +85,8 @@ public: void drawConsolePanel(); bool isAttached() const { return vm_ != nullptr; } + /** @brief True when errors also flow through Runtime::setErrorHandler. */ + bool runtimeBound() const { return runtime_ != nullptr; } bool renderTraceEnabled() const { return renderTraceEnabled_; } bool sampleLocals() const { return sampleLocals_; } void setSampleLocals(bool on) { sampleLocals_ = on; } diff --git a/src/engine/devtools/Snapshot.cpp b/src/engine/devtools/Snapshot.cpp index 6cb7ceeb..dac9724d 100644 --- a/src/engine/devtools/Snapshot.cpp +++ b/src/engine/devtools/Snapshot.cpp @@ -72,6 +72,10 @@ bool isEngineName(const std::string& name) { "soft_reload_scripts", "poll_hot_reload", "file_exists", + "format_script_error", + "emit_loop_error", + "clear_loop_error", + "_loop_errors", "path_endswith", "normalize_path", "async_pump", diff --git a/src/modules/card/CardTypes.cpp b/src/modules/card/CardTypes.cpp index b4d0dde9..b10bd2b9 100644 --- a/src/modules/card/CardTypes.cpp +++ b/src/modules/card/CardTypes.cpp @@ -220,16 +220,17 @@ void printCentered(graphics::Graphics *gfx, const std::string &text, float cx, f void renderCardBack(graphics::Graphics *gfx, float x, float y, float w, float h, float angle, float a) { const RenderTransform previous = gRenderTransform; gRenderTransform = {x + w * 0.5f, y + h * 0.5f, angle, true}; - drawSolid(gfx, x, y, w, h, angle, glm::vec4(0.06f, 0.06f, 0.10f, a)); - const float i1 = w * 0.05f; - drawSolid(gfx, x + i1, y + i1, w - i1 * 2.f, h - i1 * 2.f, angle, - glm::vec4(0.30f, 0.17f, 0.45f, a)); - const float i2 = w * 0.10f; - drawSolid(gfx, x + i2, y + i2, w - i2 * 2.f, h - i2 * 2.f, angle, - glm::vec4(0.11f, 0.08f, 0.22f, a)); + // 2D solids keep the first fragment at equal depth, so inner details go first. if (gfx->getFont()) printCentered(gfx, "?", x + w * 0.5f, y + h * 0.5f, 1.2f * (w / 110.f), glm::vec4(0.9f, 0.85f, 0.95f, a)); + const float i2 = w * 0.10f; + drawSolid(gfx, x + i2, y + i2, w - i2 * 2.f, h - i2 * 2.f, angle, + glm::vec4(0.11f, 0.08f, 0.22f, a)); + const float i1 = w * 0.05f; + drawSolid(gfx, x + i1, y + i1, w - i1 * 2.f, h - i1 * 2.f, angle, + glm::vec4(0.30f, 0.17f, 0.45f, a)); + drawSolid(gfx, x, y, w, h, angle, glm::vec4(0.06f, 0.06f, 0.10f, a)); gRenderTransform = previous; } diff --git a/src/modules/graphics/Graphics.cpp b/src/modules/graphics/Graphics.cpp index c2146da3..c6cf3b0a 100644 --- a/src/modules/graphics/Graphics.cpp +++ b/src/modules/graphics/Graphics.cpp @@ -216,12 +216,12 @@ Shader* Graphics::prepareSceneColorResolveShader(Texture* scene) { void Graphics::drawScene3DRGBA(float x, float y, float w, float h, float r, float g, float b, float a) { Texture* scene = getSceneColorTexture(); if (!scene) return; - // Scene color A is linear view-depth, not opacity. The default textured - // blit multiplies that into SrcAlpha, so the planet composites against - // the dark clear color and looks dim. Use the same opaque FXAA resolve - // as the engine auto-composite path (aa shader writes alpha = 1). - Texture *resolved = prepareFinalSceneTexture(scene); - drawTexturedRectShaderUV(resolved, nullptr, x, y, w, h, 0.f, 0.f, 1.f, 1.f, + // Queue a scene-color blit at this 2D overlay slot so present() owns + // AA/Bloom/Exposure + ACES. Running prepareFinalSceneTexture here would + // setCanvas() (ending the still-open scene pass and queueing auto-resolve) + // then blit the HDR result through the LDR textured pipeline *on top of* + // the auto ACES composite — two fullscreen 3D quads that strobe. + drawTexturedRectShaderUV(scene, nullptr, x, y, w, h, 0.f, 0.f, 1.f, 1.f, Color(r, g, b, a), false, BlendMode::Opaque); } @@ -363,9 +363,16 @@ void Graphics::expose(ssq::Table& table) { auto cam = table.addClass("Camera3D", std::function([]() { return Camera3D::createCamera(); }), false); cam.addFunc("setEye", &Camera3D::setEye); + cam.addFunc("getEyeX", &Camera3D::getEyeX); + cam.addFunc("getEyeY", &Camera3D::getEyeY); + cam.addFunc("getEyeZ", &Camera3D::getEyeZ); cam.addFunc("setTarget", &Camera3D::setTarget); + cam.addFunc("getTargetX", &Camera3D::getTargetX); + cam.addFunc("getTargetY", &Camera3D::getTargetY); + cam.addFunc("getTargetZ", &Camera3D::getTargetZ); cam.addFunc("setUp", &Camera3D::setUp); cam.addFunc("setFov", &Camera3D::setFov); + cam.addFunc("getFov", &Camera3D::getFov); cam.addFunc("setOrthographic", &Camera3D::setOrthographic); cam.addFunc("setPerspective", &Camera3D::setPerspective); cam.addFunc("setClipPlanes", &Camera3D::setClipPlanes); diff --git a/src/modules/graphics/RenderSystem3D.cpp b/src/modules/graphics/RenderSystem3D.cpp index 38089716..f6cfc1fa 100644 --- a/src/modules/graphics/RenderSystem3D.cpp +++ b/src/modules/graphics/RenderSystem3D.cpp @@ -157,6 +157,10 @@ void Camera3D::setEye(float x, float y, float z) { d->eyeZ = z; } +float Camera3D::getEyeX() { return data()->eyeX; } +float Camera3D::getEyeY() { return data()->eyeY; } +float Camera3D::getEyeZ() { return data()->eyeZ; } + void Camera3D::setTarget(float x, float y, float z) { auto d = data(); d->targetX = x; @@ -164,6 +168,10 @@ void Camera3D::setTarget(float x, float y, float z) { d->targetZ = z; } +float Camera3D::getTargetX() { return data()->targetX; } +float Camera3D::getTargetY() { return data()->targetY; } +float Camera3D::getTargetZ() { return data()->targetZ; } + void Camera3D::setUp(float x, float y, float z) { auto d = data(); d->upX = x; @@ -173,6 +181,8 @@ void Camera3D::setUp(float x, float y, float z) { void Camera3D::setFov(float fovYDeg) { data()->fovYDeg = fovYDeg; } +float Camera3D::getFov() { return data()->fovYDeg; } + void Camera3D::setOrthographic(float height) { data()->orthographic = true; data()->orthoHeight = std::max(0.001f, height); diff --git a/src/modules/graphics/RenderSystem3D.h b/src/modules/graphics/RenderSystem3D.h index 217336db..d8114a73 100644 --- a/src/modules/graphics/RenderSystem3D.h +++ b/src/modules/graphics/RenderSystem3D.h @@ -71,9 +71,19 @@ public: } void setEye(float x, float y, float z); + /** @brief Camera eye position (world space). */ + float getEyeX(); + float getEyeY(); + float getEyeZ(); void setTarget(float x, float y, float z); + /** @brief Look-at target (world space). */ + float getTargetX(); + float getTargetY(); + float getTargetZ(); void setUp(float x, float y, float z); void setFov(float fovYDeg); + /** @brief Vertical field of view in degrees. */ + float getFov(); /** @brief Enable an orthographic projection with the given vertical world-space span. */ void setOrthographic(float height); /** @brief Return to perspective projection, retaining the configured field of view. */ diff --git a/src/modules/graphics/vulkan/Graphics2D.cpp b/src/modules/graphics/vulkan/Graphics2D.cpp index d1a20e3a..4c008a80 100644 --- a/src/modules/graphics/vulkan/Graphics2D.cpp +++ b/src/modules/graphics/vulkan/Graphics2D.cpp @@ -1573,6 +1573,31 @@ void Graphics::flushToSwapchain() { replaySpans(engineSpans); }; + auto drawPlacedSceneResolve = [&](TexturedBatch &placed) { + if (!sceneResolve) { + drawTextured(placed); + return; + } + TexturedBatch blit = *sceneResolve; + const glm::vec4 acesColor = sceneResolve->batch.vertices().empty() + ? glm::vec4(1.f, 1.f, 1.f, 65536.f) + : sceneResolve->batch.vertices().front().color; + const auto &src = placed.batch.vertices(); + if (src.size() >= 6) { + const float x = src[0].pos.x; + const float y = src[0].pos.y; + const float w = src[1].pos.x - x; + const float h = src[2].pos.y - y; + const glm::vec4 tint = src[0].color; + blit.batch.clear(); + blit.batch.addTexturedRect(x, y, w, h, + Color(tint.r * acesColor.r, tint.g * acesColor.g, + tint.b * acesColor.b, acesColor.a), + src[0].uv.x, src[0].uv.y, src[1].uv.x, src[2].uv.y); + } + drawTextured(blit, true); + }; + // Default: blit 3D fullscreen under script 2D. Scripts that call // drawScene3D / drawTexturedRect(getSceneColorTexture()) own the order. if (autoScene && sceneResolve) { @@ -1596,7 +1621,10 @@ void Graphics::flushToSwapchain() { drawSolidSpan(sp.index, sp.vertBegin, sp.vertCount); } else if (sp.kind == OverlayKind::Textured && texPipeline && sp.index < textured.size()) { - drawTextured(textured[sp.index]); + if (sceneResolve && textured[sp.index].texture == sceneTex) + drawPlacedSceneResolve(textured[sp.index]); + else + drawTextured(textured[sp.index]); if (textured[sp.index].texture == sceneTex) drawEngine3D(); } else if (sp.kind == OverlayKind::Lit && lit2dPipeline && sp.index < lit.size()) { std::vector one; diff --git a/src/modules/graphics/vulkan/GraphicsDeferredGraph.cpp b/src/modules/graphics/vulkan/GraphicsDeferredGraph.cpp index edd3c1c2..c7e0c16d 100644 --- a/src/modules/graphics/vulkan/GraphicsDeferredGraph.cpp +++ b/src/modules/graphics/vulkan/GraphicsDeferredGraph.cpp @@ -185,6 +185,14 @@ void Graphics::recordGBufferPassDraws(vkb::FrameGraphPassContext &ctx) { } void Graphics::recordDeferredFrameGraph() { + // Voxel / script begin3DFrame paths never fill G-buffer or CSM. Submitting + // the empty four-pass graph every frame still waits on the JobSystem and + // can hitch the orbit camera (MAILBOX then shows a newer pose, then an + // older in-flight image — a short reverse jump). + if (!gbufferPending && shadowPendingMask == 0) { + dropPendingOffscreenPasses(); + return; + } const size_t slot = currentFrameSlot(); if (deferredGraphRecorded_ && deferredGraphRecordedSlot_ == slot) { // render3D can be called several times per script frame (e.g. the diff --git a/src/modules/graphics/vulkan/GraphicsPipeline.cpp b/src/modules/graphics/vulkan/GraphicsPipeline.cpp index ad1b260a..95a10525 100644 --- a/src/modules/graphics/vulkan/GraphicsPipeline.cpp +++ b/src/modules/graphics/vulkan/GraphicsPipeline.cpp @@ -1481,7 +1481,10 @@ void Graphics::buildPrimitive3DPipelines(const vkb::BuiltRenderPass &target, vk: void Graphics::queueSceneColorResolve() { Texture *src = takeFinalSceneTexture(); if (!src) src = getSceneColorTexture(); - if (!src || !src->gpuHandle || sceneColorComposited) return; + if (!src || !src->gpuHandle) return; + // Still build the AA/Bloom/Exposure resolve when a script called + // drawScene3D (sceneColorComposited). autoScene skips the implicit + // fullscreen blit; present replaces the script's scene-color span. const bool reflectionPasses = renderControl_ && (renderControl_->isEnabled("rtgi") || renderControl_->isEnabled("ssr") || diff --git a/src/modules/mouse/Mouse.cpp b/src/modules/mouse/Mouse.cpp index 8e896fa3..02a56ac2 100644 --- a/src/modules/mouse/Mouse.cpp +++ b/src/modules/mouse/Mouse.cpp @@ -33,6 +33,8 @@ void Mouse::expose(ssq::Class& cls) { cls.addFunc("isGrabbed", &Mouse::isGrabbed); cls.addFunc("setRelativeMode", &Mouse::setRelativeMode); cls.addFunc("getRelativeMode", &Mouse::getRelativeMode); + cls.addFunc("getMovementX", [](Mouse *m) -> float { return m ? float(m->getMovementX()) : 0.f; }); + cls.addFunc("getMovementY", [](Mouse *m) -> float { return m ? float(m->getMovementY()) : 0.f; }); } } // namespace eve::mouse diff --git a/src/modules/mouse/Mouse.h b/src/modules/mouse/Mouse.h index b45f661d..693b1ced 100644 --- a/src/modules/mouse/Mouse.h +++ b/src/modules/mouse/Mouse.h @@ -47,6 +47,10 @@ public: virtual bool isGrabbed() const = 0; virtual bool setRelativeMode(bool relative) = 0; virtual bool getRelativeMode() const = 0; + /** @brief Pixel delta since the last getMovementX() (also latches Y for getMovementY). */ + virtual double getMovementX() = 0; + /** @brief Pixel delta latched by the most recent getMovementX(); call X then Y each frame. */ + virtual double getMovementY() const = 0; }; // Mouse diff --git a/src/modules/mouse/sdl/Mouse.cpp b/src/modules/mouse/sdl/Mouse.cpp index 48260991..d856352d 100644 --- a/src/modules/mouse/sdl/Mouse.cpp +++ b/src/modules/mouse/sdl/Mouse.cpp @@ -207,4 +207,17 @@ bool Mouse::getRelativeMode() const return SDL_GetRelativeMouseMode() != SDL_FALSE; } +double Mouse::getMovementX() +{ + int x = 0, y = 0; + SDL_GetRelativeMouseState(&x, &y); + lastRelY = y; + return double(x); +} + +double Mouse::getMovementY() const +{ + return double(lastRelY); +} + } // eve::mouse::sdl diff --git a/src/modules/mouse/sdl/Mouse.h b/src/modules/mouse/sdl/Mouse.h index fcdc7806..ea4cdccc 100644 --- a/src/modules/mouse/sdl/Mouse.h +++ b/src/modules/mouse/sdl/Mouse.h @@ -36,10 +36,13 @@ public: bool isGrabbed() const override; bool setRelativeMode(bool relative) override; bool getRelativeMode() const override; + double getMovementX() override; + double getMovementY() const override; private: eve::mouse::Cursor * curCursor; std::map systemCursors; + int lastRelY = 0; }; // Mouse } // namespace eve::mouse::sdl diff --git a/src/modules/voxel/Voxel.cpp b/src/modules/voxel/Voxel.cpp index 95066c2e..1d352034 100644 --- a/src/modules/voxel/Voxel.cpp +++ b/src/modules/voxel/Voxel.cpp @@ -7,10 +7,54 @@ #include "procgen/heightmap/TerrainSampler.h" #include +#include #include namespace eve::voxel { +namespace { + +/** Script API: setVoxelByName(x, y, z, name [, orientation=0]). */ +SQInteger sqSetVoxelByName(HSQUIRRELVM vm) { + const SQInteger n = sq_gettop(vm); + if (n < 5 || n > 6) return sq_throwerror(vm, "wrong number of parameters"); + VoxelWorld* world = nullptr; + if (SQ_FAILED(sq_getinstanceup(vm, 1, reinterpret_cast(&world), nullptr)) || + !world) + return sq_throwerror(vm, "invalid VoxelWorld"); + SQInteger wx = 0, wy = 0, wz = 0, orientation = 0; + const SQChar* name = nullptr; + if (SQ_FAILED(sq_getinteger(vm, 2, &wx)) || SQ_FAILED(sq_getinteger(vm, 3, &wy)) || + SQ_FAILED(sq_getinteger(vm, 4, &wz)) || SQ_FAILED(sq_getstring(vm, 5, &name))) + return sq_throwerror(vm, "setVoxelByName expects (x, y, z, name [, orientation])"); + if (n >= 6 && SQ_FAILED(sq_getinteger(vm, 6, &orientation))) + return sq_throwerror(vm, "setVoxelByName orientation must be an integer"); + world->setVoxelByName(static_cast(wx), static_cast(wy), static_cast(wz), + name ? name : "", static_cast(orientation)); + return 0; +} + +/** Script API: streamAround(cx, cy, cz, radius [, maxCreates=0]). */ +SQInteger sqStreamAround(HSQUIRRELVM vm) { + const SQInteger n = sq_gettop(vm); + if (n < 5 || n > 6) return sq_throwerror(vm, "wrong number of parameters"); + VoxelWorld *world = nullptr; + if (SQ_FAILED(sq_getinstanceup(vm, 1, reinterpret_cast(&world), nullptr)) || + !world) + return sq_throwerror(vm, "invalid VoxelWorld"); + SQInteger cx = 0, cy = 0, cz = 0, radius = 0, maxCreates = 0; + if (SQ_FAILED(sq_getinteger(vm, 2, &cx)) || SQ_FAILED(sq_getinteger(vm, 3, &cy)) || + SQ_FAILED(sq_getinteger(vm, 4, &cz)) || SQ_FAILED(sq_getinteger(vm, 5, &radius))) + return sq_throwerror(vm, "streamAround expects (cx, cy, cz, radius [, maxCreates])"); + if (n >= 6 && SQ_FAILED(sq_getinteger(vm, 6, &maxCreates))) + return sq_throwerror(vm, "streamAround maxCreates must be an integer"); + const StreamStats stats = + world->streamAround(int(cx), int(cy), int(cz), int(radius), {}, int(maxCreates)); + sq_pushinteger(vm, stats.created); + return 1; +} + +} // namespace Module_IMPL(Voxel, new Voxel()); @@ -42,12 +86,17 @@ void Voxel::expose(ssq::Table &table) { world.addFunc("hasChunk", &VoxelWorld::hasChunk); world.addFunc("removeChunk", &VoxelWorld::removeChunk); world.addFunc("unloadChunksOutside", &VoxelWorld::unloadChunksOutside); - world.addFunc( - "streamAround", - std::function( - [](VoxelWorld *w, int cx, int cy, int cz, int radius) -> int { - return w ? w->streamAround(cx, cy, cz, radius).created : 0; - })); + world.addFunc("setStreamCacheChunks", &VoxelWorld::setStreamCacheChunks); + world.addFunc("getStreamCacheChunks", &VoxelWorld::getStreamCacheChunks); + { + HSQUIRRELVM vm = table.getHandle(); + sq_pushobject(vm, world.getRaw()); + sq_pushstring(vm, "streamAround", -1); + sq_newclosure(vm, sqStreamAround, 0); + sq_setparamscheck(vm, -5, _SC("xiiii|i")); + sq_newslot(vm, -3, SQFalse); + sq_poptop(vm); + } world.addFunc( "setTerrain", std::function( @@ -86,13 +135,24 @@ void Voxel::expose(ssq::Table &table) { world.addFunc("clear", &VoxelWorld::clear); world.addFunc("getChunkCount", &VoxelWorld::getChunkCount); world.addFunc("getRevision", &VoxelWorld::getRevision); + world.addFunc("getDirtyCount", &VoxelWorld::getDirtyCount); + world.addFunc("getInflightStreamCount", &VoxelWorld::getInflightStreamCount); world.addFunc("remeshDirty", std::function([](VoxelWorld *w) -> int { return w ? w->remeshDirty() : 0; })); world.addFunc("getVoxel", &VoxelWorld::getVoxel); world.addFunc("setVoxel", &VoxelWorld::setVoxel); - world.addFunc("setVoxelByName", &VoxelWorld::setVoxelByName); + // C++ default args are not visible to Squirrel; accept 4 or 5 parameters. + { + HSQUIRRELVM vm = table.getHandle(); + sq_pushobject(vm, world.getRaw()); + sq_pushstring(vm, "setVoxelByName", -1); + sq_newclosure(vm, sqSetVoxelByName, 0); + sq_setparamscheck(vm, -5, _SC("xiiis|i")); + sq_newslot(vm, -3, SQFalse); + sq_poptop(vm); + } world.addFunc("getCubeTypeName", &VoxelWorld::getCubeTypeName); world.addFunc("getCubeTypeTex", &VoxelWorld::getCubeTypeTex); world.addFunc( diff --git a/src/modules/voxel/VoxelWorld.cpp b/src/modules/voxel/VoxelWorld.cpp index bb8d5d47..4468c3a6 100644 --- a/src/modules/voxel/VoxelWorld.cpp +++ b/src/modules/voxel/VoxelWorld.cpp @@ -17,10 +17,11 @@ namespace eve::voxel { VoxelWorld::VoxelWorld() = default; VoxelWorld::VoxelWorld(const CubeTypeRegistry &types) : types_(types) {} -VoxelWorld::~VoxelWorld() = default; +VoxelWorld::~VoxelWorld() { waitStreamJobs(); } void VoxelWorld::setTerrainParams(uint32_t seed, uint8_t top, uint8_t sub, uint8_t stone, float baseHeight, float amplitude, float scale) { + waitStreamJobs(); if (!terrainSampler_) terrainSampler_ = std::make_unique(); terrainSampler_->setSeed(seed); terrainSampler_->setBase(0.f); @@ -36,6 +37,7 @@ void VoxelWorld::setTerrainParams(uint32_t seed, uint8_t top, uint8_t sub, uint8 } void VoxelWorld::setTerrainParam(const std::string &key, float value) { + waitStreamJobs(); if (!terrainSampler_) terrainSampler_ = std::make_unique(); if (key == "seed") { terrainSampler_->setSeed(uint32_t(value)); @@ -82,6 +84,13 @@ void VoxelWorld::setTerrainParam(const std::string &key, float value) { } } +void VoxelWorld::disableTerrain() { + waitStreamJobs(); + terrainEnabled_ = false; + terrainAssetEnabled_ = false; + terrainAsset_.clear(); +} + namespace { /** @brief Sample one terrain column: height plus top-layer texture (sand band). */ @@ -103,6 +112,7 @@ namespace { // Cap remesh worker count: enough to parallelize chunk meshing without // oversubscribing small devices / browser pthread pools. constexpr int kMaxRemeshWorkers = 4; +constexpr int kMaxStreamJobs = 8; } // namespace Chunk *VoxelWorld::getOrCreateChunk(int cx, int cy, int cz) { @@ -134,6 +144,7 @@ void VoxelWorld::removeChunk(int cx, int cy, int cz) { } void VoxelWorld::clear() { + waitStreamJobs(); const bool changed = !chunks_.empty(); chunks_.clear(); visible_.clear(); @@ -142,6 +153,7 @@ void VoxelWorld::clear() { } bool VoxelWorld::loadTerrainAsset(data::ByteData *bytes, float heightOffset, float heightScale) { + waitStreamJobs(); if (!bytes || !std::isfinite(heightOffset) || !std::isfinite(heightScale)) return false; std::string error; if (!terrainAsset_.open(static_cast(bytes->getData()), bytes->getSize(), @@ -203,6 +215,10 @@ uint8_t VoxelWorld::terrainSurfaceAt(int wx, int wz) const { } } +void VoxelWorld::setStreamCacheChunks(int extraChunks) { + streamCacheChunks_ = extraChunks < 0 ? 0 : extraChunks; +} + int VoxelWorld::unloadChunksOutside(int centerX, int centerY, int centerZ, int radiusChunks) { if (radiusChunks < 0) return 0; const int64_t r2 = int64_t(radiusChunks) * int64_t(radiusChunks); @@ -226,23 +242,68 @@ int VoxelWorld::unloadChunksOutside(int centerX, int centerY, int centerZ, int r return int(evict.size()); } +void VoxelWorld::fillChunkTerrain(Chunk &c, int nx, int ny, int nz) const { + const int wy0 = ny * kChunkSize; + for (int lz = 0; lz < kChunkSize; ++lz) + for (int lx = 0; lx < kChunkSize; ++lx) { + int h = 0; + uint8_t surface = terrainTop_; + const int wx = nx * kChunkSize + lx; + const int wz = nz * kChunkSize + lz; + if (terrainAssetEnabled_) { + h = terrainHeightAt(wx, wz); + surface = terrainSurfaceAt(wx, wz); + } else { + sampleTerrainColumn(terrainSampler_.get(), terrainBase_, terrainAmplitude_, + terrainTop_, terrainSand_, sandLevel_, wx, wz, h, surface); + } + for (int ly = 0; ly < kChunkSize; ++ly) { + const int wy = wy0 + ly; + if (wy <= h - 4) + c.set(lx, ly, lz, terrainStone_); + else if (wy <= h - 1) + c.set(lx, ly, lz, terrainSub_); + else if (wy == h) + c.set(lx, ly, lz, surface); + } + } +} + StreamStats VoxelWorld::streamAround(int centerX, int centerY, int centerZ, int radiusChunks, - const std::function &generator) { + const std::function &generator, + int maxCreates) { StreamStats stats; if (radiusChunks < 0) return stats; + if (maxCreates < 0) maxCreates = 0; + + const int keepRadius = radiusChunks + streamCacheChunks_; if (!generator && terrainAssetEnabled_) { const int assetChunk = terrainAsset_.asset().getChunkSize(); - const int worldRadius = (radiusChunks + 1) * kChunkSize; + const int worldRadius = (keepRadius + 1) * kChunkSize; const int assetRadius = assetChunk > 0 ? int(std::ceil(float(worldRadius) * 1.41421356f / float(assetChunk))) + 1 : 0; + const int assetLoads = maxCreates > 0 ? maxCreates : 0; terrainAsset_.streamAround(centerX * kChunkSize + kChunkSize / 2, - centerZ * kChunkSize + kChunkSize / 2, assetRadius); + centerZ * kChunkSize + kChunkSize / 2, assetRadius, assetLoads); } - // Evict first so far-away dirty chunks are not remeshed below. - stats.evicted = unloadChunksOutside(centerX, centerY, centerZ, radiusChunks); + // Evict only past the keep radius so recently left chunks stay resident. + stats.evicted = unloadChunksOutside(centerX, centerY, centerZ, keepRadius); + reapRetiredStreamJobs(); + restoreRetiredInside(centerX, centerY, centerZ, keepRadius); + dropStreamJobsOutside(centerX, centerY, centerZ, keepRadius); + const bool asyncTerrain = maxCreates > 0 && !generator && terrainEnabled_ && !terrainAssetEnabled_; + if (asyncTerrain) + stats.created = harvestStreamJobs(centerX, centerY, centerZ, keepRadius, maxCreates); + struct Missing { + int d2 = 0; + int nx = 0; + int ny = 0; + int nz = 0; + }; + std::vector missing; const int64_t r2 = int64_t(radiusChunks) * int64_t(radiusChunks); for (int dz = -radiusChunks; dz <= radiusChunks; ++dz) for (int dy = -radiusChunks; dy <= radiusChunks; ++dy) @@ -252,44 +313,90 @@ StreamStats VoxelWorld::streamAround(int centerX, int centerY, int centerZ, int const int nx = centerX + dx; const int ny = centerY + dy; const int nz = centerZ + dz; - if (hasChunk(nx, ny, nz)) continue; - Chunk *c = getOrCreateChunk(nx, ny, nz); - if (generator) - generator(*c, nx, ny, nz); - else if (terrainEnabled_ || terrainAssetEnabled_) { - // Heightmap terrain via procgen::TerrainSampler: one column - // per (x, z); sampling world coords keeps chunk seams flush. - const int wy0 = ny * kChunkSize; - for (int lz = 0; lz < kChunkSize; ++lz) - for (int lx = 0; lx < kChunkSize; ++lx) { - int h = 0; - uint8_t surface = terrainTop_; - const int wx = nx * kChunkSize + lx; - const int wz = nz * kChunkSize + lz; - if (terrainAssetEnabled_) { - h = terrainHeightAt(wx, wz); - surface = terrainSurfaceAt(wx, wz); - } else { - sampleTerrainColumn(terrainSampler_.get(), terrainBase_, terrainAmplitude_, - terrainTop_, terrainSand_, sandLevel_, wx, wz, h, surface); - } - for (int ly = 0; ly < kChunkSize; ++ly) { - const int wy = wy0 + ly; - if (wy <= h - 4) - c->set(lx, ly, lz, terrainStone_); - else if (wy <= h - 1) - c->set(lx, ly, lz, terrainSub_); - else if (wy == h) - c->set(lx, ly, lz, surface); - } - } - } - ++stats.created; + if (hasChunk(nx, ny, nz) || isStreamJob(nx, ny, nz)) continue; + missing.push_back({int(d2), nx, ny, nz}); } + std::sort(missing.begin(), missing.end(), [](const Missing &a, const Missing &b) { + if (a.d2 != b.d2) return a.d2 < b.d2; + if (a.nx != b.nx) return a.nx < b.nx; + if (a.ny != b.ny) return a.ny < b.ny; + return a.nz < b.nz; + }); + + if (asyncTerrain) { + thread::JobSystem *jobs = nullptr; + try { + jobs = thread::Thread::create()->getJobSystem(); + } catch (...) { + jobs = nullptr; + } + if (jobs && jobs->isRunning()) { + int submitted = 0; + for (const Missing &m : missing) { + if (int(inflight_.size()) >= kMaxStreamJobs) break; + if (submitted >= maxCreates) break; + StreamJob job; + job.cx = m.nx; + job.cy = m.ny; + job.cz = m.nz; + job.chunk = std::make_unique(m.nx, m.ny, m.nz); + Chunk *raw = job.chunk.get(); + try { + CubeTypeRegistry typesCopy = types_; + job.job = jobs->submit([this, raw, nx = m.nx, ny = m.ny, nz = m.nz, + typesCopy = std::move(typesCopy)]() { + fillChunkTerrain(*raw, nx, ny, nz); + raw->remesh(typesCopy, nullptr, nullptr); + }); + } catch (...) { + job.job = nullptr; + } + if (!job.job) { + fillChunkTerrain(*raw, m.nx, m.ny, m.nz); + raw->remesh(types_, nullptr, nullptr); + chunks_.emplace(key(m.nx, m.ny, m.nz), std::move(job.chunk)); + ++stats.created; + ++revision_; + continue; + } + inflight_.push_back(std::move(job)); + ++submitted; + } + stats.pending = int(missing.size() - size_t(submitted)) + int(inflight_.size()); + return stats; + } + } + + const size_t budget = maxCreates > 0 ? size_t(maxCreates) : missing.size(); + const size_t createCount = std::min(budget, missing.size()); + std::vector created; + created.reserve(createCount); + for (size_t i = 0; i < createCount; ++i) { + const Missing &m = missing[i]; + Chunk *c = getOrCreateChunk(m.nx, m.ny, m.nz); + if (generator) + generator(*c, m.nx, m.ny, m.nz); + else if (terrainEnabled_ || terrainAssetEnabled_) + fillChunkTerrain(*c, m.nx, m.ny, m.nz); + created.push_back(c); + ++stats.created; + } + stats.pending = int(missing.size() - createCount) + getInflightStreamCount(); if (stats.created > 0) { ++revision_; - remeshDirty(); + remeshChunks(created, 0); + if (maxCreates > 0) { + static const int nbs[6][3] = {{1, 0, 0}, {-1, 0, 0}, {0, 1, 0}, + {0, -1, 0}, {0, 0, 1}, {0, 0, -1}}; + for (Chunk *c : created) { + for (const auto &d : nbs) { + if (Chunk *n = getChunk(c->cx() + d[0], c->cy() + d[1], c->cz() + d[2])) + n->markDirty(); + } + } + remeshDirty(1, 0); + } } return stats; } @@ -380,16 +487,127 @@ bool VoxelWorld::loadWorld(data::ByteData *bytes) { return deserializeWorld(static_cast(bytes->getData()), bytes->getSize()); } -int VoxelWorld::remeshDirty(int maxThreads) { - std::vector dirty; - dirty.reserve(chunks_.size()); +int VoxelWorld::getDirtyCount() const { + int n = 0; for (auto &kv : chunks_) { - if (kv.second->isDirty()) dirty.push_back(kv.second.get()); + if (kv.second->isDirty()) ++n; } - const int count = int(dirty.size()); - if (count == 0) return 0; + return n; +} + +int VoxelWorld::getInflightStreamCount() const { + return int(inflight_.size() + retired_.size()); +} + +void VoxelWorld::waitStreamJobs() { + auto waitAll = [](std::vector &jobs) { + for (StreamJob &job : jobs) { + if (!job.job) continue; + job.job->wait(); + delete job.job; + job.job = nullptr; + } + jobs.clear(); + }; + waitAll(inflight_); + waitAll(retired_); +} + +void VoxelWorld::reapRetiredStreamJobs() { + std::vector keep; + keep.reserve(retired_.size()); + for (StreamJob &job : retired_) { + if (job.job && !job.job->isDone()) { + keep.push_back(std::move(job)); + continue; + } + if (job.job) { + job.job->wait(); + delete job.job; + } + } + retired_ = std::move(keep); +} + +bool VoxelWorld::isStreamJob(int cx, int cy, int cz) const { + auto match = [cx, cy, cz](const StreamJob &job) { + return job.cx == cx && job.cy == cy && job.cz == cz; + }; + for (const StreamJob &job : inflight_) { + if (match(job)) return true; + } + for (const StreamJob &job : retired_) { + if (match(job)) return true; + } + return false; +} + +void VoxelWorld::restoreRetiredInside(int centerX, int centerY, int centerZ, int radiusChunks) { + const int64_t r2 = int64_t(radiusChunks) * int64_t(radiusChunks); + std::vector keep; + keep.reserve(retired_.size()); + for (StreamJob &job : retired_) { + const int64_t dx = int64_t(job.cx) - centerX; + const int64_t dy = int64_t(job.cy) - centerY; + const int64_t dz = int64_t(job.cz) - centerZ; + if (dx * dx + dy * dy + dz * dz <= r2) + inflight_.push_back(std::move(job)); + else + keep.push_back(std::move(job)); + } + retired_ = std::move(keep); +} + +void VoxelWorld::dropStreamJobsOutside(int centerX, int centerY, int centerZ, int radiusChunks) { + const int64_t r2 = int64_t(radiusChunks) * int64_t(radiusChunks); + std::vector keep; + keep.reserve(inflight_.size()); + for (StreamJob &job : inflight_) { + const int64_t dx = int64_t(job.cx) - centerX; + const int64_t dy = int64_t(job.cy) - centerY; + const int64_t dz = int64_t(job.cz) - centerZ; + if (dx * dx + dy * dy + dz * dz <= r2) { + keep.push_back(std::move(job)); + continue; + } + retired_.push_back(std::move(job)); + } + inflight_ = std::move(keep); +} + +int VoxelWorld::harvestStreamJobs(int centerX, int centerY, int centerZ, int radiusChunks, + int maxHarvest) { + const int64_t r2 = int64_t(radiusChunks) * int64_t(radiusChunks); + int harvested = 0; + std::vector keep; + keep.reserve(inflight_.size()); + for (StreamJob &job : inflight_) { + if (!job.job || !job.job->isDone() || (maxHarvest > 0 && harvested >= maxHarvest)) { + keep.push_back(std::move(job)); + continue; + } + job.job->wait(); + delete job.job; + job.job = nullptr; + const int64_t dx = int64_t(job.cx) - centerX; + const int64_t dy = int64_t(job.cy) - centerY; + const int64_t dz = int64_t(job.cz) - centerZ; + const bool inside = dx * dx + dy * dy + dz * dz <= r2; + if (!inside || hasChunk(job.cx, job.cy, job.cz) || !job.chunk) continue; + chunks_.emplace(key(job.cx, job.cy, job.cz), std::move(job.chunk)); + ++harvested; + } + inflight_ = std::move(keep); + if (harvested > 0) ++revision_; + return harvested; +} + +void VoxelWorld::remeshChunks(const std::vector &chunks, int maxThreads) { + const int count = int(chunks.size()); + if (count == 0) return; const auto remeshOne = [this](Chunk *c) { + if (!c) return; c->remesh(types_, &VoxelWorld::chunkNeighborSampler, this); }; @@ -401,31 +619,36 @@ int VoxelWorld::remeshDirty(int maxThreads) { } if (workers <= 1 || count <= 1) { - for (Chunk *c : dirty) remeshOne(c); - return count; + for (Chunk *c : chunks) remeshOne(c); + return; } - // Parallel remesh through the engine JobSystem: each child task remeshes - // its own slice of distinct chunks. The sampler only reads the chunk map - // (no concurrent mutation), and each chunk is touched by exactly one task, - // so this is safe. wait() on the loop joins every slice. auto *jobs = thread::Thread::create()->getJobSystem(); thread::Job *loop = nullptr; try { loop = jobs->parallelFor(0, count, - [this, &dirty, &remeshOne](int first, int last) { - for (int k = first; k < last; ++k) remeshOne(dirty[size_t(k)]); + [this, &chunks, &remeshOne](int first, int last) { + for (int k = first; k < last; ++k) remeshOne(chunks[size_t(k)]); }, (count + workers - 1) / workers); } catch (...) { - // Job allocation failed (resource limits): finish everything serially. - // Remesh is idempotent, so any chunks already handled are done twice. - for (Chunk *c : dirty) remeshOne(c); - return count; + for (Chunk *c : chunks) remeshOne(c); + return; } loop->wait(); delete loop; - return count; +} + +int VoxelWorld::remeshDirty(int maxThreads, int maxChunks) { + std::vector dirty; + dirty.reserve(chunks_.size()); + for (auto &kv : chunks_) { + if (!kv.second->isDirty()) continue; + dirty.push_back(kv.second.get()); + if (maxChunks > 0 && int(dirty.size()) >= maxChunks) break; + } + remeshChunks(dirty, maxThreads); + return int(dirty.size()); } void VoxelWorld::selectVisible(const float *viewProj16, float eyeX, float eyeY, float eyeZ, diff --git a/src/modules/voxel/VoxelWorld.h b/src/modules/voxel/VoxelWorld.h index db7aee43..fda29de2 100644 --- a/src/modules/voxel/VoxelWorld.h +++ b/src/modules/voxel/VoxelWorld.h @@ -28,12 +28,17 @@ namespace eve::data { class ByteData; } // namespace eve::data +namespace eve::thread { +class Job; +} + namespace eve::voxel { /** @brief Result of a streaming pass. */ struct StreamStats { int created = 0; // chunks allocated + filled this pass int evicted = 0; // chunks unloaded outside the radius + int pending = 0; // still missing inside the radius after this pass }; /** @brief One draw batch: all packed rects of one face direction for one chunk. */ @@ -75,15 +80,30 @@ public: int unloadChunksOutside(int centerX, int centerY, int centerZ, int radiusChunks); /** - * @brief Player-centered streaming: evict chunks outside `radiusChunks`, then - * allocate + fill + mesh missing chunks inside it (sphere, chunk coords). + * @brief Extra chunk radius kept after leaving the stream sphere. + * `streamAround` still creates only inside `radiusChunks`, but does not + * unload (or drop in-flight jobs) until distance > radius + this value. + * 0 (default) matches the old immediately-evict behaviour. + */ + void setStreamCacheChunks(int extraChunks); + int getStreamCacheChunks() const { return streamCacheChunks_; } + + /** + * @brief Player-centered streaming: fill missing chunks inside `radiusChunks`, + * unload only past radius + `getStreamCacheChunks()` (hysteresis cache). * `generator` is called for each new chunk (chunk, cx, cy, cz); when null * the stored terrain sampler (procgen::TerrainSampler) is used if enabled, * otherwise chunks stay empty. * Radius < 0 is a no-op. + * @param maxCreates Maximum new chunks to *start or harvest* this call; nearest first. + * 0 means unlimited synchronous fill (tests / one-shot). + * >0 runs terrain fill+mesh on JobSystem workers; this call + * only enqueues work and inserts chunks that are already + * ready, so eve_update stays off the generation cost. */ StreamStats streamAround(int centerX, int centerY, int centerZ, int radiusChunks, - const std::function &generator = {}); + const std::function &generator = {}, + int maxCreates = 0); /** * @brief Configure the built-in terrain generator used by streamAround(). The @@ -104,7 +124,7 @@ public: */ void setTerrainParam(const std::string &key, float value); - void disableTerrain() { terrainEnabled_ = false; terrainAssetEnabled_ = false; terrainAsset_.clear(); } + void disableTerrain(); bool terrainEnabled() const { return terrainEnabled_ || terrainAssetEnabled_; } /** @brief Compatibility operation that opens baked EVTR terrain data. */ @@ -133,12 +153,18 @@ public: bool loadWorld(data::ByteData *bytes); /** - * @brief Remesh every dirty chunk. Returns number remeshed. + * @brief Remesh dirty chunks. * @param maxThreads 0 = auto (parallel up to an internal cap on desktop), * 1 = serial, >1 = that many workers. Falls back to * serial when threads are unavailable. + * @param maxChunks 0 = all dirty chunks; >0 remesh at most that many + * (so a streaming world can finish meshes across frames). */ - int remeshDirty(int maxThreads = 0); + int remeshDirty(int maxThreads = 0, int maxChunks = 0); + /** @brief Number of chunks whose mesh is stale. */ + int getDirtyCount() const; + /** @brief Terrain jobs still running (not yet inserted into the world). */ + int getInflightStreamCount() const; /** * @brief Select chunks/faces to draw. @@ -238,8 +264,28 @@ private: /** Mark the six adjacent chunks dirty when an edit lands on a border face. */ void markNeighborChunksDirty(int cx, int cy, int cz, int lx, int ly, int lz); uint8_t terrainSurfaceAt(int wx, int wz) const; + void fillChunkTerrain(Chunk &chunk, int nx, int ny, int nz) const; + void remeshChunks(const std::vector &chunks, int maxThreads); + void waitStreamJobs(); + void reapRetiredStreamJobs(); + void restoreRetiredInside(int centerX, int centerY, int centerZ, int radiusChunks); + bool isStreamJob(int cx, int cy, int cz) const; + int harvestStreamJobs(int centerX, int centerY, int centerZ, int radiusChunks, int maxHarvest); + void dropStreamJobsOutside(int centerX, int centerY, int centerZ, int radiusChunks); + + struct StreamJob { + int cx = 0; + int cy = 0; + int cz = 0; + std::unique_ptr chunk; + thread::Job *job = nullptr; + }; + + std::vector inflight_; + std::vector retired_; std::unordered_map> chunks_; + int streamCacheChunks_ = 0; uint64_t revision_ = 0; std::vector visible_; std::vector visibleChunkKeys_; diff --git a/src/scripts/demo.nut b/src/scripts/demo.nut index 83ea3966..33ffefd6 100644 --- a/src/scripts/demo.nut +++ b/src/scripts/demo.nut @@ -203,6 +203,7 @@ eve_init = function() { planet = eve.Renderable3D(); planet.setMesh(gfx.newMeshSphere(64, 32)); planet.setTexture(demoAssets.newPlanetTexture(gfx)); + planet.setCamera(planetCam); planet.setPosition(0.55, 0.1, 0.0); planet.setScale(1.15, 1.15, 1.15); planet.setTint(0.85, 0.88, 1.0, 1.0); diff --git a/src/scripts/load.nut b/src/scripts/load.nut index e59f1525..8ef29e1f 100644 --- a/src/scripts/load.nut +++ b/src/scripts/load.nut @@ -22,6 +22,43 @@ function file_exists(path) { } } +/** Prefer the throw-site stack captured by the VM error hook over `e` alone. */ +function format_script_error(e) { + if ("lastScriptError" in eve) { + local stacked = eve.lastScriptError(); + if (typeof stacked == "string" && stacked.len() > 0) return stacked; + } + return "" + e; +} + +_loop_errors <- {} + +function emit_loop_error(slot, prefix, e) { + local msg = prefix + format_script_error(e); + local st = (slot in _loop_errors) ? _loop_errors[slot] : null; + if (st != null && st.text == msg) { + st.n += 1; + return; + } + if (st != null && st.n > 1) + print(st.text + "\n (x" + st.n + " frames)\n"); + local rec = { text = msg, n = 1 }; + if (slot in _loop_errors) + _loop_errors[slot] = rec; + else + _loop_errors[slot] <- rec; + if ("dev" in eve) eve.dev.reportError("" + e); + print(msg + "\n"); +} + +function clear_loop_error(slot) { + if (!(slot in _loop_errors)) return; + local st = _loop_errors[slot]; + if (st.n > 1) + print(st.text + "\n (stopped after x" + st.n + " frames)\n"); + delete _loop_errors[slot]; +} + function path_endswith(str, suffix) { if (str == null || suffix == null) return false; if (str.len() < suffix.len()) return false; @@ -61,7 +98,7 @@ function ensure_module(slot) { try { getroottable()[slot] <- eve[m.cls](); } catch (e) { - print("module " + m.cls + " failed to initialize: " + e + "\n"); + print("module " + m.cls + " failed to initialize: " + format_script_error(e) + "\n"); return false; } local _mdt = (clock() - _m0) * 1000.0; @@ -253,14 +290,14 @@ if ("asyncScript" in eve && eve.asyncScript != null && eve.asyncScript != "") { // Report FIRST: with "break on error" the debugger pauses at the // throwing script line / catch site before stdout is flushed. if ("dev" in eve) eve.dev.reportError("" + e); - print("async runtime failed to load: " + e + "\n"); + print("async runtime failed to load: " + format_script_error(e) + "\n"); } } else if (file_exists("async.nut")) { try { dofile("async.nut"); } catch (e) { if ("dev" in eve) eve.dev.reportError("" + e); - print("async.nut failed to load: " + e + "\n"); + print("async.nut failed to load: " + format_script_error(e) + "\n"); } } _startup_ms("async runtime loaded"); @@ -392,7 +429,7 @@ function soft_reload_scripts() { try { candidates = compile_reload_candidates(); } catch (e) { - report_reload_failure("hot-reload compile failed: " + e); + report_reload_failure("hot-reload compile failed: " + format_script_error(e)); return false; } @@ -405,7 +442,7 @@ function soft_reload_scripts() { eve_before_reload(); } catch (e) { restore_reload_bindings(oldBindings); - report_reload_failure("eve_before_reload failed: " + e); + report_reload_failure("eve_before_reload failed: " + format_script_error(e)); return false; } } @@ -414,7 +451,7 @@ function soft_reload_scripts() { local e = eve.dev.beginStateReload(); if (e != "") { restore_reload_bindings(oldBindings); - report_reload_failure("state reload: capture failed: " + e); + report_reload_failure("state reload: capture failed: " + format_script_error(e)); return false; } } @@ -430,7 +467,7 @@ function soft_reload_scripts() { restore_reload_bindings(oldBindings); local rollbackError = ""; if (hasSession) rollbackError = eve.dev.abortStateReload(); - local message = "hot-reload script failed: " + candidate.path + ": " + e; + local message = "hot-reload script failed: " + candidate.path + ": " + format_script_error(e); if (rollbackError != "") message += "; rollback failed: " + rollbackError; report_reload_failure(message); return false; @@ -456,7 +493,7 @@ function soft_reload_scripts() { eve_after_reload(); } catch (e) { if ("dev" in eve) eve.dev.reportError("" + e); - print("eve_after_reload failed: " + e + "\n"); + print("eve_after_reload failed: " + format_script_error(e) + "\n"); } } if ("eve_reload" in getroottable()) { @@ -464,7 +501,7 @@ function soft_reload_scripts() { eve_reload(); } catch (e) { if ("dev" in eve) eve.dev.reportError("" + e); - print("eve_reload failed: " + e + "\n"); + print("eve_reload failed: " + format_script_error(e) + "\n"); } } return true; @@ -479,7 +516,7 @@ function handle_change(p) { try { if ("reloadScriptModule" in eve) isModule = eve.reloadScriptModule(p); } catch (e) { - report_reload_failure("module hot-reload failed: " + p + ": " + e); + report_reload_failure("module hot-reload failed: " + p + ": " + format_script_error(e)); return; } if (!isModule) track_script(p); @@ -491,7 +528,7 @@ function handle_change(p) { hot.tryReload(p); } catch (e) { if ("dev" in eve) eve.dev.reportError("" + e); - print("hot-reload asset failed: " + p + ": " + e + "\n"); + print("hot-reload asset failed: " + p + ": " + format_script_error(e) + "\n"); } } if ("eve_asset_reload" in getroottable()) { @@ -499,7 +536,7 @@ function handle_change(p) { eve_asset_reload(p); } catch (e) { if ("dev" in eve) eve.dev.reportError("" + e); - print("eve_asset_reload failed: " + p + ": " + e + "\n"); + print("eve_asset_reload failed: " + p + ": " + format_script_error(e) + "\n"); } } } @@ -520,7 +557,7 @@ function poll_hot_reload() { if (hot.watchNewDirectory(p)) continue; } catch (e) { if ("dev" in eve) eve.dev.reportError("" + e); - print("hot-reload directory watch failed: " + p + ": " + e + "\n"); + print("hot-reload directory watch failed: " + p + ": " + format_script_error(e) + "\n"); } } if (path_endswith(p, ".nut")) { @@ -535,7 +572,7 @@ function poll_hot_reload() { try { if ("reloadScriptModule" in eve) isModule = eve.reloadScriptModule(p); } catch (e) { - report_reload_failure("module hot-reload failed: " + p + ": " + e); + report_reload_failure("module hot-reload failed: " + p + ": " + format_script_error(e)); return; } if (!isModule) track_script(p); @@ -583,7 +620,7 @@ if (file_exists("main.nut")) { compilestring(eve.demoScript)(); } catch (e) { if ("dev" in eve) eve.dev.reportError("" + e); - print("Embedded demo failed to load: " + e + "\n"); + print("Embedded demo failed to load: " + format_script_error(e) + "\n"); } } _startup_ms("game script loaded"); @@ -600,7 +637,7 @@ if (config.hotReload && has_module("fs") && has_module("hot")) { print("hot-reload: watching " + n + " path(s)\n"); } catch (e) { if ("dev" in eve) eve.dev.reportError("" + e); - print("hot-reload watchTree failed: " + e + "\n"); + print("hot-reload watchTree failed: " + format_script_error(e) + "\n"); } } _startup_ms("hot reload watch registered"); @@ -610,7 +647,7 @@ try { eve_init(); } catch (e) { if ("dev" in eve) eve.dev.reportError("" + e); - print("eve_init failed: " + e + "\n"); + print("eve_init failed: " + format_script_error(e) + "\n"); } _startup_ms("eve_init done"); @@ -785,9 +822,9 @@ eve_frame <- function() { // ImGui AI/MCP panel (requires ui.beginFrameAndRender in eve_render). dev_draw_ai(); dev_draw_console(); + clear_loop_error("frame"); } catch (e) { - if ("dev" in eve) eve.dev.reportError("" + e); - print("frame error: " + e + "\n"); + emit_loop_error("frame", "frame error: ", e); } // Always present: eve_render may have opened a 3D pass (gfx.render3D) // before throwing. Skipping present leaves swapchainPassOpen and every @@ -795,8 +832,9 @@ eve_frame <- function() { try { gfx.present(); if (has_module("ui")) ui.dispatchEvents(); + clear_loop_error("present"); } catch (e) { - print("present error: " + e + "\n"); + emit_loop_error("present", "present error: ", e); } if (!("_startup_first_present" in getroottable())) { getroottable()._startup_first_present <- true; diff --git a/test/mouse.nut b/test/mouse.nut index baa8dbf5..388046cd 100644 --- a/test/mouse.nut +++ b/test/mouse.nut @@ -18,6 +18,10 @@ function relativeMode() { if (eve.Mouse == null) return false; local m = eve.Mouse(); if (m.setRelativeMode == null || m.getRelativeMode == null) return false; + if (m.getMovementX == null || m.getMovementY == null) return false; + m.getMovementX(); + local my = m.getMovementY(); + if (typeof my != "float" && typeof my != "integer") return false; // SDL may reject relative mode without a focused window; only verify the // round trip when the backend accepts the request. if (!m.setRelativeMode(true)) return true; diff --git a/test/runtime.cpp b/test/runtime.cpp index 20039902..102a2e90 100644 --- a/test/runtime.cpp +++ b/test/runtime.cpp @@ -109,6 +109,24 @@ outer(); CHECK(caught); } +TEST_CASE("runtimeCaughtErrorExposesThrowSiteViaLastScriptError") { + Runtime runtime(256, ssq::Libs::ALL); + runtime.initialize(); + runtime.runSource(R"SQ( +function boom() { throw "kaboom" } +function outer() { boom() } +try { outer() } catch (e) { + caught_stack <- eve.lastScriptError() +} +)SQ", + "caught.nut"); + const std::string report = runtime.vm().find("caught_stack").toString(); + CHECK(report.find("kaboom") != std::string::npos); + CHECK(report.find("caught.nut") != std::string::npos); + CHECK(report.find("Stack:") != std::string::npos); + CHECK(report.find("boom") != std::string::npos); +} + TEST_CASE("runtimeRuntimeErrorMarksScriptFailedAndRestoresStack") { Runtime runtime(256, ssq::Libs::ALL); const SQInteger top = runtime.vm().getTop(); diff --git a/test/runtime_error_sink.cpp b/test/runtime_error_sink.cpp index 6ff8b1c0..2c9e0420 100644 --- a/test/runtime_error_sink.cpp +++ b/test/runtime_error_sink.cpp @@ -51,10 +51,9 @@ boom(); )SQ", "boom.nut"); } catch (const ScriptException& error) { caught = true; - // The VM error hook reported this uncaught error, so the Runtime sink - // must have skipped it (reported()==true); the exception still carries - // the live location + stack. - CHECK(error.reported()); + // The VM hook only captures the live stack when Runtime is bound; + // the Runtime error sink reports it once after unwind. + CHECK(!error.reported()); CHECK(error.hasLocation()); CHECK(!error.stackTrace().empty()); } diff --git a/test/voxel.cpp b/test/voxel.cpp index 44224430..de49067e 100644 --- a/test/voxel.cpp +++ b/test/voxel.cpp @@ -15,10 +15,12 @@ #include "voxel/VoxelWorld.h" #include +#include #include #include #include #include +#include #include using namespace eve::voxel; @@ -2479,6 +2481,42 @@ TEST_CASE("voxel.world.streamAround_evicts_and_creates") { CHECK_EQ(s4.evicted, 0); } +TEST_CASE("voxel.world.streamAround_cache_keeps_recent_chunks") { + VoxelWorld world; + world.setStreamCacheChunks(4); + + StreamStats s1 = world.streamAround(0, 0, 0, 2); + CHECK_EQ(s1.created, 33); + CHECK_EQ(world.getChunkCount(), 33); + + StreamStats s2 = world.streamAround(1, 0, 0, 2); + CHECK_EQ(s2.evicted, 0); + CHECK(world.hasChunk(0, 0, 0)); + CHECK(world.getChunkCount() >= 33); + + StreamStats s3 = world.streamAround(10, 0, 0, 2); + CHECK(!world.hasChunk(0, 0, 0)); + CHECK(world.hasChunk(10, 0, 0)); + CHECK(s3.evicted > 0); +} + +TEST_CASE("voxel.world.streamAround_create_budget_spreads_across_calls") { + VoxelWorld world; + world.setTerrainParams(42, 1, 2, 3, 8.f, 14.f, 1.f / 32.f); + + StreamStats first = world.streamAround(0, 0, 0, 2, {}, 4); + CHECK(first.created + world.getInflightStreamCount() >= 4); + CHECK(world.getChunkCount() <= 4); + CHECK_EQ(first.pending + world.getChunkCount(), 33); + + for (int i = 0; i < 400 && world.getChunkCount() < 33; ++i) { + world.streamAround(0, 0, 0, 2, {}, 8); + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + CHECK_EQ(world.getChunkCount(), 33); + CHECK_EQ(world.getInflightStreamCount(), 0); +} + TEST_CASE("voxel.world.streamTerrain_and_persist") { VoxelWorld world; world.setTerrainParams(42, 1, 2, 3, 8.f, 14.f, 1.f / 32.f);