aboutsummaryrefslogtreecommitdiffstats
path: root/prototypes/workbench/web/runtime.mjs
blob: d8d6618b73091c9255d5ec259bb08295f754eb43 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
import { compile } from './compiler.mjs';
import { validateDocument } from './project.mjs';

const messages = [
  '', '실행기의 용량 한도를 넘었습니다.', '실행 정의가 유효하지 않습니다.',
  '계산 스택의 범위를 벗어났습니다.', '0으로 나눌 수 없습니다.',
  '계산 결과가 유한한 수의 범위를 벗어났습니다.', '조건 분기 범위가 유효하지 않습니다.',
  '존재하지 않는 상태를 참조했습니다.', '시간 간격이 유효하지 않습니다.',
  '정의되지 않은 입력 사건입니다.',
];
const sourceLimit = 32768;

function problem(message, line = null, column = null) {
  return Object.assign(new Error(message), { line, column });
}

function freezeProgram(value) {
  if (value && typeof value === 'object') {
    for (const child of Object.values(value)) freezeProgram(child);
    Object.freeze(value);
  }
  return value;
}

export async function createRuntime(wasmBytes, evaluationOptions = {}) {
  if (wasmBytes === undefined) {
    const response = await fetch(new URL('./core.wasm', import.meta.url), { cache: 'no-store' });
    if (!response.ok) throw new Error(`실행기를 불러오지 못했습니다 (HTTP ${response.status}).`);
    wasmBytes = await response.arrayBuffer();
  }
  const module = await WebAssembly.compile(wasmBytes);
  if (WebAssembly.Module.imports(module).length) throw new Error('이 실행기는 호스트 함수 없이 동작해야 합니다.');
  const { exports: core } = await WebAssembly.instantiate(module, {});
  if (typeof core.eval_bounded !== 'function' || typeof core.abort_program !== 'function' || typeof core.dispatch_point !== 'function' || typeof core.put_restored_value !== 'function' || typeof core.commit_restored_program !== 'function' || typeof core.bind_handler !== 'function' || typeof core.put_pointer_target !== 'function' || typeof core.bind_target_handler !== 'function' || typeof core.dispatch_target !== 'function' || typeof core.dispatch_value !== 'function')
    throw new Error('실행기 버전이 맞지 않습니다. 새로고침하거나 core.wasm을 다시 빌드하세요.');
  core.reset_all();
  let program = null;
  let appliedSource = '';
  let lastEvent = null;
  // Keep a failed event visible through its release/focus-loss cleanup.
  // The active program can clear this diagnostic by apply or explicit reset.
  let lastFailure = null;
  const held = new Map();
  const points = new Map();
  let presenting = false;
  let bindings = [];
  let pointRoutes = new Map();
  // One bounded publication buffer, never exposed to callers. No state mirror.
  let displayValues = null, displayValid = null;
  function available() {
    if (presenting) throw new Error('출력 전달 중에는 실행기를 다시 호출할 수 없습니다.');
  }
  let evaluationPolicy = { cacheEntries: 0, historyEntries: 8, workLimit: 2048 };

  function checked(code, line = null) {
    if (code !== 0) throw problem(messages[code] || `실행기 오류 ${code}`, line);
  }
  function inputIndex(name) {
    const index = program?.inputs.findIndex((input) => input.name === name) ?? -1;
    if (index < 0) throw new Error(`정의되지 않은 입력: ${name}`);
    return index;
  }
  function dispatch(event, name, phase, source, point = null, value = undefined) {
    const status = value !== undefined ? core.dispatch_value(event, value) : point ? (point.route === undefined ? core.dispatch_point(event, point.x, point.y)
      : core.dispatch_target(event, point.route, point.x, point.y)) : core.dispatch(event, event === 0 ? 1 / 60 : 0);
    const pc = core.info(2) >>> 0;
    lastEvent = { name, phase, source, status,
      line: program?.instructions[pc]?.line ?? null, ops: core.info(3) >>> 0 };
    if (point) { lastEvent.x = point.x; lastEvent.y = point.y; if (point.target) lastEvent.target = point.target; }
    if (value !== undefined) lastEvent.value = value;
    if (status) lastFailure = { ...lastEvent };
    return status;
  }

  function evaluateDisplay() {
    core.begin_evaluation(evaluationPolicy.workLimit);
    if (!bindings.length) return null;
    displayValues ??= new Float64Array(96);
    displayValid ??= new Uint8Array(24);
    let error = null, slot = 0;
    for (let i = 0; i < bindings.length; i++) {
      const view = program.views[i];
      displayValid[i] = 1;
      for (const [name, binding] of bindings[i]) {
        const value = core.eval_bounded(binding.start, binding.length);
        if (core.evaluation_info(0) === 10) {
          displayValid.fill(0);
          return { code: 101, line: view.line,
            message: '표시 계산의 작업량 한도를 넘었습니다. 한도를 늘리거나 표시식을 줄이세요.' };
        }
        if (!Number.isFinite(value) || ((name === 'width' || name === 'height' || name === 'radius') && value < 0)) {
          error ??= { line: view.line, message: `${view.id}.${name}: 표시할 수 없는 값입니다. 0 나눗셈이나 음수 크기를 확인하세요.` };
          displayValid[i] = 0;
        }
        displayValues[slot++] = value;
      }
    }
    return error;
  }

  function status(viewError) {
    const executionError = lastFailure?.status || 0;
    return {
      tick: core.info(0) >>> 0, revision: core.info(4) >>> 0,
      displayError: viewError ? { code: viewError.code || 100, ...viewError } : null,
      lastError: executionError || (viewError ? viewError.code || 100 : 0),
      errorMessage: executionError ? messages[executionError] || `실행 오류 ${executionError}` : viewError?.message || '',
      errorLine: executionError ? lastFailure.line : viewError?.line ?? null,
      lastOps: core.info(3) >>> 0, successfulEvents: core.info(9) >>> 0,
      held: [...held.keys()], lastEvent: lastEvent ? { ...lastEvent } : null,
      lastFailure: lastFailure ? { ...lastFailure } : null,
      memory: {
        linearBytes: core.memory.buffer.byteLength,
        reservedEngineBytes: core.layout(0), programReservedBytes: core.layout(1),
        instructionBytes: core.layout(2), handlerBytes: core.layout(3),
        activeIRBytes: core.layout(4), activeStateBytes: core.layout(5),
        stagingReservedBytes: core.layout(6), evalStackBytes: core.layout(7),
        pointerTargetBytes: core.layout(8), pointerTargets: core.layout(9),
      },
    };
  }

  function load(next, source, restored = null) {
    const oldIds = new Map(restored ? [] : program?.states.map((state, index) => [state.name, index]) ?? []);
    checked(core.begin_program(next.states.length, next.inputs.length));
    try {
      for (let index = 0; index < next.states.length; index++) {
        const state = next.states[index];
        checked(core.put_state(index, state.initial, oldIds.get(state.name) ?? -1), state.line);
        if (restored) checked(core.put_restored_value(index, restored[index]), state.line);
      }
      for (const op of next.instructions) checked(core.put_instruction(op.op, op.arg, op.value), op.line);
      for (const target of next.pointerTargets) checked(core.put_pointer_target(target.input, target.base, target.count));
      for (const [index, handler] of next.handlers.entries()) {
        checked(core.put_handler(handler.event, handler.start, handler.length), handler.line);
        if (handler.parameterCount)
          checked(handler.parameterBase === 255 ? core.bind_target_handler(index, handler.parameterCount)
            : core.bind_handler(index, handler.parameterBase, handler.parameterCount), handler.line);
      }
      checked(restored ? core.commit_restored_program() : core.commit_program());
    } catch (error) {
      core.abort_program();
      throw error;
    }
    pointRoutes = new Map(next.inputs.flatMap(input => (input.routes ?? []).map(route => [route.target, route])));
    program = next;
    bindings = next.views.map(view => Object.entries(view.bindings));
    appliedSource = source;
    lastEvent = null;
    lastFailure = null;
  }

  const api = {
    get currentProgram() { return program; },
    configureEvaluation({ cacheEntries = evaluationPolicy.cacheEntries,
      historyEntries = evaluationPolicy.historyEntries, workLimit = evaluationPolicy.workLimit } = {}) {
      available();
      for (const [name, value, maximum] of [['cacheEntries', cacheEntries, 32],
        ['historyEntries', historyEntries, 8], ['workLimit', workLimit, 0xffffffff]]) {
        if (!Number.isInteger(value) || value < 0 || value > maximum)
          throw new Error(`${name}: 0~${maximum} 사이의 정수여야 합니다.`);
      }
      checked(core.configure_evaluation(cacheEntries, historyEntries));
      evaluationPolicy = { cacheEntries, historyEntries, workLimit };
      return { ...evaluationPolicy };
    },
    forgetEvaluation(part = 'all') {
      available();
      const flags = part === 'all' ? 3 : part === 'values' ? 1 : part === 'history' ? 2 : undefined;
      if (flags === undefined) throw new Error('삭제할 기록은 values, history, all 중 하나여야 합니다.');
      core.forget_evaluation(flags);
    },
    evaluationStats() {
      return { ...evaluationPolicy, work: core.evaluation_info(1),
        instructions: core.evaluation_info(2), cacheHits: core.evaluation_info(3),
        reservedBytes: core.evaluation_info(6), dedicatedBytes: core.evaluation_info(7),
        sharedBytes: core.evaluation_info(8), reuseAvailable: Boolean(core.evaluation_info(9)),
        workspaceTagBytes: core.evaluation_info(10) };
    },
    apply(source) {
      available();
      if (typeof source !== 'string' || new TextEncoder().encode(source).length > sourceLimit) {
        throw problem('정의는 UTF-8 기준 32,768바이트 이하의 텍스트여야 합니다.');
      }
      if (held.size) throw problem('누르고 있는 입력을 놓은 뒤 적용하세요. 현재 작업은 그대로 유지합니다.');
      const next = freezeProgram(compile(source));
      load(next, source);
      return next;
    },
    restoreDocument(document) {
      available();
      if (held.size) throw problem('누르고 있는 입력을 놓은 뒤 복원하세요.');
      const next = validateDocument(document);
      load(freezeProgram(next.program), next.source, next.values);
      return { source: next.source, draft: next.draft };
    },
    exportDocument(draft = appliedSource) {
      available();
      if (!program) throw problem('먼저 코드를 적용하세요.');
      if (held.size) throw problem('조작을 끝낸 뒤 배치를 저장하세요.');
      if (lastFailure) throw problem('실행 오류를 해결한 뒤 배치를 저장하세요.');
      if (typeof draft !== 'string' || new TextEncoder().encode(draft).length > sourceLimit)
        throw problem('편집 중인 코드는 UTF-8 32 KiB 이하여야 합니다.');
      return { format: 'pure-workbench', version: 2, source: appliedSource, draft,
        states: program.states.map((state, i) => {
          const value = core.get_state(i);
          return { name: state.name, value: Object.is(value, -0) ? '-0' : value };
        }) };
    },
    reset() {
      available();
      held.clear();
      points.clear();
      core.reset_state();
      lastEvent = null;
      lastFailure = null;
    },
    frame(count = 1) {
      available();
      if (!Number.isInteger(count) || count < 0 || count > 600) throw new Error('프레임 수는 0~600 사이의 정수여야 합니다.');
      if (!program) return 0;
      for (let i = 0; i < count; i++) {
        const code = dispatch(0, 'frame', 'frame', 'clock');
        if (code !== 0) return code;
      }
      return 0;
    },
    down(name, source = 'external') {
      available();
      const index = inputIndex(name);
      if (program.inputs[index].kind === 'value') throw new Error('숫자 입력은 change(name, value)를 사용하세요.');
      if (program.inputs[index].target || program.inputs[index].routes) throw new Error('좌표 입력은 point(name, phase, x, y)를 사용하세요.');
      const sources = held.get(name) || new Set();
      if (sources.has(source)) return 0;
      const wasDown = sources.size > 0;
      sources.add(source);
      held.set(name, sources);
      return wasDown ? 0 : dispatch(1 + index * 2, name, 'down', source);
    },
    up(name, source = 'external') {
      available();
      const index = inputIndex(name);
      if (program.inputs[index].kind === 'value') throw new Error('숫자 입력은 change(name, value)를 사용하세요.');
      if (program.inputs[index].target || program.inputs[index].routes) throw new Error('좌표 입력은 point(name, phase, x, y)를 사용하세요.');
      const sources = held.get(name);
      if (!sources?.delete(source)) return 0;
      if (sources.size) return 0;
      held.delete(name);
      return dispatch(2 + index * 2, name, 'up', source);
    },
    change(name, value, source = 'external') {
      available();
      const index = inputIndex(name);
      if (program.inputs[index].kind !== 'value') throw new Error('숫자 값에 연결된 입력이 아닙니다.');
      if (!Number.isFinite(value)) throw new Error('입력 값은 유한한 숫자여야 합니다.');
      return dispatch(33 + index, name, 'change', source, null, value);
    },
    point(name, phase, x, y, source = 'external', target = undefined) {
      available();
      const index = inputIndex(name);
      const input = program.inputs[index];
      if (!input.target && !input.routes) throw new Error('포인터에 연결된 입력이 아닙니다.');
      const route = input.routes ? pointRoutes.get(target) : undefined;
      if (input.routes && (!route || program.pointerTargets[route.route].input !== index)) throw new Error('포인터 그룹에 연결된 도형 이름이 필요합니다.');
      if (!input.routes && target !== undefined) throw new Error('단일 포인터 입력에는 대상 인자를 지정하지 않습니다.');
      const owner = route ? `${name}:${target}` : name;
      if (!['down', 'move', 'up', 'cancel'].includes(phase)) throw new Error('포인터 단계는 down, move, up, cancel 중 하나여야 합니다.');
      if (!Number.isFinite(x) || !Number.isFinite(y)) throw new Error('포인터 좌표는 유한한 숫자여야 합니다.');
      if (typeof source !== 'string' || source.length === 0) throw new Error('포인터 출처 이름이 필요합니다.');
      let point = points.get(owner);
      if (phase === 'down') {
        if (point?.source === source) return 0;
        if (point) throw new Error('이 입력은 다른 포인터가 사용 중입니다.');
        point = {name, source, x, y, target, route:route?.route};
        points.set(owner, point);
        held.set(owner, new Set([source]));
      } else {
        if (!point || point.source !== source) return 0;
        point.x = x; point.y = y;
        if (phase === 'up' || phase === 'cancel') { points.delete(owner); held.delete(owner); }
      }
      const event = phase === 'move' ? 17 + index : phase === 'cancel' ? 25 + index : 1 + index * 2 + (phase === 'up' ? 1 : 0);
      return dispatch(event, name, phase, source, point);
    },
    releaseAll() {
      available();
      let firstError = 0;
      for (const point of [...points.values()]) {
        const status = api.point(point.name, 'cancel', point.x, point.y, point.source, point.target);
        if (!firstError) firstError = status;
      }
      for (const [name, sources] of [...held]) {
        for (const source of [...sources]) {
          const status = api.up(name, source);
          if (!firstError) firstError = status;
        }
      }
      return firstError;
    },
    exportProject() {
      return { format: 'pure-workbench', version: 1, source: appliedSource };
    },
    // Synchronous trusted output adapter. Callbacks run only after the whole
    // display has been evaluated. Runtime mutation/reentrant reads are refused.
    // Exceptions from an adapter propagate; the next call republishes all values.
    present(target) {
      available();
      presenting = true;
      try {
        const { begin, state, view, value, end } = target;
        for (const callback of [begin, state, view, value, end])
          if (typeof callback !== 'function') throw new TypeError('출력 어댑터 함수가 필요합니다.');
        const error = evaluateDisplay();
        begin.call(target, program);
        for (let i = 0; i < (program?.states.length ?? 0); i++) state.call(target, i, core.get_state(i));
        let slot = 0, visible = 0;
        for (let i = 0; i < bindings.length; i++) {
          const valid = Boolean(displayValid[i]);
          view.call(target, i, valid);
          for (const [name] of bindings[i]) {
            if (valid) value.call(target, i, name, displayValues[slot]);
            slot++;
          }
          if (valid) visible++;
        }
        end.call(target, visible);
        return status(error);
      } finally { presenting = false; }
    },
    snapshot() {
      available();
      const error = evaluateDisplay();
      const states = Object.fromEntries((program?.states ?? []).map((state, index) => [state.name, core.get_state(index)]));
      const views = [];
      let slot = 0;
      for (let i = 0; i < bindings.length; i++) {
        const view = program.views[i];
        const rendered = displayValid[i] ? { id: view.id, type: view.type, color: view.color, prefix: view.prefix } : null;
        for (const [name] of bindings[i]) {
          if (rendered) rendered[name] = displayValues[slot];
          slot++;
        }
        if (rendered) views.push(rendered);
      }
      return { states, views, ...status(error) };
    },
  };
  api.configureEvaluation(evaluationOptions);
  return Object.freeze(api);
}