@origingame/engine 0.3.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 (92) hide show
  1. package/dist/web/origin-engine.d.ts +44 -5
  2. package/dist/web/origin-engine.js +279 -19
  3. package/dist/web/origin-engine_bg.wasm +0 -0
  4. package/dist/web/origin-engine_bg.wasm.d.ts +18 -5
  5. package/dist/web-publish/origin-engine-lanes.js +289 -48
  6. package/dist/web-publish/origin-engine-lanes_bg.wasm +0 -0
  7. package/dist/web-publish/origin-engine.d.ts +44 -5
  8. package/dist/web-publish/origin-engine.js +279 -38
  9. package/dist/web-publish/origin-engine_bg.wasm +0 -0
  10. package/dist/web-publish/origin-engine_bg.wasm.d.ts +18 -5
  11. package/dist/web-publish-2d/origin-engine-lanes.js +289 -48
  12. package/dist/web-publish-2d/origin-engine-lanes_bg.wasm +0 -0
  13. package/dist/web-publish-2d/origin-engine.d.ts +44 -5
  14. package/dist/web-publish-2d/origin-engine.js +279 -38
  15. package/dist/web-publish-2d/origin-engine_bg.wasm +0 -0
  16. package/dist/web-publish-2d/origin-engine_bg.wasm.d.ts +18 -5
  17. package/knowledge/README.md +2 -1
  18. package/knowledge/guides/three-to-og.md +393 -0
  19. package/knowledge/knowledge.json +2 -1
  20. package/knowledge/looks/catalogue.json +123 -2
  21. package/knowledge/package.json +3 -2
  22. package/knowledge/scaffold/origin-engine.json +2 -2
  23. package/knowledge/scaffold/template/package.json +1 -1
  24. package/knowledge/scaffold/template/project.ogt +1 -1
  25. package/knowledge/scripts/validate.ts +209 -27
  26. package/knowledge/skills/og-audio/SKILL.md +33 -7
  27. package/knowledge/skills/og-build/SKILL.md +66 -7
  28. package/knowledge/skills/og-core/SKILL.md +24 -8
  29. package/knowledge/skills/og-feel/SKILL.md +11 -3
  30. package/knowledge/skills/og-game/SKILL.md +680 -51
  31. package/knowledge/skills/og-i18n/SKILL.md +7 -7
  32. package/knowledge/skills/og-model/SKILL.md +374 -0
  33. package/knowledge/skills/og-platform-sdk/SKILL.md +7 -1
  34. package/knowledge/skills/og-taste/SKILL.md +7 -0
  35. package/knowledge/skills/og-ui/SKILL.md +39 -8
  36. package/knowledge/wiki/ai.md +20 -20
  37. package/knowledge/wiki/anim.md +537 -63
  38. package/knowledge/wiki/atmosphere.md +13 -13
  39. package/knowledge/wiki/audio.md +160 -20
  40. package/knowledge/wiki/camera.md +13 -13
  41. package/knowledge/wiki/commands.md +11 -11
  42. package/knowledge/wiki/debug.md +13 -13
  43. package/knowledge/wiki/game.md +8 -8
  44. package/knowledge/wiki/i18n.md +7 -7
  45. package/knowledge/wiki/index.md +81 -3
  46. package/knowledge/wiki/input.md +47 -47
  47. package/knowledge/wiki/load.md +83 -57
  48. package/knowledge/wiki/math.md +102 -102
  49. package/knowledge/wiki/nav.md +34 -34
  50. package/knowledge/wiki/net.md +10 -10
  51. package/knowledge/wiki/path.md +14 -14
  52. package/knowledge/wiki/physics.md +111 -100
  53. package/knowledge/wiki/platform.md +44 -44
  54. package/knowledge/wiki/probe.md +86 -86
  55. package/knowledge/wiki/reflect.md +30 -18
  56. package/knowledge/wiki/render.md +1243 -176
  57. package/knowledge/wiki/rng.md +14 -14
  58. package/knowledge/wiki/shapes.md +727 -0
  59. package/knowledge/wiki/stream.md +15 -15
  60. package/knowledge/wiki/system.md +8 -8
  61. package/knowledge/wiki/systems.md +2 -2
  62. package/knowledge/wiki/terrain.md +24 -20
  63. package/knowledge/wiki/tilemap.md +13 -13
  64. package/knowledge/wiki/time.md +4 -4
  65. package/knowledge/wiki/timeline.md +14 -14
  66. package/knowledge/wiki/transform.md +8 -8
  67. package/knowledge/wiki/tween.md +17 -17
  68. package/knowledge/wiki/ui.md +245 -269
  69. package/knowledge/wiki/video.md +5 -5
  70. package/knowledge/wiki/world.md +36 -36
  71. package/package.json +4 -4
  72. package/schema/api.schema.json +2238 -81
  73. package/shell/page.ts +4 -0
  74. package/shell/platform.ts +5 -0
  75. package/shell/player.ts +8 -2
  76. package/shell/transport.ts +6 -0
  77. package/src/generated/abi.ts +40 -1
  78. package/src/geom.ts +260 -0
  79. package/src/index.ts +19 -1
  80. package/src/node/device.ts +5 -2
  81. package/src/node/playtest-run.ts +1 -0
  82. package/src/node/polish.ts +34 -15
  83. package/src/node/run.ts +23 -6
  84. package/src/platform-local.ts +2 -0
  85. package/src/playtest.ts +8 -0
  86. package/src/runtime.ts +88 -6
  87. package/src/ui-kit/settings.ts +9 -0
  88. package/src/web/gl-state.ts +907 -0
  89. package/src/web/shell.ts +1 -1
  90. package/src/web/worker.ts +8 -2
  91. package/types/bindings.json +1222 -453
  92. package/types/index.d.ts +1868 -315
@@ -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;
@@ -28,6 +28,8 @@ export interface PlatformHost {
28
28
  /** `player`, `local` or `none` (og.platform.info). */
29
29
  readonly host: string;
30
30
  readonly capabilities: readonly string[];
31
+ /** The Player hello's UTC milliseconds, sampled when this host attaches. */
32
+ readonly time?: number | undefined;
31
33
  /** Handle calls in order; answer every one but `telemetry`, now or later. */
32
34
  handle(calls: readonly PlatformCall[], answer: (request: number, result: PlatformAnswer) => void): void;
33
35
  }
package/src/playtest.ts CHANGED
@@ -127,6 +127,14 @@ export interface PlaytestDesc {
127
127
  readonly seed?: number;
128
128
  /** The render target, `WIDTHxHEIGHT` (default 1280x720). */
129
129
  readonly size?: string;
130
+ /**
131
+ * `action[:args];...` run once through the command channel after `start`, before the first
132
+ * tick (`og run --setup`), in every process of the playtest: the stand-ins for what a host does
133
+ * around the game, such as `cover:12,12,40,40`, the published page's Back over the play surface
134
+ * (`Viewport.covered`), so the frames lay out as the page's. Not part of `commands`: the
135
+ * published page plays a script without it and covers itself.
136
+ */
137
+ readonly setup?: string;
130
138
  /** Run the script this many times in fresh processes (default 1) and report every end hash in `run.hashes`: a determinism check. The files are the first run's. */
131
139
  readonly runs?: number;
132
140
  /**
package/src/runtime.ts CHANGED
@@ -42,6 +42,8 @@ export interface ColumnSpec {
42
42
  readonly stride: number;
43
43
  /** The stride is each field's capacity (`str(32)`), not the kind's. */
44
44
  readonly capacity?: boolean;
45
+ /** The list factory (`list(u32(), 8)`): no kind of its own, a list field's kind is its element's. */
46
+ readonly list?: boolean;
45
47
  }
46
48
 
47
49
  export const COLUMNS: ReadonlyMap<string, ColumnSpec> = new Map(
@@ -122,6 +124,8 @@ export interface FieldSpec {
122
124
  readonly options: Record<string, unknown>;
123
125
  /** A `str` field's capacity in bytes. */
124
126
  readonly capacity?: number;
127
+ /** A list field's capacity in elements (`list(u32(), 8)`); `kind` is the element's. */
128
+ readonly list?: number;
125
129
  }
126
130
 
127
131
  export interface Token {
@@ -138,6 +142,7 @@ function fieldList(fields: Readonly<Record<string, FieldSpec>>): unknown[] {
138
142
  default: spec?.default,
139
143
  options: spec?.options ?? {},
140
144
  ...(spec?.capacity === undefined ? {} : { capacity: spec.capacity }),
145
+ ...(spec?.list === undefined ? {} : { list: spec.list }),
141
146
  }));
142
147
  }
143
148
 
@@ -150,13 +155,18 @@ export function defineRecord(kind: "component" | "resource", name: string, field
150
155
  return token;
151
156
  }
152
157
 
153
- function builtinFields(fields: ReadonlyArray<{ name: string; column?: string; type?: string; default?: unknown }>): Record<string, FieldSpec> {
158
+ type DeclField = { name: string; column?: string; type?: string; default?: unknown; list?: number };
159
+
160
+ function builtinFields(fields: ReadonlyArray<DeclField>): Record<string, FieldSpec> {
154
161
  const out: Record<string, FieldSpec> = {};
155
- for (const f of fields) out[f.name] = Object.freeze({ kind: f.column ?? f.type ?? "", default: f.default, options: Object.freeze({}) });
162
+ for (const f of fields) {
163
+ const spec = { kind: f.column ?? f.type ?? "", default: f.default, options: Object.freeze({}) };
164
+ out[f.name] = Object.freeze(f.list === undefined ? spec : { ...spec, list: f.list });
165
+ }
156
166
  return out;
157
167
  }
158
168
 
159
- type Decl = { name: string; schema: string; fields: Array<{ name: string; column?: string; type?: string; default?: unknown }> };
169
+ type Decl = { name: string; schema: string; fields: Array<DeclField> };
160
170
 
161
171
  /** The engine-owned tokens on `og`, under their schema names (`og.Transform`). */
162
172
  export const BUILTINS: Readonly<Record<string, Token>> = Object.freeze(
@@ -316,7 +326,9 @@ function layoutOf(component: Token): { names: string[]; kinds: ColumnSpec[] } {
316
326
  const kind = COLUMNS.get(field.kind);
317
327
  if (!kind) throw new Error(`'${component.name}.${n}' is not a column field`);
318
328
  // A str column's stride is the field's capacity: one byte lane per byte.
319
- return kind.capacity ? { ...kind, stride: field.capacity ?? 0 } : kind;
329
+ if (kind.capacity) return { ...kind, stride: field.capacity ?? 0 };
330
+ // A list's is its element's, once for the count and once per element.
331
+ return field.list === undefined ? kind : { ...kind, stride: kind.stride * (field.list + 1) };
320
332
  });
321
333
  layouts.set(component, (l = { names, kinds }));
322
334
  }
@@ -483,6 +495,9 @@ interface RecordField {
483
495
  readonly size: number;
484
496
  readonly readonly: boolean;
485
497
  readonly variants?: readonly string[];
498
+ /** A list's element kind (`f32`, `f64`, `u32`, `i32`, `handle`) and capacity in elements. */
499
+ readonly element?: string;
500
+ readonly capacity?: number;
486
501
  }
487
502
 
488
503
  interface RecordLayout {
@@ -599,9 +614,55 @@ const LANES: Readonly<Record<string, readonly string[]>> = {
599
614
  Color: ["r", "g", "b"],
600
615
  };
601
616
 
617
+ /** Bytes of one lane of a list's element kind. */
618
+ const LANE_SIZE: Readonly<Record<string, number>> = { f32: 4, f64: 8, u32: 4, i32: 4, handle: 8 };
619
+
620
+ function readLane(v: DataView, o: number, element: string): number {
621
+ switch (element) {
622
+ case "f32": return v.getFloat32(o, true);
623
+ case "f64": return v.getFloat64(o, true);
624
+ case "u32": return v.getUint32(o, true);
625
+ case "i32": return v.getInt32(o, true);
626
+ default: return v.getUint32(o, true) + v.getUint32(o + 4, true) * 4294967296;
627
+ }
628
+ }
629
+
630
+ function writeLane(v: DataView, o: number, element: string, n: number): void {
631
+ switch (element) {
632
+ case "f32": return v.setFloat32(o, n, true);
633
+ case "f64": return v.setFloat64(o, n, true);
634
+ case "u32": return v.setUint32(o, n, true);
635
+ case "i32": return v.setInt32(o, n, true);
636
+ default:
637
+ v.setUint32(o, n % 4294967296, true);
638
+ v.setUint32(o + 4, Math.floor(n / 4294967296), true);
639
+ }
640
+ }
641
+
642
+ /** A list element checked as the engine's JSON path checks it; throws what it refuses. */
643
+ function listElement(value: unknown, element: string, i: number): number {
644
+ const n = number(value, `element ${i}`);
645
+ const whole = (lo: number, hi: number, what: string) => {
646
+ if (!Number.isInteger(n) || n < lo || n > hi) throw new Error(`element ${i}: ${n} is not ${what}`);
647
+ };
648
+ if (element === "u32") whole(0, 4294967295, "a u32");
649
+ else if (element === "i32") whole(-2147483648, 2147483647, "an i32");
650
+ else if (element === "handle") whole(0, 9007199254740991, "a handle");
651
+ return n;
652
+ }
653
+
602
654
  function decodeField(v: DataView, bytes: Uint8Array, f: RecordField): unknown {
603
655
  const o = f.offset;
604
656
  switch (f.kind) {
657
+ case "list": {
658
+ // A frozen copy: `push` throws instead of changing nothing.
659
+ const element = f.element ?? "f32";
660
+ const lane = LANE_SIZE[element] ?? 4;
661
+ const count = Math.min(readLane(v, o, element), f.capacity ?? 0);
662
+ const out: number[] = [];
663
+ for (let i = 0; i < count; i++) out.push(readLane(v, o + lane * (i + 1), element));
664
+ return Object.freeze(out);
665
+ }
605
666
  case "f32": return v.getFloat32(o, true);
606
667
  case "f64": return v.getFloat64(o, true);
607
668
  case "u32": return v.getUint32(o, true);
@@ -639,6 +700,23 @@ function number(value: unknown, what: string): number {
639
700
  function encodeField(v: DataView, bytes: Uint8Array, f: RecordField, value: unknown): void {
640
701
  const o = f.offset;
641
702
  switch (f.kind) {
703
+ case "list": {
704
+ // The whole list: an array, at most the capacity, every element checked before a byte moves.
705
+ const element = f.element ?? "f32";
706
+ const capacity = f.capacity ?? 0;
707
+ const items = value === null || value === undefined ? [] : value;
708
+ if (!Array.isArray(items)) throw new Error(`a list is written whole, as an array, got ${JSON.stringify(value)}`);
709
+ if (items.length > capacity) {
710
+ const kind = element === "handle" ? "Asset" : element;
711
+ throw new Error(`${items.length} elements and the field holds ${capacity}: remove ${items.length - capacity}, or declare the field list(${kind}(), ${items.length}) or larger`);
712
+ }
713
+ const lanes = items.map((x, i) => listElement(x, element, i));
714
+ const lane = LANE_SIZE[element] ?? 4;
715
+ bytes.fill(0, o, o + f.size);
716
+ writeLane(v, o, element, lanes.length);
717
+ lanes.forEach((n, i) => writeLane(v, o + lane * (i + 1), element, n));
718
+ return;
719
+ }
642
720
  case "f32": return v.setFloat32(o, number(value, "the value"), true);
643
721
  case "f64": return v.setFloat64(o, number(value, "the value"), true);
644
722
  case "u32": {
@@ -732,7 +810,9 @@ function recordHandle(world: WorldImpl, r: RecordCopy): object {
732
810
  if (!refresh(r)) {
733
811
  const answer = parse<Result<Record<string, unknown>>>(host!.abi.og_world_resource(id, name));
734
812
  if (!answer.ok) throw new Error(`${name}: ${answer.reason}`);
735
- return (lastValue = answer.value[key]);
813
+ const value = answer.value[key];
814
+ // A list reads as a frozen copy on this path too.
815
+ return (lastValue = Array.isArray(value) ? Object.freeze(value) : value);
736
816
  }
737
817
  }
738
818
  return (lastValue = decodeField(r.view, r.bytes, field()));
@@ -1122,7 +1202,7 @@ export class Engine {
1122
1202
  /** Attach what answers og.platform's calls: the native host's local store, the web shell's Player bridge. */
1123
1203
  attachPlatform(host: PlatformHost): void {
1124
1204
  this.#platform = host;
1125
- abi().og_platform_set_host(this.id, JSON.stringify({ host: host.host, capabilities: host.capabilities, time: this.#clock.now(), clock: this.#clock.kind }));
1205
+ abi().og_platform_set_host(this.id, JSON.stringify({ host: host.host, capabilities: host.capabilities, time: host.time ?? this.#clock.now(), clock: host.time === undefined ? this.#clock.kind : "player" }));
1126
1206
  }
1127
1207
 
1128
1208
  /** Set the clock og.platform tells the game (before attachPlatform, which tells it the time). */
@@ -1335,6 +1415,7 @@ export const render = Object.freeze({
1335
1415
  parse(abi().og_render_update_material(world.id, material, json(desc))),
1336
1416
  capabilities: (world: WorldImpl) => parse(abi().og_render_capabilities(world.id)),
1337
1417
  applyLook: (world: WorldImpl, look: number) => parse(abi().og_render_apply_look(world.id, look)),
1418
+ applyQuality: (world: WorldImpl, preset: string) => parse(abi().og_render_apply_quality(world.id, preset)),
1338
1419
  look: (world: WorldImpl) => parse(abi().og_render_look(world.id)),
1339
1420
  text: (world: WorldImpl, desc?: unknown) => parse(abi().og_render_text(world.id, json(desc))),
1340
1421
  sprite: (world: WorldImpl, desc?: unknown) => parse(abi().og_render_sprite(world.id, json(desc))),
@@ -1389,6 +1470,7 @@ export const anim = Object.freeze({
1389
1470
  graph: (world: WorldImpl, desc?: unknown) => parse(abi().og_anim_graph(world.id, json(desc))),
1390
1471
  humanoid: (world: WorldImpl, desc?: unknown) => parse(abi().og_anim_humanoid(world.id, json(desc))),
1391
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))),
1392
1474
  rig2dInfo: (world: WorldImpl, rig: number) => parse(abi().og_anim_rig2d_info(world.id, rig)),
1393
1475
  skeleton2dBoxes: (world: WorldImpl, entity: number) => parse(abi().og_anim_skeleton2d_boxes(world.id, entity)),
1394
1476
  // One lookup for a flipbook's or a 2D rig's clips (an index, -1 when
@@ -2,8 +2,11 @@
2
2
  // game's GameSettings. The game owns the values (a resource, a save); the
3
3
  // screen answers the new ones every tick and calls the hooks: onChange on
4
4
  // every change (apply it at once), onSave when it closes after changes.
5
+ // A new graphics preset is applied to SettingsDesc.world by the screen
6
+ // itself (og.render.applyQuality) before onChange.
5
7
 
6
8
  import type * as Api from "@origingame/engine";
9
+ import { render } from "../runtime.ts";
7
10
  import { kit } from "./bind.ts";
8
11
  import { frame, grow, px, token } from "./util.ts";
9
12
 
@@ -119,6 +122,12 @@ export function settingsScreen(id: string, desc: Api.SettingsDesc = {}): Api.Set
119
122
  const changed = JSON.stringify(s) !== JSON.stringify(before);
120
123
  if (changed) {
121
124
  dirty = true;
125
+ // The preset is RenderSettings' quality fields (og.render.applyQuality):
126
+ // the next frame draws it, and the probe reads what is drawn.
127
+ if (s.quality !== before.quality && desc.world) {
128
+ const r = render.applyQuality(desc.world as unknown as Parameters<typeof render.applyQuality>[0], s.quality) as Api.Result<void>;
129
+ if (!r.ok) throw new Error(`og.ui.settingsScreen: ${r.reason}`);
130
+ }
122
131
  desc.onChange?.(s);
123
132
  }
124
133
  if (closed && dirty) {