diff --git a/xbox/live/oskiewar.js b/xbox/live/oskiewar.js index 738951ae96..d5d06f96db 100644 --- a/xbox/live/oskiewar.js +++ b/xbox/live/oskiewar.js @@ -5792,6 +5792,9 @@ function restartAfterClientError() { bullets.length = 0; grenades.length = 0; try { telemetry("SHELL", "error->restart"); } catch (_) {} + // A host that can rebuild the runtime does: booting again in place kept + // whatever filled the heap, and a console looped on the same error. + if (typeof pieceReboot === "function") { pieceReboot(); return; } boot(); } @@ -10621,6 +10624,9 @@ function deathOrbitShot(target, width, progress) { let poolCameraYaw = 0, poolCameraDip = 0, poolCameraReturnYaw = null; let poolIdleSeconds=0,poolIdleClose=0; +// A rider's lens sits behind the heading: it snaps there on mounting and +// swings back after the right stick lets go, so steering reads as forward. +let poolLookIdle=0,poolCameraRiding=false; // Ground-plane movement follows the view the player actually sees, including // the drawing camera's transition. Stick diagonals have the same top speed. function poolMoveVector(held, pad) { @@ -10641,6 +10647,7 @@ function updateLookInput(x,y,orbit,dt) { const axis=value=>Math.abs(value)>.12?Math.sign(value)*clamp((Math.abs(value)-.12)/.88,0,1):0; if(poolOnly()&&freeskateActive()) { const precision=players[0].aiming?.5:playerCameraZoom<.5?.6:1; + poolLookIdle=axis(x)||orbit?.yaw?0:poolLookIdle+dt; poolCameraYaw-=axis(x)*dt*2.4*precision; poolCameraYaw+=orbit?.yaw||0; poolCameraYaw=Math.atan2(Math.sin(poolCameraYaw),Math.cos(poolCameraYaw)); @@ -10654,6 +10661,13 @@ function updateLookInput(x,y,orbit,dt) { } function trackPoolFreeCamera(p,dt) { const drawing=p.chalkDrawing,zoom=playerCameraZoom*(p.aiming?.72:1); + const riding=!!(p.skateboard||p.goKart)&&!drawing,heading=p.poolYaw||0; + if(riding&&!poolCameraRiding)poolCameraYaw=heading; + else if(riding&&poolLookIdle>.8){ + const error=Math.atan2(Math.sin(heading-poolCameraYaw),Math.cos(heading-poolCameraYaw)); + poolCameraYaw+=error*(1-Math.exp(-dt*4)); + } + poolCameraRiding=riding; const pitch=clamp(.72-playerCameraPitch,-.3,1.48); const radius=(drawing?620:680)*zoom,flat=Math.cos(pitch)*radius; const shoulder=85*Math.min(1,zoom); @@ -22725,7 +22739,7 @@ function desertGrid(){ const paintingCanvasColor=[174,175,178]; function paintingBounds(){return {left:gridLeft+720,right:gridLeft+gridWidth-720,near:worldNear+720,far:worldFar-720};} function onPaintingCanvas(x,z){const b=paintingBounds();return x>=b.left&&x<=b.right&&z>=b.near&&z<=b.far;} -function paintingLavaY(){return parkDeckY+260;} +function paintingLavaY(){return parkDeckY+900;} function resetPaintingFall(p,now){ if(!poolPainting||onPaintingCanvas(p.x,p.z||0)||p.yworldQuad({x:l,y:h,z:n},{x:r,y:h,z:n},{x:r,y:h,z:f},{x:l,y:h,z:f},color); - // The canvas has thickness and an unrailed edge; lava sits below it. + // The canvas has thickness and an unrailed edge; lava sits well below it. + // The glowing tiles float a hand above the dark sheet: one unit apart the + // console's depth buffer cannot tell them apart and the two flicker. quad(b.left-m,b.near-m,b.right+m,b.far+m,lava,[151,43,23]); for(let z=b.near-m;zon_box = [this](const ac::xbox::Rect& rect) { m_frameRects.push_back(rect); }; m_graphics->on_line = [this](const ac::xbox::Line& line) { m_frameLines.push_back(line); }; m_graphics->on_triangle = [this](const ac::xbox::Triangle& triangle) { - if (m_frameTriangles.size() < kMaxTriangles) m_frameTriangles.push_back(triangle); + // The HUD is authored last, so a full world used to drop it whole (the + // debug pad in the park). World triangles stop short of a reserve that + // only overlay triangles (z <= -1.46, see DrawGpuTriangles) may use. + const bool overlay = triangle.z1 <= -1.46f && triangle.z2 <= -1.46f && triangle.z3 <= -1.46f; + const std::size_t cap = overlay ? kMaxTriangles : kMaxTriangles - kOverlayTriangleReserve; + if (m_frameTriangles.size() < cap) m_frameTriangles.push_back(triangle); else ++m_frameTrianglesDropped; }; m_graphics->on_textured_triangle = [this](const ac::xbox::TexturedTriangle& triangle) { @@ -554,7 +559,12 @@ public: RefreshAcData(true); RefreshNetworkClock(true); m_engine = std::make_unique(); - m_supervisor = std::make_unique(*m_engine); + // The game is ~1.5 MB of source and a long park session holds a lot of + // state; 32 MB ran out after a few hours and every restart inside the + // same runtime failed again. The Series X has room to spare. + JsLimits limits; + limits.max_heap_bytes = 128 * 1024 * 1024; + m_supervisor = std::make_unique(*m_engine, limits); // The game ships inside the signed package, next to the shaders and the // fonts. kSmokePiece is the fallback for a package built without it, not // the thing players are meant to get. @@ -3154,7 +3164,7 @@ private: unsigned m_frameHeight = 0; unsigned m_frameBlurRadius = 0; static constexpr std::size_t kMaxSystemDraws = 128; - static constexpr std::size_t kMaxTriangles = 8192; + static constexpr std::size_t kMaxTriangles = 8192, kOverlayTriangleReserve = 1536; static constexpr std::size_t kMaxTexturedTriangles = 2048; static constexpr std::size_t kMaxDecalTriangles = 8192; static constexpr std::size_t kMaxSprites = 512; diff --git a/xbox/native-bios/QuickJsEngine.cpp b/xbox/native-bios/QuickJsEngine.cpp index e2e3a97e21..77094027d5 100644 --- a/xbox/native-bios/QuickJsEngine.cpp +++ b/xbox/native-bios/QuickJsEngine.cpp @@ -20,7 +20,7 @@ struct RetainedMesh; // ScenePrimitives.inc // Meshes the piece uploads once and draws by handle; owned by the piece, so // they live exactly as long as its JS context. struct SceneStore { std::vector> meshes; }; -struct CallScope { Api* api; SceneStore scene; }; +struct CallScope { Api* api; SceneStore scene; bool rebootRequested = false; }; #include "ScenePrimitives.inc" bool ValidOskiewarMatchId(std::string_view value) { @@ -604,6 +604,15 @@ JSValue Blur(JSContext* context, JSValueConst, int argc, JSValueConst* argv) { return JS_UNDEFINED; } +// The piece's own error restart boots again inside the same runtime, so a +// heap that ran out stays out. pieceReboot asks for a fresh runtime from the +// same source, built once the current callback returns. +JSValue PieceReboot(JSContext* context, JSValueConst, int, JSValueConst*) { + auto* scope = static_cast(JS_GetContextOpaque(context)); + if (scope) scope->rebootRequested = true; + return JS_UNDEFINED; +} + JSValue Telemetry(JSContext* context, JSValueConst, int argc, JSValueConst* argv) { auto* scope = static_cast(JS_GetContextOpaque(context)); if (!scope || !scope->api || !scope->api->telemetry || argc < 1) return JS_UNDEFINED; @@ -991,10 +1000,14 @@ JSValue DiscPhoto(JSContext* context, JSValueConst, int argc, JSValueConst* argv class QuickJsPiece final : public JsPiece { public: QuickJsPiece(const PieceBundle& bundle, const JsLimits& limits, std::string& error) - : slug_(bundle.slug), version_(bundle.version) { + : slug_(bundle.slug), version_(bundle.version), source_(bundle.source), + heapLimit_(limits.max_heap_bytes) { + Create(error); + } + void Create(std::string& error) { runtime_ = JS_NewRuntime(); if (!runtime_) { error = "JS_NewRuntime failed"; return; } - JS_SetMemoryLimit(runtime_, limits.max_heap_bytes); + JS_SetMemoryLimit(runtime_, heapLimit_); JS_SetMaxStackSize(runtime_, 1024 * 1024); context_ = JS_NewContext(runtime_); if (!context_) { error = "JS_NewContext failed"; return; } @@ -1042,6 +1055,7 @@ class QuickJsPiece final : public JsPiece { JS_SetPropertyStr(context_, global, "stampPainting", JS_NewCFunction(context_, StampPainting, "stampPainting", 4)); JS_SetPropertyStr(context_, global, "blur", JS_NewCFunction(context_, Blur, "blur", 1)); JS_SetPropertyStr(context_, global, "telemetry", JS_NewCFunction(context_, Telemetry, "telemetry", 2)); + JS_SetPropertyStr(context_, global, "pieceReboot", JS_NewCFunction(context_, PieceReboot, "pieceReboot", 0)); JS_SetPropertyStr(context_, global, "gameSignal", JS_NewCFunction(context_, GameSignal, "gameSignal", 4)); JS_SetPropertyStr(context_, global, "saveReplay", JS_NewCFunction(context_, SaveReplay, "saveReplay", 1)); JS_SetPropertyStr(context_, global, "publishLive", JS_NewCFunction(context_, PublishLive, "publishLive", 2)); @@ -1059,19 +1073,37 @@ class QuickJsPiece final : public JsPiece { JS_SetPropertyStr(context_, global, "discCopy", JS_NewCFunction(context_, DiscCopy, "discCopy", 0)); JS_SetPropertyStr(context_, global, "discPhoto", JS_NewCFunction(context_, DiscPhoto, "discPhoto", 4)); JS_FreeValue(context_, global); - JSValue result = JS_Eval(context_, bundle.source.data(), bundle.source.size(), - bundle.slug.c_str(), JS_EVAL_TYPE_GLOBAL); + JSValue result = JS_Eval(context_, source_.data(), source_.size(), + slug_.c_str(), JS_EVAL_TYPE_GLOBAL); if (JS_IsException(result)) error = ExceptionText(); else valid_ = true; JS_FreeValue(context_, result); } - ~QuickJsPiece() override { if (context_) JS_FreeContext(context_); if (runtime_) JS_FreeRuntime(runtime_); } + ~QuickJsPiece() override { Destroy(); } + void Destroy() { + if (context_) JS_FreeContext(context_); + if (runtime_) JS_FreeRuntime(runtime_); + context_ = nullptr; runtime_ = nullptr; valid_ = false; + } bool valid() const { return valid_; } std::string_view slug() const noexcept override { return slug_; } std::string_view version() const noexcept override { return version_; } void boot(Api& api) override { Call("boot", api); } - void sim(Api& api) override { Call("sim", api); } - void paint(Api& api) override { Call("paint", api); } + void sim(Api& api) override { Call("sim", api); RebootIfRequested(api); } + void paint(Api& api) override { + Call("paint", api); + // One heap reading a minute, so a long session shows what it grew to. + if (++paints_ % 1800 == 0 && api.telemetry) { + JSMemoryUsage usage{}; + JS_ComputeMemoryUsage(runtime_, &usage); + api.telemetry("JS_HEAP used=" + std::to_string(usage.memory_used_size) + + " limit=" + std::to_string(heapLimit_) + + " objects=" + std::to_string(usage.obj_count) + + " strings=" + std::to_string(usage.str_count) + + " arrays=" + std::to_string(usage.array_count)); + } + RebootIfRequested(api); + } void leave(Api& api) override { Call("leave", api); } void act(Api& api, const Event& event) override { scope_.api = &api; @@ -1098,12 +1130,24 @@ class QuickJsPiece final : public JsPiece { } JS_FreeValue(context_, function); JS_FreeValue(context_, global); scope_.api = nullptr; } + void RebootIfRequested(Api& api) { + if (!scope_.rebootRequested) return; + scope_.rebootRequested = false; + Destroy(); + scope_.scene = {}; + std::string error; + Create(error); + if (api.telemetry) api.telemetry(error.empty() ? "JS_REBOOT fresh runtime" : "JS_REBOOT failed " + error); + if (!valid_) throw std::runtime_error("piece reboot failed: " + error); + Call("boot", api); + } std::string ExceptionText() { JSValue exception = JS_GetException(context_); const char* text = JS_ToCString(context_, exception); std::string result = text ? text : "JavaScript exception"; if (text) JS_FreeCString(context_, text); JS_FreeValue(context_, exception); return result; } - std::string slug_, version_; JSRuntime* runtime_ = nullptr; JSContext* context_ = nullptr; + std::string slug_, version_, source_; std::size_t heapLimit_ = 0, paints_ = 0; + JSRuntime* runtime_ = nullptr; JSContext* context_ = nullptr; CallScope scope_{nullptr}; bool valid_ = false; }; } diff --git a/xbox/native-bios/tests/quickjs_engine_smoke.cpp b/xbox/native-bios/tests/quickjs_engine_smoke.cpp index 986d4226b0..92b90fe6b6 100644 --- a/xbox/native-bios/tests/quickjs_engine_smoke.cpp +++ b/xbox/native-bios/tests/quickjs_engine_smoke.cpp @@ -1,3 +1,5 @@ +#include +#include #include "ac/chord_sound.hpp" #include "QuickJsEngine.hpp" #include "ac/relay_endpoint.hpp" @@ -144,6 +146,24 @@ int main() { }function sim(){}function paint(){} )JS", "test"}, {}, error); assert(scene);{GraphicsProbe sg;SoundProbe ss;Api sa{{},{},{},{},sg,ss,{}};scene->boot(sa);assert(sg.triangles>2);} + // pieceReboot rebuilds the runtime from the same source: module state starts + // over and boot runs once more, after the callback that asked returns. + auto reboot = engine.compile({"reboot", "test", R"JS( + let boots = 0, paints = 0; + function boot() { boots++; } + function sim() {} + function paint() { + paints++; + if (boots !== 1 || paints > 1) throw Error('state survived the reboot'); + pieceReboot(); + } + )JS", "test"}, {}, error); + assert(reboot); + { GraphicsProbe rg; SoundProbe rs; Api ra{{},{},{},{},rg,rs,{}}; + std::vector lines; + ra.telemetry = [&](std::string_view line) { lines.emplace_back(line); }; + reboot->boot(ra); reboot->paint(ra); reboot->paint(ra); + assert(std::count(lines.begin(), lines.end(), std::string("JS_REBOOT fresh runtime")) == 2); } auto network = engine.compile({"net", "test", R"JS( function boot() { if (!oskiewarNetSend('ow-lantest924', '{}')) throw Error('send'); diff --git a/xbox/tools/live.mjs b/xbox/tools/live.mjs index 895be4b237..ff3f96be7c 100644 --- a/xbox/tools/live.mjs +++ b/xbox/tools/live.mjs @@ -137,6 +137,17 @@ function launch() { console.log(`launched ${item.PackageFullName}`); } +// Stopping the app drops its QuickJS runtime, which the piece's own +// error->restart cannot: that boots again inside the same exhausted heap. +function stop() { + const item = installed(); + const query = new URLSearchParams({ + package: Buffer.from(item.PackageFullName).toString("base64"), + }); + curl(["-u", autoAuth, "-X", "DELETE", `${base}/api/taskmanager/app?${query}`]); + console.log(`stopped ${item.PackageFullName}`); +} + function publish(sourcePath) { if (!sourcePath) throw new Error("usage: xbox-live publish "); const absolute = resolve(sourcePath); @@ -376,6 +387,8 @@ async function main() { else if (command === "install") install(argument, rest); else if (command === "prune") prune(); else if (command === "launch") launch(); + else if (command === "stop") stop(); + else if (command === "restart") { stop(); launch(); } else if (command === "publish") publish(argument); else if (command === "shader") publishShader(argument); else if (command === "shader-reset") publishShader(null, true); @@ -386,7 +399,7 @@ async function main() { else if (command === "deploy") { publish(argument); launch(); logs("20"); } else if (command === "hot-deploy") await hotDeploy(argument); else if (command === "deploy-kidlisp") await deployKidLisp(argument); - else throw new Error("commands: status | install [deps...] | prune | launch | publish | shader | shader-reset | logs [lines] | frames [output.json] | screenshot [output.png] | video [seconds] [output.mp4] | deploy | hot-deploy | deploy-kidlisp <$code>"); + else throw new Error("commands: status | install [deps...] | prune | launch | stop | restart | publish | shader | shader-reset | logs [lines] | frames [output.json] | screenshot [output.png] | video [seconds] [output.mp4] | deploy | hot-deploy | deploy-kidlisp <$code>"); } try { await main(); } catch (error) { console.error(error.message); process.exit(1); }