moonscratch 0.1.0-alpha.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.
- package/.agents/skills/moonbit-agent-guide/LICENSE +202 -0
- package/.agents/skills/moonbit-agent-guide/SKILL.mbt.md +1126 -0
- package/.agents/skills/moonbit-agent-guide/SKILL.md +1126 -0
- package/.agents/skills/moonbit-agent-guide/ide.md +116 -0
- package/.agents/skills/moonbit-agent-guide/references/advanced-moonbit-build.md +106 -0
- package/.agents/skills/moonbit-agent-guide/references/moonbit-language-fundamentals.mbt.md +422 -0
- package/.agents/skills/moonbit-agent-guide/references/moonbit-language-fundamentals.md +422 -0
- package/.agents/skills/moonbit-practice/SKILL.md +258 -0
- package/.agents/skills/moonbit-practice/assets/ci.yaml +25 -0
- package/.agents/skills/moonbit-practice/reference/agents.md +1469 -0
- package/.agents/skills/moonbit-practice/reference/configuration.md +228 -0
- package/.agents/skills/moonbit-practice/reference/ffi.md +229 -0
- package/.agents/skills/moonbit-practice/reference/ide.md +189 -0
- package/.agents/skills/moonbit-practice/reference/performance.md +217 -0
- package/.agents/skills/moonbit-practice/reference/refactor.md +154 -0
- package/.agents/skills/moonbit-practice/reference/stdlib.md +351 -0
- package/.agents/skills/moonbit-practice/reference/testing.md +228 -0
- package/.agents/skills/moonbit-refactoring/LICENSE +21 -0
- package/.agents/skills/moonbit-refactoring/SKILL.md +323 -0
- package/.githooks/README.md +23 -0
- package/.githooks/pre-commit +3 -0
- package/.github/workflows/copilot-setup-steps.yml +40 -0
- package/.turbo/turbo-typecheck.log +2 -0
- package/AGENTS.md +91 -0
- package/LICENSE +21 -0
- package/PLAN.md +64 -0
- package/README.mbt.md +77 -0
- package/README.md +84 -0
- package/TODO.md +120 -0
- package/a.png +0 -0
- package/benchmarks/calc.bench.ts +144 -0
- package/benchmarks/draw.bench.ts +215 -0
- package/benchmarks/load.bench.ts +28 -0
- package/benchmarks/render.bench.ts +53 -0
- package/benchmarks/run.bench.ts +8 -0
- package/benchmarks/types.d.ts +15 -0
- package/docs/scratch-vm-specs/eventloop.md +103 -0
- package/docs/scratch-vm-specs/moonscratch-time-separation.md +50 -0
- package/index.html +91 -0
- package/js/AGENTS.md +5 -0
- package/js/a.ts +52 -0
- package/js/assets/AGENTS.md +5 -0
- package/js/assets/base64.test.ts +14 -0
- package/js/assets/base64.ts +21 -0
- package/js/assets/build-asset.test.ts +26 -0
- package/js/assets/build-asset.ts +28 -0
- package/js/assets/create.test.ts +142 -0
- package/js/assets/create.ts +122 -0
- package/js/assets/index.test.ts +15 -0
- package/js/assets/index.ts +2 -0
- package/js/assets/types.ts +26 -0
- package/js/assets/validation.test.ts +34 -0
- package/js/assets/validation.ts +25 -0
- package/js/assets.test.ts +14 -0
- package/js/assets.ts +1 -0
- package/js/index.test.ts +26 -0
- package/js/index.ts +3 -0
- package/js/render/index.test.ts +65 -0
- package/js/render/index.ts +13 -0
- package/js/render/sharp.ts +87 -0
- package/js/render/svg.ts +68 -0
- package/js/render/types.ts +35 -0
- package/js/render/utils.ts +108 -0
- package/js/render/webgl.ts +274 -0
- package/js/sharp-optional.d.ts +16 -0
- package/js/test/helpers.ts +116 -0
- package/js/test/hikkaku-sample.test.ts +37 -0
- package/js/test/rubik-components.input-motion.test.ts +60 -0
- package/js/test/rubik-components.lists.test.ts +49 -0
- package/js/test/rubik-components.operators.test.ts +104 -0
- package/js/test/rubik-components.pen.test.ts +112 -0
- package/js/test/rubik-components.procedures-loops.test.ts +72 -0
- package/js/test/rubik-components.variables-branches.test.ts +57 -0
- package/js/test/rubik-components.visibility-entry.test.ts +31 -0
- package/js/test/test-projects.ts +598 -0
- package/js/test/variable.ts +200 -0
- package/js/test/warp.test.ts +59 -0
- package/js/vm/AGENTS.md +6 -0
- package/js/vm/README.md +183 -0
- package/js/vm/bindings.test.ts +13 -0
- package/js/vm/bindings.ts +5 -0
- package/js/vm/compare-operators.test.ts +145 -0
- package/js/vm/constants.test.ts +11 -0
- package/js/vm/constants.ts +4 -0
- package/js/vm/effect-guards.test.ts +68 -0
- package/js/vm/effect-guards.ts +44 -0
- package/js/vm/factory.test.ts +486 -0
- package/js/vm/factory.ts +615 -0
- package/js/vm/headless-vm.test.ts +131 -0
- package/js/vm/headless-vm.ts +342 -0
- package/js/vm/index.test.ts +28 -0
- package/js/vm/index.ts +5 -0
- package/js/vm/internal-types.ts +32 -0
- package/js/vm/json.test.ts +40 -0
- package/js/vm/json.ts +273 -0
- package/js/vm/normalize.test.ts +48 -0
- package/js/vm/normalize.ts +65 -0
- package/js/vm/options.test.ts +30 -0
- package/js/vm/options.ts +55 -0
- package/js/vm/pen-transparency.test.ts +115 -0
- package/js/vm/program-wasm.ts +322 -0
- package/js/vm/scheduler-render.test.ts +401 -0
- package/js/vm/scratch-assets.test.ts +136 -0
- package/js/vm/scratch-assets.ts +202 -0
- package/js/vm/types.ts +358 -0
- package/js/vm/value-guards.test.ts +25 -0
- package/js/vm/value-guards.ts +18 -0
- package/moon.mod.json +10 -0
- package/package.json +33 -0
- package/scripts/preinstall.ts +4 -0
- package/src/AGENTS.md +6 -0
- package/src/api.mbt +161 -0
- package/src/api_aot_commands.mbt +184 -0
- package/src/api_effects_json.mbt +72 -0
- package/src/api_options.mbt +60 -0
- package/src/api_program_wasm.mbt +1647 -0
- package/src/api_program_wat.mbt +2206 -0
- package/src/api_snapshot_json.mbt +44 -0
- package/src/cmd/AGENTS.md +5 -0
- package/src/cmd/main/AGENTS.md +5 -0
- package/src/cmd/main/main.mbt +29 -0
- package/src/cmd/main/moon.pkg +7 -0
- package/src/cmd/main/pkg.generated.mbti +13 -0
- package/src/json_helpers.mbt +176 -0
- package/src/moon.pkg +65 -0
- package/src/moonscratch.mbt +3 -0
- package/src/moonscratch_wbtest.mbt +40 -0
- package/src/parser_sb3.mbt +890 -0
- package/src/pkg.generated.mbti +479 -0
- package/src/runtime_eval.mbt +2844 -0
- package/src/runtime_exec.mbt +3850 -0
- package/src/runtime_render.mbt +2550 -0
- package/src/runtime_state.mbt +870 -0
- package/src/test/AGENTS.md +3 -0
- package/src/test/projects/AGENTS.md +6 -0
- package/src/test/projects/moon.pkg +4 -0
- package/src/test/projects/moonscratch_compat_test.mbt +642 -0
- package/src/test/projects/moonscratch_core_test.mbt +1332 -0
- package/src/test/projects/moonscratch_runtime_test.mbt +1087 -0
- package/src/test/projects/pkg.generated.mbti +13 -0
- package/src/test/projects/test_support.mbt +35 -0
- package/src/types_effects.mbt +20 -0
- package/src/types_error.mbt +4 -0
- package/src/types_options.mbt +31 -0
- package/src/types_runtime_structs.mbt +254 -0
- package/src/types_vm.mbt +109 -0
- package/tsconfig.json +29 -0
- package/viewer/index.ts +399 -0
- package/viewer/vite.d.ts +1 -0
- package/viewer/worker.ts +161 -0
- package/vite.config.ts +11 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from 'vite-plus/test'
|
|
2
|
+
|
|
3
|
+
import { resolveMissingScratchAssets } from './scratch-assets.ts'
|
|
4
|
+
|
|
5
|
+
const RGBA_ASSET = {
|
|
6
|
+
width: 1,
|
|
7
|
+
height: 1,
|
|
8
|
+
rgbaBase64: 'AP8A/w==',
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const STAGE_PROJECT_WITH_COSTUMES = {
|
|
12
|
+
targets: [
|
|
13
|
+
{
|
|
14
|
+
isStage: true,
|
|
15
|
+
name: 'Stage',
|
|
16
|
+
variables: {},
|
|
17
|
+
lists: {},
|
|
18
|
+
blocks: {},
|
|
19
|
+
costumes: [
|
|
20
|
+
{
|
|
21
|
+
name: 'backdrop1',
|
|
22
|
+
assetId: 'bg_green',
|
|
23
|
+
md5ext: 'bg_green.png',
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('moonscratch/js/vm/scratch-assets.ts', () => {
|
|
31
|
+
test('downloads missing assets from Scratch CDN and maps by assetId + md5ext', async () => {
|
|
32
|
+
const fetchAsset = vi.fn(async () => ({
|
|
33
|
+
ok: true,
|
|
34
|
+
status: 200,
|
|
35
|
+
statusText: 'OK',
|
|
36
|
+
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
|
37
|
+
}))
|
|
38
|
+
const decodeImageBytes = vi.fn(async () => RGBA_ASSET)
|
|
39
|
+
|
|
40
|
+
const assets = await resolveMissingScratchAssets({
|
|
41
|
+
projectJson: STAGE_PROJECT_WITH_COSTUMES,
|
|
42
|
+
fetchAsset,
|
|
43
|
+
decodeImageBytes,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
expect(fetchAsset).toHaveBeenCalledWith(
|
|
47
|
+
'https://cdn.scratch.mit.edu/internalapi/asset/bg_green.png/get/',
|
|
48
|
+
)
|
|
49
|
+
expect(decodeImageBytes).toHaveBeenCalledTimes(1)
|
|
50
|
+
expect(assets.bg_green).toEqual(RGBA_ASSET)
|
|
51
|
+
expect(assets['bg_green.png']).toEqual(RGBA_ASSET)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('does not download when existing asset is keyed by md5ext', async () => {
|
|
55
|
+
const fetchAsset = vi.fn(async () => ({
|
|
56
|
+
ok: true,
|
|
57
|
+
status: 200,
|
|
58
|
+
statusText: 'OK',
|
|
59
|
+
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
|
60
|
+
}))
|
|
61
|
+
|
|
62
|
+
const assets = await resolveMissingScratchAssets({
|
|
63
|
+
projectJson: STAGE_PROJECT_WITH_COSTUMES,
|
|
64
|
+
assets: {
|
|
65
|
+
'bg_green.png': RGBA_ASSET,
|
|
66
|
+
},
|
|
67
|
+
fetchAsset,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
expect(fetchAsset).not.toHaveBeenCalled()
|
|
71
|
+
expect(assets.bg_green).toEqual(RGBA_ASSET)
|
|
72
|
+
expect(assets['bg_green.png']).toEqual(RGBA_ASSET)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('deduplicates repeated md5ext downloads', async () => {
|
|
76
|
+
const fetchAsset = vi.fn(async () => ({
|
|
77
|
+
ok: true,
|
|
78
|
+
status: 200,
|
|
79
|
+
statusText: 'OK',
|
|
80
|
+
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
|
|
81
|
+
}))
|
|
82
|
+
const decodeImageBytes = vi.fn(async () => RGBA_ASSET)
|
|
83
|
+
|
|
84
|
+
const assets = await resolveMissingScratchAssets({
|
|
85
|
+
projectJson: {
|
|
86
|
+
targets: [
|
|
87
|
+
{
|
|
88
|
+
isStage: true,
|
|
89
|
+
name: 'Stage',
|
|
90
|
+
variables: {},
|
|
91
|
+
lists: {},
|
|
92
|
+
blocks: {},
|
|
93
|
+
costumes: [
|
|
94
|
+
{
|
|
95
|
+
name: 'backdrop1',
|
|
96
|
+
assetId: 'bg_green',
|
|
97
|
+
md5ext: 'bg_green.png',
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: 'backdrop2',
|
|
101
|
+
assetId: 'bg_green_2',
|
|
102
|
+
md5ext: 'bg_green.png',
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
fetchAsset,
|
|
109
|
+
decodeImageBytes,
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
expect(fetchAsset).toHaveBeenCalledTimes(1)
|
|
113
|
+
expect(decodeImageBytes).toHaveBeenCalledTimes(1)
|
|
114
|
+
expect(assets.bg_green).toEqual(RGBA_ASSET)
|
|
115
|
+
expect(assets.bg_green_2).toEqual(RGBA_ASSET)
|
|
116
|
+
expect(assets['bg_green.png']).toEqual(RGBA_ASSET)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test('throws when asset download fails', async () => {
|
|
120
|
+
const fetchAsset = vi.fn(async () => ({
|
|
121
|
+
ok: false,
|
|
122
|
+
status: 404,
|
|
123
|
+
statusText: 'Not Found',
|
|
124
|
+
arrayBuffer: async () => new ArrayBuffer(0),
|
|
125
|
+
}))
|
|
126
|
+
|
|
127
|
+
await expect(
|
|
128
|
+
resolveMissingScratchAssets({
|
|
129
|
+
projectJson: STAGE_PROJECT_WITH_COSTUMES,
|
|
130
|
+
fetchAsset,
|
|
131
|
+
}),
|
|
132
|
+
).rejects.toThrow(
|
|
133
|
+
'failed to download Scratch asset bg_green.png: 404 Not Found',
|
|
134
|
+
)
|
|
135
|
+
})
|
|
136
|
+
})
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { fromImageBytes } from '../assets/create.ts'
|
|
2
|
+
import { parseJson } from './json.ts'
|
|
3
|
+
import type {
|
|
4
|
+
DecodeImageBytes,
|
|
5
|
+
FetchAsset,
|
|
6
|
+
JsonValue,
|
|
7
|
+
ProjectJson,
|
|
8
|
+
ResolveMissingScratchAssetsOptions,
|
|
9
|
+
} from './types.ts'
|
|
10
|
+
import { isObjectRecord } from './value-guards.ts'
|
|
11
|
+
|
|
12
|
+
const DEFAULT_SCRATCH_CDN_BASE_URL =
|
|
13
|
+
'https://cdn.scratch.mit.edu/internalapi/asset'
|
|
14
|
+
|
|
15
|
+
type AssetObject = {
|
|
16
|
+
width: number
|
|
17
|
+
height: number
|
|
18
|
+
rgbaBase64: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type CostumeRef = {
|
|
22
|
+
assetId: string
|
|
23
|
+
md5ext: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const hasOwn = (record: Record<string, JsonValue>, key: string): boolean =>
|
|
27
|
+
Object.hasOwn(record, key)
|
|
28
|
+
|
|
29
|
+
const normalizeAssets = (
|
|
30
|
+
assets: ResolveMissingScratchAssetsOptions['assets'],
|
|
31
|
+
): Record<string, JsonValue> => {
|
|
32
|
+
if (assets === undefined) {
|
|
33
|
+
return {}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (typeof assets === 'string') {
|
|
37
|
+
const parsed = parseJson<unknown>(assets, 'assets')
|
|
38
|
+
if (!isObjectRecord(parsed)) {
|
|
39
|
+
throw new Error('assets must be a JSON object')
|
|
40
|
+
}
|
|
41
|
+
return { ...(parsed as Record<string, JsonValue>) }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { ...assets }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const normalizeProject = (
|
|
48
|
+
projectJson: string | ProjectJson,
|
|
49
|
+
): Record<string, unknown> => {
|
|
50
|
+
const parsed =
|
|
51
|
+
typeof projectJson === 'string'
|
|
52
|
+
? parseJson<unknown>(projectJson, 'projectJson')
|
|
53
|
+
: projectJson
|
|
54
|
+
if (!isObjectRecord(parsed)) {
|
|
55
|
+
throw new Error('projectJson must be a JSON object')
|
|
56
|
+
}
|
|
57
|
+
return parsed
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const collectCostumeRefs = (project: Record<string, unknown>): CostumeRef[] => {
|
|
61
|
+
const refs: CostumeRef[] = []
|
|
62
|
+
const targets = project.targets
|
|
63
|
+
if (!Array.isArray(targets)) {
|
|
64
|
+
return refs
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
for (const target of targets) {
|
|
68
|
+
if (!isObjectRecord(target)) {
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
const costumes = target.costumes
|
|
72
|
+
if (!Array.isArray(costumes)) {
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const costume of costumes) {
|
|
77
|
+
if (!isObjectRecord(costume)) {
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
const rawMd5ext = costume.md5ext
|
|
81
|
+
const md5ext = typeof rawMd5ext === 'string' ? rawMd5ext.trim() : ''
|
|
82
|
+
if (md5ext.length === 0) {
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
const rawAssetId = costume.assetId
|
|
86
|
+
const assetId = typeof rawAssetId === 'string' ? rawAssetId.trim() : ''
|
|
87
|
+
refs.push({ assetId, md5ext })
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return refs
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const normalizeCdnBaseUrl = (input: string | undefined): string => {
|
|
95
|
+
const base = (input ?? DEFAULT_SCRATCH_CDN_BASE_URL).trim()
|
|
96
|
+
if (base.length === 0) {
|
|
97
|
+
throw new Error('scratchCdnBaseUrl must not be empty')
|
|
98
|
+
}
|
|
99
|
+
return base.endsWith('/') ? base.slice(0, -1) : base
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const defaultFetchAsset = (): FetchAsset => {
|
|
103
|
+
const fetchAsset = (globalThis as { fetch?: unknown }).fetch
|
|
104
|
+
if (typeof fetchAsset !== 'function') {
|
|
105
|
+
throw new Error('global fetch is not available; pass fetchAsset option')
|
|
106
|
+
}
|
|
107
|
+
return fetchAsset as FetchAsset
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const validateDecodedAsset = (
|
|
111
|
+
md5ext: string,
|
|
112
|
+
decoded: AssetObject,
|
|
113
|
+
): AssetObject => {
|
|
114
|
+
if (!Number.isFinite(decoded.width) || decoded.width <= 0) {
|
|
115
|
+
throw new Error(`decodeImageBytes returned invalid width for ${md5ext}`)
|
|
116
|
+
}
|
|
117
|
+
if (!Number.isFinite(decoded.height) || decoded.height <= 0) {
|
|
118
|
+
throw new Error(`decodeImageBytes returned invalid height for ${md5ext}`)
|
|
119
|
+
}
|
|
120
|
+
if (
|
|
121
|
+
typeof decoded.rgbaBase64 !== 'string' ||
|
|
122
|
+
decoded.rgbaBase64.length === 0
|
|
123
|
+
) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`decodeImageBytes returned invalid rgbaBase64 for ${md5ext}`,
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
return decoded
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const toJsonAsset = (asset: AssetObject): Record<string, JsonValue> => ({
|
|
132
|
+
width: asset.width,
|
|
133
|
+
height: asset.height,
|
|
134
|
+
rgbaBase64: asset.rgbaBase64,
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
const buildAssetUrl = (baseUrl: string, md5ext: string): string =>
|
|
138
|
+
`${baseUrl}/${encodeURIComponent(md5ext)}/get/`
|
|
139
|
+
|
|
140
|
+
export const resolveMissingScratchAssets = async ({
|
|
141
|
+
projectJson,
|
|
142
|
+
assets,
|
|
143
|
+
scratchCdnBaseUrl,
|
|
144
|
+
fetchAsset,
|
|
145
|
+
decodeImageBytes,
|
|
146
|
+
}: ResolveMissingScratchAssetsOptions): Promise<Record<string, JsonValue>> => {
|
|
147
|
+
const resolvedAssets = normalizeAssets(assets)
|
|
148
|
+
const refs = collectCostumeRefs(normalizeProject(projectJson))
|
|
149
|
+
if (refs.length === 0) {
|
|
150
|
+
return resolvedAssets
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const baseUrl = normalizeCdnBaseUrl(scratchCdnBaseUrl)
|
|
154
|
+
const fetchFn = fetchAsset ?? defaultFetchAsset()
|
|
155
|
+
const decodeFn: DecodeImageBytes = decodeImageBytes ?? fromImageBytes
|
|
156
|
+
const cache = new Map<string, Record<string, JsonValue>>()
|
|
157
|
+
|
|
158
|
+
for (const { assetId, md5ext } of refs) {
|
|
159
|
+
if (assetId.length > 0 && hasOwn(resolvedAssets, assetId)) {
|
|
160
|
+
const asset = resolvedAssets[assetId]
|
|
161
|
+
if (asset === undefined) {
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
164
|
+
if (!hasOwn(resolvedAssets, md5ext)) {
|
|
165
|
+
resolvedAssets[md5ext] = asset
|
|
166
|
+
}
|
|
167
|
+
continue
|
|
168
|
+
}
|
|
169
|
+
if (hasOwn(resolvedAssets, md5ext)) {
|
|
170
|
+
const asset = resolvedAssets[md5ext]
|
|
171
|
+
if (asset === undefined) {
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
if (assetId.length > 0 && !hasOwn(resolvedAssets, assetId)) {
|
|
175
|
+
resolvedAssets[assetId] = asset
|
|
176
|
+
}
|
|
177
|
+
continue
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let loaded = cache.get(md5ext)
|
|
181
|
+
if (!loaded) {
|
|
182
|
+
const url = buildAssetUrl(baseUrl, md5ext)
|
|
183
|
+
const response = await fetchFn(url)
|
|
184
|
+
if (!response.ok) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`failed to download Scratch asset ${md5ext}: ${response.status} ${response.statusText}`,
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
const bytes = await response.arrayBuffer()
|
|
190
|
+
const decoded = validateDecodedAsset(md5ext, await decodeFn(bytes))
|
|
191
|
+
loaded = toJsonAsset(decoded)
|
|
192
|
+
cache.set(md5ext, loaded)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
resolvedAssets[md5ext] = loaded
|
|
196
|
+
if (assetId.length > 0) {
|
|
197
|
+
resolvedAssets[assetId] = loaded
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return resolvedAssets
|
|
202
|
+
}
|
package/js/vm/types.ts
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import type { ScratchProject } from 'sb3-types'
|
|
2
|
+
|
|
3
|
+
type MaybePromise<T> = T | Promise<T>
|
|
4
|
+
|
|
5
|
+
export type JsonPrimitive = string | number | boolean | null
|
|
6
|
+
export type JsonValue =
|
|
7
|
+
| JsonPrimitive
|
|
8
|
+
| { [key: string]: JsonValue }
|
|
9
|
+
| JsonValue[]
|
|
10
|
+
export type ProjectJson = JsonValue | ScratchProject
|
|
11
|
+
|
|
12
|
+
export interface VMOptions {
|
|
13
|
+
turbo: boolean
|
|
14
|
+
compatibility30tps: boolean
|
|
15
|
+
maxClones: number
|
|
16
|
+
deterministic: boolean
|
|
17
|
+
seed: number
|
|
18
|
+
penWidth: number
|
|
19
|
+
penHeight: number
|
|
20
|
+
stepTimeoutTicks: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface VMOptionsInput extends Partial<VMOptions> {
|
|
24
|
+
compatibility_30tps?: boolean
|
|
25
|
+
max_clones?: number
|
|
26
|
+
pen_width?: number
|
|
27
|
+
pen_height?: number
|
|
28
|
+
step_timeout_ticks?: number
|
|
29
|
+
stepTimeoutTicks?: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type FrameStopReason = 'finished' | 'timeout' | 'rerender' | 'warp-exit'
|
|
33
|
+
|
|
34
|
+
export interface FrameReport {
|
|
35
|
+
activeThreads: number
|
|
36
|
+
ticks: number
|
|
37
|
+
ops: number
|
|
38
|
+
emittedEffects: number
|
|
39
|
+
stopReason: FrameStopReason
|
|
40
|
+
shouldRender: boolean
|
|
41
|
+
isInWarp: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type RunEndedBy = 'idle' | 'frame_limit'
|
|
45
|
+
|
|
46
|
+
export interface RunReport {
|
|
47
|
+
frames: number
|
|
48
|
+
ticks: number
|
|
49
|
+
ops: number
|
|
50
|
+
activeThreads: number
|
|
51
|
+
endedBy: RunEndedBy
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface RunUntilIdleOptions {
|
|
55
|
+
maxFrames?: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type VMInputEvent =
|
|
59
|
+
| {
|
|
60
|
+
type: 'answer'
|
|
61
|
+
answer: string
|
|
62
|
+
}
|
|
63
|
+
| {
|
|
64
|
+
type: 'mouse'
|
|
65
|
+
x: number
|
|
66
|
+
y: number
|
|
67
|
+
isDown?: boolean
|
|
68
|
+
}
|
|
69
|
+
| {
|
|
70
|
+
type: 'keys_down'
|
|
71
|
+
keys: string[]
|
|
72
|
+
}
|
|
73
|
+
| {
|
|
74
|
+
type: 'touching'
|
|
75
|
+
touching: Record<string, string[]>
|
|
76
|
+
}
|
|
77
|
+
| {
|
|
78
|
+
type: 'mouse_targets'
|
|
79
|
+
stage?: boolean
|
|
80
|
+
target?: string
|
|
81
|
+
targets?: string[]
|
|
82
|
+
}
|
|
83
|
+
| {
|
|
84
|
+
type: 'backdrop'
|
|
85
|
+
backdrop: string | string[]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type {
|
|
89
|
+
RenderFrame,
|
|
90
|
+
RenderFrameLike,
|
|
91
|
+
RenderImageData,
|
|
92
|
+
RenderWithSharpOptions,
|
|
93
|
+
RenderWithWebGLOptions,
|
|
94
|
+
WebGLRenderResult,
|
|
95
|
+
} from '../render/types.ts'
|
|
96
|
+
|
|
97
|
+
export interface VMSnapshotTarget {
|
|
98
|
+
id: string
|
|
99
|
+
name: string
|
|
100
|
+
isStage: boolean
|
|
101
|
+
x: number
|
|
102
|
+
y: number
|
|
103
|
+
direction: number
|
|
104
|
+
size: number
|
|
105
|
+
volume: number
|
|
106
|
+
musicInstrument: number
|
|
107
|
+
textToSpeechVoice: string
|
|
108
|
+
visible: boolean
|
|
109
|
+
currentCostume: number
|
|
110
|
+
variables: Record<string, JsonValue>
|
|
111
|
+
lists: Record<string, JsonValue[]>
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface VMSnapshot {
|
|
115
|
+
runId: number
|
|
116
|
+
nowMs: number
|
|
117
|
+
running: boolean
|
|
118
|
+
answer: string
|
|
119
|
+
musicTempo: number
|
|
120
|
+
textToSpeechLanguage: string
|
|
121
|
+
activeThreads: number
|
|
122
|
+
targets: VMSnapshotTarget[]
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface PlaySoundEffect {
|
|
126
|
+
type: 'play_sound'
|
|
127
|
+
target: string
|
|
128
|
+
sound: string
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface MusicPlayNoteEffect {
|
|
132
|
+
type: 'music_play_note'
|
|
133
|
+
target: string
|
|
134
|
+
note: number
|
|
135
|
+
beats: number
|
|
136
|
+
instrument: number
|
|
137
|
+
tempo: number
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface MusicPlayDrumEffect {
|
|
141
|
+
type: 'music_play_drum'
|
|
142
|
+
target: string
|
|
143
|
+
drum: number
|
|
144
|
+
beats: number
|
|
145
|
+
tempo: number
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface TextToSpeechEffect {
|
|
149
|
+
type: 'text_to_speech'
|
|
150
|
+
target: string
|
|
151
|
+
words: string
|
|
152
|
+
voice: string
|
|
153
|
+
language: string
|
|
154
|
+
waitKey: string
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface TranslateRequestEffect {
|
|
158
|
+
type: 'translate_request'
|
|
159
|
+
words: string
|
|
160
|
+
language: string
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export interface StopAllSoundsEffect {
|
|
164
|
+
type: 'stop_all_sounds'
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface SayEffect {
|
|
168
|
+
type: 'say'
|
|
169
|
+
target: string
|
|
170
|
+
message: string
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface ThinkEffect {
|
|
174
|
+
type: 'think'
|
|
175
|
+
target: string
|
|
176
|
+
message: string
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface AskEffect {
|
|
180
|
+
type: 'ask'
|
|
181
|
+
question: string
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface BroadcastEffect {
|
|
185
|
+
type: 'broadcast'
|
|
186
|
+
message: string
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface LogEffect {
|
|
190
|
+
type: 'log'
|
|
191
|
+
level: string
|
|
192
|
+
message: string
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export interface UnknownEffect {
|
|
196
|
+
type: string
|
|
197
|
+
[key: string]: JsonValue
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
type KnownEffect =
|
|
201
|
+
| PlaySoundEffect
|
|
202
|
+
| MusicPlayNoteEffect
|
|
203
|
+
| MusicPlayDrumEffect
|
|
204
|
+
| TextToSpeechEffect
|
|
205
|
+
| TranslateRequestEffect
|
|
206
|
+
| StopAllSoundsEffect
|
|
207
|
+
| SayEffect
|
|
208
|
+
| ThinkEffect
|
|
209
|
+
| AskEffect
|
|
210
|
+
| BroadcastEffect
|
|
211
|
+
| LogEffect
|
|
212
|
+
|
|
213
|
+
export type VMEffect = KnownEffect | UnknownEffect
|
|
214
|
+
|
|
215
|
+
export type TranslateCache = Record<string, Record<string, string>>
|
|
216
|
+
|
|
217
|
+
export interface EffectHandlers {
|
|
218
|
+
translate?: (
|
|
219
|
+
effect: TranslateRequestEffect,
|
|
220
|
+
) => MaybePromise<string | null | undefined>
|
|
221
|
+
textToSpeech?: (effect: TextToSpeechEffect) => MaybePromise<void>
|
|
222
|
+
musicNote?: (effect: MusicPlayNoteEffect) => MaybePromise<void>
|
|
223
|
+
musicDrum?: (effect: MusicPlayDrumEffect) => MaybePromise<void>
|
|
224
|
+
effect?: (effect: VMEffect) => MaybePromise<void>
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export interface CompileProjectToWatOptions {
|
|
228
|
+
projectJson: string | ProjectJson
|
|
229
|
+
assets?: string | Record<string, JsonValue>
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export interface ProgramManifest {
|
|
233
|
+
abiVersion: number
|
|
234
|
+
opcodeSetVersion: number
|
|
235
|
+
projectByteLength: number
|
|
236
|
+
assetsByteLength: number
|
|
237
|
+
buildFingerprint: string
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export interface CompileProjectToWatResult {
|
|
241
|
+
wat: string
|
|
242
|
+
abiVersion: number
|
|
243
|
+
manifest: ProgramManifest
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export type ProgramWasmBytes = Uint8Array | ArrayBuffer
|
|
247
|
+
|
|
248
|
+
export type WatToWasm = (wat: string) => ProgramWasmBytes
|
|
249
|
+
|
|
250
|
+
export interface CompileProjectToWasmOptions
|
|
251
|
+
extends CompileProjectToWatOptions {
|
|
252
|
+
watToWasm?: WatToWasm
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export interface CompileProjectToWasmResult extends CompileProjectToWatResult {
|
|
256
|
+
wasmBytes: Uint8Array
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export interface RuntimeHandle {
|
|
260
|
+
raw: unknown
|
|
261
|
+
abiVersion: number
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export interface ProgramPayload {
|
|
265
|
+
projectJson: string
|
|
266
|
+
assetsJson: string
|
|
267
|
+
commandsJson?: string
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export interface ProgramWasmExecHost {
|
|
271
|
+
getVarNumber: (targetIndex: number, variableId: string) => number
|
|
272
|
+
setVarNumber: (targetIndex: number, variableId: string, value: number) => void
|
|
273
|
+
setVarJson: (
|
|
274
|
+
targetIndex: number,
|
|
275
|
+
variableId: string,
|
|
276
|
+
valueJson: string,
|
|
277
|
+
) => void
|
|
278
|
+
execHostOpcode: (targetIndex: number, pc: number) => number
|
|
279
|
+
execHostTail: (targetIndex: number, startPc: number) => number
|
|
280
|
+
execDrawOpcode: (
|
|
281
|
+
targetIndex: number,
|
|
282
|
+
opcode: string,
|
|
283
|
+
arg0: number,
|
|
284
|
+
arg1: number,
|
|
285
|
+
extra: number,
|
|
286
|
+
) => number
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export type ProgramWasmExecRunner = () => number
|
|
290
|
+
|
|
291
|
+
export interface ProgramModule {
|
|
292
|
+
raw: WebAssembly.Instance
|
|
293
|
+
abiVersion: number
|
|
294
|
+
manifest: ProgramManifest
|
|
295
|
+
readPayload: () => ProgramPayload
|
|
296
|
+
hasWasmExec: () => boolean
|
|
297
|
+
createWasmExecRunner: (
|
|
298
|
+
host: ProgramWasmExecHost,
|
|
299
|
+
) => ProgramWasmExecRunner | null
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export interface CreateProgramModuleOptions {
|
|
303
|
+
wasmBytes: ProgramWasmBytes
|
|
304
|
+
manifest?: ProgramManifest
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export interface CreateProgramModuleFromProjectOptions
|
|
308
|
+
extends CompileProjectToWasmOptions {}
|
|
309
|
+
|
|
310
|
+
export interface PrecompileProgramForRuntimeOptions {
|
|
311
|
+
program: ProgramModule
|
|
312
|
+
runtime?: RuntimeHandle
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export interface CreateHeadlessVMOptions {
|
|
316
|
+
program: ProgramModule
|
|
317
|
+
runtime?: RuntimeHandle
|
|
318
|
+
options?: string | VMOptionsInput
|
|
319
|
+
initialNowMs?: number
|
|
320
|
+
viewerLanguage?: string
|
|
321
|
+
translateCache?: TranslateCache
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export interface CreateHeadlessVMFromProjectOptions
|
|
325
|
+
extends Omit<CreateHeadlessVMOptions, 'program'>,
|
|
326
|
+
CreateProgramModuleFromProjectOptions {}
|
|
327
|
+
|
|
328
|
+
export interface ScratchAssetResponse {
|
|
329
|
+
ok: boolean
|
|
330
|
+
status: number
|
|
331
|
+
statusText: string
|
|
332
|
+
arrayBuffer(): Promise<ArrayBuffer>
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export type FetchAsset = (url: string) => Promise<ScratchAssetResponse>
|
|
336
|
+
|
|
337
|
+
export type DecodeImageBytes = (bytes: ArrayBuffer | Uint8Array) => Promise<{
|
|
338
|
+
width: number
|
|
339
|
+
height: number
|
|
340
|
+
rgbaBase64: string
|
|
341
|
+
}>
|
|
342
|
+
|
|
343
|
+
export interface ResolveMissingScratchAssetsOptions {
|
|
344
|
+
projectJson: string | ProjectJson
|
|
345
|
+
assets?: string | Record<string, JsonValue>
|
|
346
|
+
scratchCdnBaseUrl?: string
|
|
347
|
+
fetchAsset?: FetchAsset
|
|
348
|
+
decodeImageBytes?: DecodeImageBytes
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export interface CreateHeadlessVMWithScratchAssetsOptions
|
|
352
|
+
extends Omit<CreateHeadlessVMFromProjectOptions, 'assets'> {
|
|
353
|
+
projectJson: string | ProjectJson
|
|
354
|
+
assets?: string | Record<string, JsonValue>
|
|
355
|
+
scratchCdnBaseUrl?: string
|
|
356
|
+
fetchAsset?: FetchAsset
|
|
357
|
+
decodeImageBytes?: DecodeImageBytes
|
|
358
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vite-plus/test'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
hasNumberField,
|
|
5
|
+
hasStringField,
|
|
6
|
+
isObjectRecord,
|
|
7
|
+
} from './value-guards.ts'
|
|
8
|
+
|
|
9
|
+
describe('moonscratch/js/vm/value-guards.ts', () => {
|
|
10
|
+
test('isObjectRecord identifies plain objects', () => {
|
|
11
|
+
expect(isObjectRecord({ foo: 'bar' })).toBe(true)
|
|
12
|
+
expect(isObjectRecord(null)).toBe(false)
|
|
13
|
+
expect(isObjectRecord([])).toBe(false)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('hasStringField validates string fields', () => {
|
|
17
|
+
expect(hasStringField({ name: 'Sprite1' }, 'name')).toBe(true)
|
|
18
|
+
expect(hasStringField({ name: 1 }, 'name')).toBe(false)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('hasNumberField validates number fields', () => {
|
|
22
|
+
expect(hasNumberField({ beats: 1.5 }, 'beats')).toBe(true)
|
|
23
|
+
expect(hasNumberField({ beats: '1.5' }, 'beats')).toBe(false)
|
|
24
|
+
})
|
|
25
|
+
})
|