@origingame/engine 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/dist/web/origin-engine.d.ts +8 -5
  2. package/dist/web/origin-engine.js +39 -19
  3. package/dist/web/origin-engine_bg.wasm +0 -0
  4. package/dist/web/origin-engine_bg.wasm.d.ts +6 -5
  5. package/dist/web-publish/origin-engine-lanes.js +49 -29
  6. package/dist/web-publish/origin-engine-lanes_bg.wasm +0 -0
  7. package/dist/web-publish/origin-engine.d.ts +8 -5
  8. package/dist/web-publish/origin-engine.js +39 -19
  9. package/dist/web-publish/origin-engine_bg.wasm +0 -0
  10. package/dist/web-publish/origin-engine_bg.wasm.d.ts +6 -5
  11. package/dist/web-publish-2d/origin-engine-lanes.js +49 -29
  12. package/dist/web-publish-2d/origin-engine-lanes_bg.wasm +0 -0
  13. package/dist/web-publish-2d/origin-engine.d.ts +8 -5
  14. package/dist/web-publish-2d/origin-engine.js +39 -19
  15. package/dist/web-publish-2d/origin-engine_bg.wasm +0 -0
  16. package/dist/web-publish-2d/origin-engine_bg.wasm.d.ts +6 -5
  17. package/knowledge/looks/catalogue.json +123 -2
  18. package/knowledge/package.json +1 -1
  19. package/knowledge/scaffold/origin-engine.json +2 -2
  20. package/knowledge/scaffold/template/package.json +1 -1
  21. package/knowledge/scaffold/template/project.ogt +1 -1
  22. package/knowledge/scripts/validate.ts +69 -6
  23. package/knowledge/skills/og-audio/SKILL.md +31 -5
  24. package/knowledge/skills/og-build/SKILL.md +44 -4
  25. package/knowledge/skills/og-core/SKILL.md +4 -3
  26. package/knowledge/skills/og-feel/SKILL.md +9 -1
  27. package/knowledge/skills/og-game/SKILL.md +199 -16
  28. package/knowledge/skills/og-platform-sdk/SKILL.md +6 -0
  29. package/knowledge/skills/og-taste/SKILL.md +7 -0
  30. package/knowledge/skills/og-ui/SKILL.md +24 -0
  31. package/knowledge/wiki/ai.md +20 -20
  32. package/knowledge/wiki/anim.md +526 -60
  33. package/knowledge/wiki/atmosphere.md +13 -13
  34. package/knowledge/wiki/audio.md +160 -20
  35. package/knowledge/wiki/camera.md +13 -13
  36. package/knowledge/wiki/commands.md +11 -11
  37. package/knowledge/wiki/debug.md +13 -13
  38. package/knowledge/wiki/game.md +8 -8
  39. package/knowledge/wiki/i18n.md +7 -7
  40. package/knowledge/wiki/index.md +25 -1
  41. package/knowledge/wiki/input.md +47 -47
  42. package/knowledge/wiki/load.md +76 -50
  43. package/knowledge/wiki/math.md +102 -102
  44. package/knowledge/wiki/nav.md +34 -34
  45. package/knowledge/wiki/net.md +10 -10
  46. package/knowledge/wiki/path.md +14 -14
  47. package/knowledge/wiki/physics.md +111 -100
  48. package/knowledge/wiki/platform.md +44 -44
  49. package/knowledge/wiki/probe.md +86 -86
  50. package/knowledge/wiki/reflect.md +17 -17
  51. package/knowledge/wiki/render.md +245 -176
  52. package/knowledge/wiki/rng.md +14 -14
  53. package/knowledge/wiki/shapes.md +26 -26
  54. package/knowledge/wiki/stream.md +15 -15
  55. package/knowledge/wiki/system.md +8 -8
  56. package/knowledge/wiki/systems.md +2 -2
  57. package/knowledge/wiki/terrain.md +19 -19
  58. package/knowledge/wiki/tilemap.md +13 -13
  59. package/knowledge/wiki/time.md +4 -4
  60. package/knowledge/wiki/timeline.md +14 -14
  61. package/knowledge/wiki/transform.md +8 -8
  62. package/knowledge/wiki/tween.md +17 -17
  63. package/knowledge/wiki/ui.md +243 -243
  64. package/knowledge/wiki/video.md +5 -5
  65. package/knowledge/wiki/world.md +36 -36
  66. package/package.json +4 -4
  67. package/schema/api.schema.json +639 -11
  68. package/shell/page.ts +4 -0
  69. package/src/generated/abi.ts +3 -0
  70. package/src/index.ts +11 -0
  71. package/src/node/device.ts +5 -2
  72. package/src/node/polish.ts +34 -15
  73. package/src/node/run.ts +23 -6
  74. package/src/runtime.ts +1 -0
  75. package/src/web/gl-state.ts +907 -0
  76. package/src/web/worker.ts +6 -0
  77. package/types/bindings.json +823 -349
  78. package/types/index.d.ts +650 -15
package/shell/page.ts CHANGED
@@ -600,6 +600,10 @@ export async function play(): Promise<PlayHandle> {
600
600
  pausedByPlayer = m.paused;
601
601
  if (m.started && phase === "gate") start();
602
602
  };
603
+ // The usual case with a real Player: it answered the shell's hello while the engine loaded,
604
+ // and the handler above was not installed yet (the first one only released og.platform).
605
+ // What that hello says applies now: its rooms, and a Play link's room through them.
606
+ if (!page && player.hosted) playerEvents.hello({ started: player.started, paused: player.paused, capabilities: player.capabilities });
603
607
  playerEvents.start = () => {
604
608
  byPlayer = true;
605
609
  if (phase === "gate") start();
@@ -397,6 +397,8 @@ export interface HostAbi<A, V> {
397
397
  og_anim_humanoid(world: number, desc: string | undefined): string;
398
398
  /** `og.anim.retarget` */
399
399
  og_anim_retarget(world: number, desc: string | undefined): string;
400
+ /** `og.anim.mirror` */
401
+ og_anim_mirror(world: number, desc: string | undefined): string;
400
402
  /** `og.anim.rig2dInfo` */
401
403
  og_anim_rig2d_info(world: number, rig: number): string;
402
404
  /** `og.anim.skeleton2dBoxes` */
@@ -978,6 +980,7 @@ export const SYMBOLS: readonly string[] = [
978
980
  "og_anim_graph",
979
981
  "og_anim_humanoid",
980
982
  "og_anim_retarget",
983
+ "og_anim_mirror",
981
984
  "og_anim_rig2d_info",
982
985
  "og_anim_skeleton2d_boxes",
983
986
  "og_tween_to",
package/src/index.ts CHANGED
@@ -72,6 +72,7 @@ export const LineMode = ((value = "strip" as Api.LineMode, options) => spec("Lin
72
72
  export const DofQuality = ((value = "low" as Api.DofQuality, options) => spec("DofQuality", value, options)) as typeof Api.DofQuality;
73
73
  export const ReflectionProbeMode = ((value = "baked" as Api.ReflectionProbeMode, options) => spec("ReflectionProbeMode", value, options)) as typeof Api.ReflectionProbeMode;
74
74
  export const SpriteBlend = ((value = "alpha" as Api.SpriteBlend, options) => spec("SpriteBlend", value, options)) as typeof Api.SpriteBlend;
75
+ export const SpriteFacing = ((value = "none" as Api.SpriteFacing, options) => spec("SpriteFacing", value, options)) as typeof Api.SpriteFacing;
75
76
  export const StripBlend = ((value = "additive" as Api.StripBlend, options) => spec("StripBlend", value, options)) as typeof Api.StripBlend;
76
77
  export const Palette = ((value = 0 as Api.Palette, options) => spec("Palette", value, options)) as typeof Api.Palette;
77
78
  export const DitherPattern = ((value = "none" as Api.DitherPattern, options) => spec("DitherPattern", value, options)) as typeof Api.DitherPattern;
@@ -79,6 +80,11 @@ export const StyliseSpace = ((value = "screen" as Api.StyliseSpace, options) =>
79
80
  export const TextureArray = ((value = 0 as Api.TextureArray, options) => spec("TextureArray", value, options)) as typeof Api.TextureArray;
80
81
  export const Asset = ((value = 0 as Api.Asset, options) => spec("Asset", value, options)) as typeof Api.Asset;
81
82
  export const Sound = ((value = 0 as Api.Sound, options) => spec("Sound", value, options)) as typeof Api.Sound;
83
+ export const Font = ((value = 0 as Api.Font, options) => spec("Font", value, options)) as typeof Api.Font;
84
+ export const Style = ((value = 0 as Api.Style, options) => spec("Style", value, options)) as typeof Api.Style;
85
+ export const Look = ((value = 0 as Api.Look, options) => spec("Look", value, options)) as typeof Api.Look;
86
+ export const Shader = ((value = 0 as Api.Shader, options) => spec("Shader", value, options)) as typeof Api.Shader;
87
+ export const Rig = ((value = 0 as Api.Rig, options) => spec("Rig", value, options)) as typeof Api.Rig;
82
88
  export const SkeletonAsset = ((value = 0 as Api.SkeletonAsset, options) => spec("SkeletonAsset", value, options)) as typeof Api.SkeletonAsset;
83
89
  export const ClipAsset = ((value = 0 as Api.ClipAsset, options) => spec("ClipAsset", value, options)) as typeof Api.ClipAsset;
84
90
  export const GraphAsset = ((value = 0 as Api.GraphAsset, options) => spec("GraphAsset", value, options)) as typeof Api.GraphAsset;
@@ -99,6 +105,10 @@ export const BlendshapeSource = ((value = "component" as Api.BlendshapeSource, o
99
105
  export const IkSolver = ((value = "twoBone" as Api.IkSolver, options) => spec("IkSolver", value, options)) as typeof Api.IkSolver;
100
106
  export const IkSpace = ((value = "world" as Api.IkSpace, options) => spec("IkSpace", value, options)) as typeof Api.IkSpace;
101
107
  export const AudioBus = ((value = "master" as Api.AudioBus, options) => spec("AudioBus", value, options)) as typeof Api.AudioBus;
108
+ export const PanningModel = ((value = "equalpower" as Api.PanningModel, options) => spec("PanningModel", value, options)) as typeof Api.PanningModel;
109
+ export const AudioResampler = ((value = "linear" as Api.AudioResampler, options) => spec("AudioResampler", value, options)) as typeof Api.AudioResampler;
110
+ export const SpringSpace = ((value = "world" as Api.SpringSpace, options) => spec("SpringSpace", value, options)) as typeof Api.SpringSpace;
111
+ export const SpringColliderShape = ((value = "sphere" as Api.SpringColliderShape, options) => spec("SpringColliderShape", value, options)) as typeof Api.SpringColliderShape;
102
112
  export const Navigation = ((value = 0 as Api.Navigation, options) => spec("Navigation", value, options)) as typeof Api.Navigation;
103
113
  export const Behaviour = ((value = 0 as Api.Behaviour, options) => spec("Behaviour", value, options)) as typeof Api.Behaviour;
104
114
 
@@ -147,6 +157,7 @@ export const SOUND_STOPPED: typeof Api.SOUND_STOPPED = 1;
147
157
  export const SOUND_REMOVED: typeof Api.SOUND_REMOVED = 2;
148
158
  export const SOUND_REPLACED: typeof Api.SOUND_REPLACED = 3;
149
159
  export const SOUND_CULLED: typeof Api.SOUND_CULLED = 4;
160
+ export const SOUND_RETRIGGERED: typeof Api.SOUND_RETRIGGERED = 5;
150
161
  export const NAV_IDLE: typeof Api.NAV_IDLE = 0;
151
162
  export const NAV_MOVING: typeof Api.NAV_MOVING = 1;
152
163
  export const NAV_ARRIVED: typeof Api.NAV_ARRIVED = 2;
@@ -514,7 +514,7 @@ async function connectAndroid(wanted: string | null, port: number, scheme: strin
514
514
 
515
515
  const ELEMENT = "element-6066-11e4-a52e-4f735466cecf";
516
516
  const IPHONE_FIX =
517
- "on the iPhone turn on Settings > Apps > Safari > Advanced > Web Inspector and Remote Automation; on the Mac run `safaridriver --enable` once and pair the phone in Xcode (Window > Devices and Simulators)";
517
+ "on the iPhone turn on Settings > Apps > Safari > Advanced > Web Inspector and Remote Automation; on the Mac turn on Safari > Develop > Allow Remote Automation, run `safaridriver --enable` once and pair the phone in Xcode (Window > Devices and Simulators)";
518
518
 
519
519
  /** Safari on an iPhone over WebDriver (safaridriver). */
520
520
  async function connectIphone(udid: string | null): Promise<Driver> {
@@ -568,7 +568,10 @@ async function connectIphone(udid: string | null): Promise<Driver> {
568
568
  );
569
569
  session = s.sessionId;
570
570
  } catch (e) {
571
- return refuse(`safaridriver could not open Safari on the iPhone (${(e as Error).message.split("\n")[0]}): ${IPHONE_FIX}`);
571
+ // The whole answer: safaridriver names the device it found and why it
572
+ // could not use it on the lines after the first.
573
+ const why = (e as Error).message.split("\n").map((l) => l.trim()).filter(Boolean).join("; ");
574
+ return refuse(`safaridriver could not open Safari on the iPhone (${why}): ${IPHONE_FIX}`);
572
575
  }
573
576
  await call("POST", `/session/${session}/timeouts`, { script: 120_000, pageLoad: 120_000 });
574
577
  return {
@@ -611,7 +611,8 @@ interface PlaytestMeasures {
611
611
  hud: { gameplayShots: number; worstShare: number; worstCentre: number };
612
612
  ui: { screenChanges: number; withMotion: number; presses: number; pressesWithSound: number; motionTicks: number };
613
613
  latency: { inputs: number; answered: number; medianTicks: number; maxTicks: number; baselineShare: number; sound: number };
614
- damage: { moments: number; hitStop: number; shake: number; floating: number; sound: number; flash: number; particles: number; facts: string[] };
614
+ /** Per signal, the moments that had it; `whole`, the moments with three of the four (hit stop, shake, floating text, a sound: og-feel §7's rule); `credit`, each moment's min(3, its signals) summed, what the score reads. */
615
+ damage: { moments: number; hitStop: number; shake: number; floating: number; sound: number; flash: number; particles: number; whole: number; credit: number; facts: string[] };
615
616
  idle: { share: number };
616
617
  particles: { peakSystems: number; bursts: number };
617
618
  /** `rendered` false: no renderer (tier `none`), so render and frame time were not measured. */
@@ -757,22 +758,39 @@ function measure(rec: RunRecord, frameDir: string, frames: number, relTo: string
757
758
  const tick = rec.facts[i]!.tick;
758
759
  if (hit && (moments.length === 0 || tick - moments.at(-1)! > 3)) moments.push(tick);
759
760
  }
760
- const dmg = { moments: moments.length, hitStop: 0, shake: 0, floating: 0, sound: 0, flash: 0, particles: 0, facts: [...factKeys].slice(0, 6) };
761
+ const dmg = { moments: moments.length, hitStop: 0, shake: 0, floating: 0, sound: 0, flash: 0, particles: 0, whole: 0, credit: 0, facts: [...factKeys].slice(0, 6) };
762
+ // The four signals an impact is held to, in the order a finding names them: three of four make it whole.
763
+ const FOUR = ["hit stop", "shake", "floating text", "a sound"] as const;
764
+ const FIX: Record<(typeof FOUR)[number], string> = { "hit stop": "og-feel §3 (hit stop by impact weight)", shake: "og-feel §4 (trauma, not offsets)", "floating text": "og-feel §7 (floating text)", "a sound": "og-audio §1 (every event sounds; a hit's sound on its tick)" };
765
+ const lacking = new Map<(typeof FOUR)[number], number>(FOUR.map((k) => [k, 0]));
766
+ let short = 0;
761
767
  for (const t of moments) {
762
768
  const i = tickIndex.get(t) ?? -1;
763
- if (i < 0) continue;
764
- const w = sig.slice(Math.max(1, i - 3), Math.min(s.length, i + 9));
765
- const held = s.slice(Math.max(0, i - 3), Math.min(s.length, i + 9)).some((p) => p.hitStop > 0 || p.scale < 1);
766
- if (held || w.some((x) => x.hitStop)) dmg.hitStop++;
767
- if (w.some((x) => x.shake)) dmg.shake++;
768
- if (w.some((x) => x.floating)) dmg.floating++;
769
- if (w.some((x) => x.sfx)) dmg.sound++;
769
+ // A moment the record cannot place is short of all four.
770
+ const w = i < 0 ? [] : sig.slice(Math.max(1, i - 3), Math.min(s.length, i + 9));
771
+ const held = i >= 0 && s.slice(Math.max(0, i - 3), Math.min(s.length, i + 9)).some((p) => p.hitStop > 0 || p.scale < 1);
772
+ const has = { "hit stop": held || w.some((x) => x.hitStop), shake: w.some((x) => x.shake), "floating text": w.some((x) => x.floating), "a sound": w.some((x) => x.sfx) };
773
+ if (has["hit stop"]) dmg.hitStop++;
774
+ if (has.shake) dmg.shake++;
775
+ if (has["floating text"]) dmg.floating++;
776
+ if (has["a sound"]) dmg.sound++;
770
777
  if (w.some((x) => x.tween)) dmg.flash++;
771
778
  if (w.some((x) => x.particles)) dmg.particles++;
779
+ const n = FOUR.filter((k) => has[k]).length;
780
+ dmg.credit += Math.min(3, n);
781
+ if (n >= 3) {
782
+ dmg.whole++;
783
+ continue;
784
+ }
785
+ // Short of three: each signal it lacks is owed. A whole moment owes none,
786
+ // so a party game's bump with hit stop, shake and a sound is not asked
787
+ // for a floating number (og-feel §7: damage and score numbers).
788
+ short++;
789
+ for (const k of FOUR) if (!has[k]) lacking.set(k, lacking.get(k)! + 1);
772
790
  }
773
- if (dmg.moments > 0) {
774
- const missing = [["hit stop", dmg.hitStop, "og-feel §3 (hit stop by impact weight)"], ["shake", dmg.shake, "og-feel §4 (trauma, not offsets)"], ["floating text", dmg.floating, "og-feel §7 (floating text)"], ["a sound", dmg.sound, "og-audio §1 (every event sounds; a hit's sound on its tick)"]] as const;
775
- for (const [what, n, fix] of missing) if (n < dmg.moments) findings.push({ measure: "hit feedback", where: rec.playtest, detail: `${dmg.moments - n} of ${dmg.moments} damage moments (${dmg.facts.join(", ")}) had no ${what} within 8 ticks`, fix });
791
+ for (const k of FOUR) {
792
+ const n = lacking.get(k)!;
793
+ if (n > 0) findings.push({ measure: "hit feedback", where: rec.playtest, detail: `${n} of ${dmg.moments} damage moments (${dmg.facts.join(", ")}) had no ${k} within 8 ticks, and fewer than three of hit stop, shake, floating text and a sound (${short} short)`, fix: FIX[k] });
776
794
  }
777
795
 
778
796
  // Idle frames, particles, frame time.
@@ -844,7 +862,8 @@ function scores(ms: PlaytestMeasures[], missingKeys: number, hasLocales: boolean
844
862
  const lat = ms.filter((m) => m.latency.inputs > 0);
845
863
  const latency = lat.length === 0 ? null : Math.round(lat.reduce((n, m) => n + (m.latency.answered / m.latency.inputs) * (m.latency.medianTicks <= 1 ? 100 : m.latency.medianTicks <= 3 ? 70 : m.latency.medianTicks <= 6 ? 40 : 0), 0) / lat.length);
846
864
  const dm = sum((m) => m.damage.moments);
847
- const hitFeedback = dm === 0 ? null : Math.round((100 * sum((m) => Math.min(3, [m.damage.hitStop, m.damage.shake, m.damage.floating, m.damage.sound].filter((n) => n > 0).length) / 3 * m.damage.moments)) / dm);
865
+ // Per impact: each moment scores min(3, its signals) of 3, so three of four is 100 and no moment's lack hides behind another's signal.
866
+ const hitFeedback = dm === 0 ? null : Math.round((100 * sum((m) => m.damage.credit)) / (3 * dm));
848
867
  const gameplay = ms.filter((m) => m.hud.gameplayShots > 0);
849
868
  const hud = gameplay.length === 0 ? null : Math.min(...gameplay.map((m) => Math.min(band(m.hud.worstShare, 15, 45), band(m.hud.worstCentre, 5, 30))));
850
869
  const timed = ms.filter((m) => m.frame.rendered);
@@ -894,7 +913,7 @@ function markdown(project: string, ms: PlaytestMeasures[], sc: Scores, findings:
894
913
  L.push(`| Locale keys | ${show(sc.locale)} | string tables without missing keys (og cook) |`);
895
914
  L.push(`| **Feel** | **${show(sc.feel)}** | mean of the feel rows |`);
896
915
  L.push(`| Input latency | ${show(sc.latency)} | inputs answered, by the median ticks to the answer (0–1: 100) |`);
897
- L.push(`| Hit feedback | ${show(sc.hitFeedback)} | damage moments with hit stop, shake, floating text or a sound (three of four: 100) |`);
916
+ L.push(`| Hit feedback | ${show(sc.hitFeedback)} | each damage moment with three of hit stop, shake, floating text and a sound (every moment so: 100) |`);
898
917
  L.push(`| Idle frames | ${show(sc.idle)} | ticks where nothing new is seen or heard (10 %: 100, 60 %: 0) |`);
899
918
  L.push(`| Frame time | ${sc.frameTime === null && ms.length > 0 && ms.every((m) => !m.frame.rendered) ? "n/a (no renderer)" : show(sc.frameTime)} | p95 step + render (8.3 ms: 100, 33 ms: 0), worst playtest${ms.some((m) => !m.frame.rendered) ? "; no renderer: not measured" : ""} |`);
900
919
  L.push(`| Sound on input | ${show(sc.inputSound)} | inputs answered by a sound within 6 ticks |`, "");
@@ -920,7 +939,7 @@ function markdown(project: string, ms: PlaytestMeasures[], sc: Scores, findings:
920
939
  L.push(`| HUD | ${m.hud.gameplayShots} gameplay frame(s), up to ${m.hud.worstShare} % of the surface, ${m.hud.worstCentre} % of the centre |`);
921
940
  L.push(`| UI | ${m.ui.screenChanges} screen change(s), ${m.ui.withMotion} with motion; ${m.ui.motionTicks} ticks of UI motion; ${m.ui.presses} press(es), ${m.ui.pressesWithSound} with a ui-bus sound |`);
922
941
  L.push(`| Inputs | ${m.latency.inputs}, ${m.latency.answered} answered, median ${m.latency.medianTicks} ticks, max ${m.latency.maxTicks}; ${m.latency.sound} with a sound; ${m.latency.baselineShare} % of input-free ticks also change something |`);
923
- L.push(`| Damage | ${m.damage.moments} moment(s)${m.damage.facts.length ? ` from ${m.damage.facts.map((f) => `\`${f}\``).join(", ")}` : ""}: hit stop ${m.damage.hitStop}, shake ${m.damage.shake}, floating text ${m.damage.floating}, sound ${m.damage.sound}, tween ${m.damage.flash}, particles ${m.damage.particles} |`);
942
+ L.push(`| Damage | ${m.damage.moments} moment(s)${m.damage.facts.length ? ` from ${m.damage.facts.map((f) => `\`${f}\``).join(", ")}` : ""}: hit stop ${m.damage.hitStop}, shake ${m.damage.shake}, floating text ${m.damage.floating}, sound ${m.damage.sound} (three of four: ${m.damage.whole}), tween ${m.damage.flash}, particles ${m.damage.particles} |`);
924
943
  L.push(`| Idle | ${m.idle.share} % of ticks |`);
925
944
  L.push(`| Particles | peak ${m.particles.peakSystems} system(s), ${m.particles.bursts} burst(s) |`);
926
945
  if (m.frame.rendered) L.push(`| Frame (${m.frame.tier}) | step ${m.frame.stepMedianMs} ms median, ${m.frame.stepP95Ms} p95; render ${m.frame.renderMedianMs} median, ${m.frame.renderP95Ms} p95; frame p95 ${m.frame.frameP95Ms}, max ${m.frame.frameMaxMs} |`);
package/src/node/run.ts CHANGED
@@ -44,7 +44,7 @@ import { loadNativeHost, native } from "./index.ts";
44
44
  import { openAudio, type AudioSink } from "./audio.ts";
45
45
  import { nativePlatform } from "./platform.ts";
46
46
  import { Trace, unixMs, type TraceDocument } from "../trace.ts";
47
- import { beginBundle, boundHost, createEngine, fixedClock, type Engine, type GameToken } from "../runtime.ts";
47
+ import { beginBundle, boundHost, createEngine, fixedClock, type Engine, type GameToken, type Result } from "../runtime.ts";
48
48
 
49
49
  const argv = process.argv.slice(2);
50
50
  let entry = "";
@@ -298,7 +298,7 @@ if (!probing) {
298
298
  if (!advanced.ok) fail(advanced.reason);
299
299
  sink.pump();
300
300
  } else {
301
- let pendingReload: { mtimeMs: number } | null = null;
301
+ let pendingReload: { mtimeMs: number; importMs: number } | null = null;
302
302
  // Imports in flight: a reload swaps only once the last one has declared
303
303
  // everything (a second write of the bundle starts another import, whose
304
304
  // beginBundle clears the declarations the first one made).
@@ -313,9 +313,10 @@ if (!probing) {
313
313
  const mtimeMs = statSync(watchFile!).mtimeMs;
314
314
  importing++;
315
315
  try {
316
+ const started = performance.now();
316
317
  beginBundle();
317
318
  await importGame(watchFile!);
318
- pendingReload = { mtimeMs };
319
+ pendingReload = { mtimeMs, importMs: performance.now() - started };
319
320
  } catch (e) {
320
321
  process.stderr.write(`WARN reload: the bundle failed to import (${e instanceof Error ? e.message : String(e)}); the old one keeps running\n`);
321
322
  } finally {
@@ -328,13 +329,29 @@ if (!probing) {
328
329
  let last = performance.now();
329
330
  for (;;) {
330
331
  if (pendingReload && importing === 0) {
331
- const { mtimeMs } = pendingReload as { mtimeMs: number };
332
+ const { mtimeMs, importMs } = pendingReload as { mtimeMs: number; importMs: number };
332
333
  pendingReload = null;
334
+ const restoring = performance.now();
333
335
  const report = engine.probe.reload() as { ms: number; tick: number } | null;
336
+ const restored = performance.now();
334
337
  const first = engine.frame(0);
335
- const captured = render && engine.probe.capture(0, 0) !== null;
338
+ const submitted = performance.now();
339
+ let rendered = false;
340
+ if (first.rendered) {
341
+ // Measure the completed first frame, without rendering and encoding
342
+ // an unused second frame through probe.capture.
343
+ const waited: Result<void> = JSON.parse(extras.og_node_wait_gpu(engine.id));
344
+ if (!waited.ok) fail(`reload's first frame: ${waited.reason}`);
345
+ rendered = true;
346
+ }
347
+ const timings = {
348
+ importMs,
349
+ reloadMs: restored - restoring,
350
+ frameMs: submitted - restored,
351
+ gpuWaitMs: performance.now() - submitted,
352
+ };
336
353
  const latencyMs = Date.now() - mtimeMs;
337
- process.stdout.write(JSON.stringify({ event: "reload", ok: report !== null, report, tick: first.tick, captured, latencyMs: +latencyMs.toFixed(1) }) + "\n");
354
+ process.stdout.write(JSON.stringify({ event: "reload", ok: report !== null && first.ok, report, tick: first.tick, rendered, timings, latencyMs: +latencyMs.toFixed(1) }) + "\n");
338
355
  drain();
339
356
  }
340
357
  const elapsed = realtime ? performance.now() - last : tickMs;
package/src/runtime.ts CHANGED
@@ -1470,6 +1470,7 @@ export const anim = Object.freeze({
1470
1470
  graph: (world: WorldImpl, desc?: unknown) => parse(abi().og_anim_graph(world.id, json(desc))),
1471
1471
  humanoid: (world: WorldImpl, desc?: unknown) => parse(abi().og_anim_humanoid(world.id, json(desc))),
1472
1472
  retarget: (world: WorldImpl, desc?: unknown) => parse(abi().og_anim_retarget(world.id, json(desc))),
1473
+ mirror: (world: WorldImpl, desc?: unknown) => parse(abi().og_anim_mirror(world.id, json(desc))),
1473
1474
  rig2dInfo: (world: WorldImpl, rig: number) => parse(abi().og_anim_rig2d_info(world.id, rig)),
1474
1475
  skeleton2dBoxes: (world: WorldImpl, entity: number) => parse(abi().og_anim_skeleton2d_boxes(world.id, entity)),
1475
1476
  // One lookup for a flipbook's or a 2D rig's clips (an index, -1 when