Monorepo for Aesthetic.Computer aesthetic.computer
core xbox ENGINE.md
6.9 kB
Markdown
at main

Oskiewar engine: the game/renderer split #

Written 2026-09-26 from four read-only surveys of xbox/live/oskiewar.js (day branch, 21.5k lines) and xbox/native-bios/ (App.cpp, QuickJsEngine.cpp, ScenePrimitives.inc). Numbers are Series X AC_NATIVE_PROFILE hostJsMs unless noted.

The problem in one line #

On the console the game's JavaScript runs interpreted (QuickJS, no JIT) and that JavaScript is the whole frame: ~24 ms in the freeskate park, 60 fps needs <16. Render CPU is 0.7 ms; the GPU is idle. Every frame the JS re-tessellates ~2,100 faces (Bézier limbs, cloth skirts, hair fans, quad meshes, capsule edges) and hands them over one call at a time.

The emitTriangle comment (batching a Float32Array cost 5.7 ms where per-face calls cost 1.8) is right and points the wrong way: in QuickJS a typed-array element write costs ~0.23 µs and a 12-arg C call ~0.85 µs, so the count of numbers crossing is the cost, whichever side writes them. 2,100 faces are 25,000 numbers a frame. A fighter's pose is ~130. Only designs where JS sends O(objects) and C++ produces the faces get under the 1.8 ms floor.

The split #

Game (one JS source; deterministic; 331 tests): input, gameSim/netTick, rollback (netSnapshot/netRestore/netStateHash), replay capture, the world-space pose (buildRunnerWorldGeometry — it is also the hitbox source, so it never leaves JS), the LOD tier decision (figureLod), the camera solve, and the order things are drawn in (gamePaint).

Renderer (C++ on the console; a JS reference everywhere else): everything from "world-space pose + camera" down — projection, clipping, capsule/disc/ ribbon fans, skeleton skinning, outfit, face, terrain strips, quad-mesh lighting, HUD glyph fanning, post effects.

The interface is retained handles plus small per-frame payloads:

// once per map / per look
const mesh  = meshUpload(verticesF32, facesF32, capsulesF32)   // → int
const style = figureStyle(jsonString)                          // → int
// per frame
meshDraw(mesh, cameraF32 /* the persistent 27-float buffer */)
figure(style, pose /* the JS pose object; C++ walks it by atom */, state, tier, depth)
postEffects(focusY, band, feather, tiltPx, motionX, motionY)   // shipped, 1.0.0.48

Rules that keep it one game:

  • The renderer returns nothing the game reads.
  • paint never writes a sim object. Render-only fields (lodTier, displayFps, interpolation) live in WeakMaps or a frame-state snapshot, not on player (players ride structuredClone into net snapshots).
  • Nothing reachable from gameSim reads runtime().monotonicUs, cameraDoll, projectPoint or viewWidth() — a wall clock inside the pose would give two seats different hitboxes.
  • Every native call is additive and levelled: native advertises capabilities().sceneApi = N; JS resolves the level once at boot (no typeof on hot paths) and keeps its level-(N−1) path. Level 0 is the JS reference, which is what the web, the Mac shell and the tests run.
  • Native changes bump Package.appxmanifest Version every time — the Device Portal keeps the package it has when the version matches.

Where the frame goes (park, five figures) #

phase ms why
figures (drawRunner ×5) 8 ~514 native calls per pastel skater; skirt cloth, ponytail, face, curved limbs, bow all tessellated in JS
sim 7.5 poses built 9–12×/frame (melee, balls, camera, paint, inset); updateBall 2.4, resolveMelee 1.2
park meshes 3.4 + 2.1 + 1.5 meshes are cached but their ~120 edge capsules are re-projected in JS every frame; sceneMesh re-validates and heap-allocates per face
P1 inset 4.2 every park mesh re-sent through sceneMesh a second time, unculled (the bounds check reads a field only the web path computes)
HUD text 1.6 strings rebuilt per frame, DirectWrite layout per string per frame

Migration, in order of ms per day #

step lane work saves
0 JS cache runtime()/capabilities() per sim and paint; hoist typeof to constants; one pose memo per tick shared by melee, balls, camera, paint and inset (keyed on the sim tick, never the wall clock); one combat-box cache per tick; render fields off player; ac() at 1 Hz; HUD strings at 10 Hz 3–4 ms
1 native meshUpload/meshDraw/meshFree: validate, AABB, pre-light once at upload; capsules folded in and fanned after projection; stack clipping; the inset draws the same handles with its own camera; hall draws retained even with roof shards ~2.5 ms
2 native figureStyle + figure: tiers 1–3, tier-0 flat body, curved chains, hair cap, outfit minus skirt, trio face, scars, strobe. JS keeps ponytail/skirt/bow/items/shield/glyphs for now. Split drawRunner into figureRecord → figureNative | figureJs ~1.2–1.4 of 1.6 ms per figure
3 native sceneDraw(camera, viewMask) for both views; meshDrawAt for rigid props (a skateboard = 7 calls, not ~110); figureInstance with ponytail/skirt Verlets in C++; crowd batch ~3.5 + ~2 ms
4 native terrain strips by handle for the outdoor maps; native glyph-run cache; sceneMesh per-face allocs gone ~3 ms outdoor
5 web ship scene3d-webgl.mjs + the reference module so "web = reference" is checked, not hoped; Mac gains triangles3d/figure via a C shim 0 console ms

Each step is measured the same way: dev-deploy, read AC_NATIVE_PROFILE median over 20 lines, screenshot with xbox/tools/live.mjs screenshot.

Tests #

  • The harness (createFight) stubs triangle3d and never defines capsule3d/disc3d/figure/meshDraw, so all 120 triangle-observing tests keep exercising the JS reference unchanged.
  • Add scene-level stubs that record {kind, handle, tier} and assert semantic counts (five figures at tier 1, one mesh) — the countPoseBuilds pattern.
  • Golden cross-check: quickjs_engine_smoke.cpp dumps the native triangle stream for one canonical pose/mesh; a Node test replays the reference and diffs. This is the test that would have caught SceneDisc (5 ring tiers) already differing from discRingFor (6 tiers).
  • Source-shape gate: REQUIRED_SCENE_API in oskiewar.js ≤ the sceneApi literal in App.cpp, so oskiewar-release.mjs cannot ship a JS that needs a level the console does not advertise.

Rejected #

  • GPU instancing as the fix. Building the instance list in JS costs the same as the calls it replaces. It pays only as a cheaper sink under a C++ builder, or for shared props.
  • A retained angle skeleton with C++ forward kinematics. The rig is ~450 lines of positional, terrain-probing game logic and the hitbox source; a positional pose is the same wire size as angles.
  • Submitting the inset on alternate frames. Nothing is retained on the GPU between frames; it would flicker.