sci2-ts 0.0.0-stage → 0.2.2

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 (357) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/LICENSE +21 -0
  3. package/README.md +113 -2
  4. package/apps/viewer/editor.html +43 -0
  5. package/apps/viewer/explore.html +27 -0
  6. package/apps/viewer/index.html +26 -0
  7. package/apps/viewer/package.json +27 -0
  8. package/apps/viewer/play.html +26 -0
  9. package/apps/viewer/plugin.d.ts +18 -0
  10. package/apps/viewer/plugin.js +75 -0
  11. package/apps/viewer/plugin.ts +84 -0
  12. package/apps/viewer/src/adlib.d.ts +20 -0
  13. package/apps/viewer/src/adlib.js +55 -0
  14. package/apps/viewer/src/adlib.ts +62 -0
  15. package/apps/viewer/src/app.d.ts +5 -0
  16. package/apps/viewer/src/app.js +5 -0
  17. package/apps/viewer/src/app.ts +5 -0
  18. package/apps/viewer/src/asset-index.d.ts +22 -0
  19. package/apps/viewer/src/asset-index.js +74 -0
  20. package/apps/viewer/src/asset-index.ts +90 -0
  21. package/apps/viewer/src/audio.d.ts +28 -0
  22. package/apps/viewer/src/audio.js +102 -0
  23. package/apps/viewer/src/audio.ts +110 -0
  24. package/apps/viewer/src/editor-handles.d.ts +63 -0
  25. package/apps/viewer/src/editor-handles.js +332 -0
  26. package/apps/viewer/src/editor-handles.ts +368 -0
  27. package/apps/viewer/src/editor-languages.d.ts +23 -0
  28. package/apps/viewer/src/editor-languages.js +157 -0
  29. package/apps/viewer/src/editor-languages.ts +135 -0
  30. package/apps/viewer/src/editor.css +213 -0
  31. package/apps/viewer/src/editor.d.ts +1 -0
  32. package/apps/viewer/src/editor.js +393 -0
  33. package/apps/viewer/src/editor.ts +412 -0
  34. package/apps/viewer/src/explore/classes.d.ts +7 -0
  35. package/apps/viewer/src/explore/classes.js +63 -0
  36. package/apps/viewer/src/explore/classes.ts +61 -0
  37. package/apps/viewer/src/explore/context.d.ts +33 -0
  38. package/apps/viewer/src/explore/context.js +96 -0
  39. package/apps/viewer/src/explore/context.ts +124 -0
  40. package/apps/viewer/src/explore/explore.css +93 -0
  41. package/apps/viewer/src/explore/explore.d.ts +1 -0
  42. package/apps/viewer/src/explore/explore.js +102 -0
  43. package/apps/viewer/src/explore/explore.ts +104 -0
  44. package/apps/viewer/src/explore/inspect.d.ts +5 -0
  45. package/apps/viewer/src/explore/inspect.js +208 -0
  46. package/apps/viewer/src/explore/inspect.ts +279 -0
  47. package/apps/viewer/src/explore/live.d.ts +22 -0
  48. package/apps/viewer/src/explore/live.js +318 -0
  49. package/apps/viewer/src/explore/live.ts +365 -0
  50. package/apps/viewer/src/explore/map.d.ts +11 -0
  51. package/apps/viewer/src/explore/map.js +182 -0
  52. package/apps/viewer/src/explore/map.ts +193 -0
  53. package/apps/viewer/src/files.d.ts +21 -0
  54. package/apps/viewer/src/files.js +69 -0
  55. package/apps/viewer/src/files.ts +72 -0
  56. package/apps/viewer/src/game-profile.d.ts +19 -0
  57. package/apps/viewer/src/game-profile.js +4 -0
  58. package/apps/viewer/src/game-profile.ts +26 -0
  59. package/apps/viewer/src/main.d.ts +1 -0
  60. package/apps/viewer/src/main.js +473 -0
  61. package/apps/viewer/src/main.ts +527 -0
  62. package/apps/viewer/src/music.d.ts +14 -0
  63. package/apps/viewer/src/music.js +32 -0
  64. package/apps/viewer/src/music.ts +34 -0
  65. package/apps/viewer/src/opl-worklet.d.ts +1 -0
  66. package/apps/viewer/src/opl-worklet.js +47 -0
  67. package/apps/viewer/src/opl-worklet.ts +55 -0
  68. package/apps/viewer/src/overlay.d.ts +31 -0
  69. package/apps/viewer/src/overlay.js +103 -0
  70. package/apps/viewer/src/overlay.ts +123 -0
  71. package/apps/viewer/src/play.css +14 -0
  72. package/apps/viewer/src/play.d.ts +1 -0
  73. package/apps/viewer/src/play.js +179 -0
  74. package/apps/viewer/src/play.ts +177 -0
  75. package/apps/viewer/src/saves.d.ts +10 -0
  76. package/apps/viewer/src/saves.js +50 -0
  77. package/apps/viewer/src/saves.ts +51 -0
  78. package/apps/viewer/src/session.d.ts +91 -0
  79. package/apps/viewer/src/session.js +301 -0
  80. package/apps/viewer/src/session.ts +321 -0
  81. package/apps/viewer/src/sound-preview.d.ts +7 -0
  82. package/apps/viewer/src/sound-preview.js +214 -0
  83. package/apps/viewer/src/sound-preview.ts +228 -0
  84. package/apps/viewer/src/style.css +64 -0
  85. package/apps/viewer/vite.config.ts +25 -0
  86. package/bin/sci-ts.mjs +9 -0
  87. package/lib/994.sc +49 -0
  88. package/lib/995.sc +53 -0
  89. package/lib/996.sc +299 -0
  90. package/lib/997.sc +198 -0
  91. package/lib/998.sc +338 -0
  92. package/lib/999.sc +189 -0
  93. package/lib/resources.d.ts +2 -0
  94. package/lib/resources.js +84 -0
  95. package/lib/resources.ts +88 -0
  96. package/lib/system.sh +62 -0
  97. package/package.json +75 -4
  98. package/packages/content/room.schema.json +267 -0
  99. package/packages/content/src/index.d.ts +3 -0
  100. package/packages/content/src/index.js +3 -0
  101. package/packages/content/src/index.ts +3 -0
  102. package/packages/content/src/room.d.ts +111 -0
  103. package/packages/content/src/room.js +886 -0
  104. package/packages/content/src/room.ts +1011 -0
  105. package/packages/content/src/target.d.ts +80 -0
  106. package/packages/content/src/target.js +1 -0
  107. package/packages/content/src/target.ts +60 -0
  108. package/packages/content/src/yarn.d.ts +90 -0
  109. package/packages/content/src/yarn.js +214 -0
  110. package/packages/content/src/yarn.ts +261 -0
  111. package/packages/sci/src/analysis/game.d.ts +90 -0
  112. package/packages/sci/src/analysis/game.js +221 -0
  113. package/packages/sci/src/analysis/game.ts +305 -0
  114. package/packages/sci/src/analysis/runtime.d.ts +58 -0
  115. package/packages/sci/src/analysis/runtime.js +133 -0
  116. package/packages/sci/src/analysis/runtime.ts +163 -0
  117. package/packages/sci/src/audio/adlib.d.ts +73 -0
  118. package/packages/sci/src/audio/adlib.js +280 -0
  119. package/packages/sci/src/audio/adlib.ts +329 -0
  120. package/packages/sci/src/audio/index.d.ts +43 -0
  121. package/packages/sci/src/audio/index.js +99 -0
  122. package/packages/sci/src/audio/index.ts +122 -0
  123. package/packages/sci/src/audio/midi.d.ts +92 -0
  124. package/packages/sci/src/audio/midi.js +242 -0
  125. package/packages/sci/src/audio/midi.ts +262 -0
  126. package/packages/sci/src/audio/opl2.d.ts +74 -0
  127. package/packages/sci/src/audio/opl2.js +283 -0
  128. package/packages/sci/src/audio/opl2.ts +283 -0
  129. package/packages/sci/src/audio/sol.d.ts +32 -0
  130. package/packages/sci/src/audio/sol.js +81 -0
  131. package/packages/sci/src/audio/sol.ts +100 -0
  132. package/packages/sci/src/audio/write.d.ts +35 -0
  133. package/packages/sci/src/audio/write.js +91 -0
  134. package/packages/sci/src/audio/write.ts +108 -0
  135. package/packages/sci/src/gfx/cel.d.ts +36 -0
  136. package/packages/sci/src/gfx/cel.js +82 -0
  137. package/packages/sci/src/gfx/cel.ts +110 -0
  138. package/packages/sci/src/gfx/palette.d.ts +24 -0
  139. package/packages/sci/src/gfx/palette.js +49 -0
  140. package/packages/sci/src/gfx/palette.ts +56 -0
  141. package/packages/sci/src/gfx/pic-file.d.ts +46 -0
  142. package/packages/sci/src/gfx/pic-file.js +114 -0
  143. package/packages/sci/src/gfx/pic-file.ts +155 -0
  144. package/packages/sci/src/gfx/pic.d.ts +27 -0
  145. package/packages/sci/src/gfx/pic.js +34 -0
  146. package/packages/sci/src/gfx/pic.ts +53 -0
  147. package/packages/sci/src/gfx/quantize.d.ts +42 -0
  148. package/packages/sci/src/gfx/quantize.js +199 -0
  149. package/packages/sci/src/gfx/quantize.ts +218 -0
  150. package/packages/sci/src/gfx/view-file.d.ts +62 -0
  151. package/packages/sci/src/gfx/view-file.js +182 -0
  152. package/packages/sci/src/gfx/view-file.ts +233 -0
  153. package/packages/sci/src/gfx/view.d.ts +28 -0
  154. package/packages/sci/src/gfx/view.js +45 -0
  155. package/packages/sci/src/gfx/view.ts +62 -0
  156. package/packages/sci/src/index.d.ts +38 -0
  157. package/packages/sci/src/index.js +38 -0
  158. package/packages/sci/src/index.ts +38 -0
  159. package/packages/sci/src/motion/pathfinding.d.ts +40 -0
  160. package/packages/sci/src/motion/pathfinding.js +140 -0
  161. package/packages/sci/src/motion/pathfinding.ts +147 -0
  162. package/packages/sci/src/resource/archive.d.ts +16 -0
  163. package/packages/sci/src/resource/archive.js +51 -0
  164. package/packages/sci/src/resource/archive.ts +56 -0
  165. package/packages/sci/src/resource/lzs.d.ts +12 -0
  166. package/packages/sci/src/resource/lzs.js +64 -0
  167. package/packages/sci/src/resource/lzs.ts +66 -0
  168. package/packages/sci/src/resource/manager.d.ts +45 -0
  169. package/packages/sci/src/resource/manager.js +158 -0
  170. package/packages/sci/src/resource/manager.ts +184 -0
  171. package/packages/sci/src/resource/map.d.ts +18 -0
  172. package/packages/sci/src/resource/map.js +34 -0
  173. package/packages/sci/src/resource/map.ts +44 -0
  174. package/packages/sci/src/resource/types.d.ts +37 -0
  175. package/packages/sci/src/resource/types.js +45 -0
  176. package/packages/sci/src/resource/types.ts +57 -0
  177. package/packages/sci/src/script/assembly.d.ts +67 -0
  178. package/packages/sci/src/script/assembly.js +748 -0
  179. package/packages/sci/src/script/assembly.ts +714 -0
  180. package/packages/sci/src/script/compiler.d.ts +75 -0
  181. package/packages/sci/src/script/compiler.js +993 -0
  182. package/packages/sci/src/script/compiler.ts +1040 -0
  183. package/packages/sci/src/script/disasm.d.ts +81 -0
  184. package/packages/sci/src/script/disasm.js +318 -0
  185. package/packages/sci/src/script/disasm.ts +323 -0
  186. package/packages/sci/src/script/kernel-names.d.ts +6 -0
  187. package/packages/sci/src/script/kernel-names.js +165 -0
  188. package/packages/sci/src/script/kernel-names.ts +165 -0
  189. package/packages/sci/src/script/opcodes.d.ts +22 -0
  190. package/packages/sci/src/script/opcodes.js +45 -0
  191. package/packages/sci/src/script/opcodes.ts +72 -0
  192. package/packages/sci/src/script/script.d.ts +56 -0
  193. package/packages/sci/src/script/script.js +63 -0
  194. package/packages/sci/src/script/script.ts +111 -0
  195. package/packages/sci/src/script/sexpr.d.ts +45 -0
  196. package/packages/sci/src/script/sexpr.js +151 -0
  197. package/packages/sci/src/script/sexpr.ts +139 -0
  198. package/packages/sci/src/script/vocab.d.ts +15 -0
  199. package/packages/sci/src/script/vocab.js +44 -0
  200. package/packages/sci/src/script/vocab.ts +48 -0
  201. package/packages/sci/src/script/world.d.ts +25 -0
  202. package/packages/sci/src/script/world.js +65 -0
  203. package/packages/sci/src/script/world.ts +70 -0
  204. package/packages/sci/src/text/font.d.ts +37 -0
  205. package/packages/sci/src/text/font.js +103 -0
  206. package/packages/sci/src/text/font.ts +125 -0
  207. package/packages/sci/src/text/message.d.ts +69 -0
  208. package/packages/sci/src/text/message.js +171 -0
  209. package/packages/sci/src/text/message.ts +205 -0
  210. package/packages/sci/src/vm/kernels/arrays.d.ts +19 -0
  211. package/packages/sci/src/vm/kernels/arrays.js +182 -0
  212. package/packages/sci/src/vm/kernels/arrays.ts +164 -0
  213. package/packages/sci/src/vm/kernels/audio.d.ts +70 -0
  214. package/packages/sci/src/vm/kernels/audio.js +347 -0
  215. package/packages/sci/src/vm/kernels/audio.ts +349 -0
  216. package/packages/sci/src/vm/kernels/core.d.ts +5 -0
  217. package/packages/sci/src/vm/kernels/core.js +87 -0
  218. package/packages/sci/src/vm/kernels/core.ts +91 -0
  219. package/packages/sci/src/vm/kernels/graphics.d.ts +141 -0
  220. package/packages/sci/src/vm/kernels/graphics.js +467 -0
  221. package/packages/sci/src/vm/kernels/graphics.ts +502 -0
  222. package/packages/sci/src/vm/kernels/index.d.ts +8 -0
  223. package/packages/sci/src/vm/kernels/index.js +29 -0
  224. package/packages/sci/src/vm/kernels/index.ts +32 -0
  225. package/packages/sci/src/vm/kernels/lists.d.ts +6 -0
  226. package/packages/sci/src/vm/kernels/lists.js +127 -0
  227. package/packages/sci/src/vm/kernels/lists.ts +111 -0
  228. package/packages/sci/src/vm/kernels/motion.d.ts +7 -0
  229. package/packages/sci/src/vm/kernels/motion.js +204 -0
  230. package/packages/sci/src/vm/kernels/motion.ts +194 -0
  231. package/packages/sci/src/vm/kernels/palette.d.ts +82 -0
  232. package/packages/sci/src/vm/kernels/palette.js +292 -0
  233. package/packages/sci/src/vm/kernels/palette.ts +303 -0
  234. package/packages/sci/src/vm/kernels/saves.d.ts +45 -0
  235. package/packages/sci/src/vm/kernels/saves.js +123 -0
  236. package/packages/sci/src/vm/kernels/saves.ts +149 -0
  237. package/packages/sci/src/vm/kernels/system.d.ts +62 -0
  238. package/packages/sci/src/vm/kernels/system.js +110 -0
  239. package/packages/sci/src/vm/kernels/system.ts +128 -0
  240. package/packages/sci/src/vm/kernels/text.d.ts +13 -0
  241. package/packages/sci/src/vm/kernels/text.js +338 -0
  242. package/packages/sci/src/vm/kernels/text.ts +328 -0
  243. package/packages/sci/src/vm/memory.d.ts +138 -0
  244. package/packages/sci/src/vm/memory.js +168 -0
  245. package/packages/sci/src/vm/memory.ts +266 -0
  246. package/packages/sci/src/vm/replace.d.ts +11 -0
  247. package/packages/sci/src/vm/replace.js +14 -0
  248. package/packages/sci/src/vm/replace.ts +16 -0
  249. package/packages/sci/src/vm/savegame.d.ts +89 -0
  250. package/packages/sci/src/vm/savegame.js +161 -0
  251. package/packages/sci/src/vm/savegame.ts +188 -0
  252. package/packages/sci/src/vm/value.d.ts +17 -0
  253. package/packages/sci/src/vm/value.js +12 -0
  254. package/packages/sci/src/vm/value.ts +25 -0
  255. package/packages/sci/src/vm/vm.d.ts +155 -0
  256. package/packages/sci/src/vm/vm.js +1053 -0
  257. package/packages/sci/src/vm/vm.ts +914 -0
  258. package/tools/adlib-render.d.ts +1 -0
  259. package/tools/adlib-render.js +49 -0
  260. package/tools/adlib-render.ts +42 -0
  261. package/tools/analyze.d.ts +1 -0
  262. package/tools/analyze.js +25 -0
  263. package/tools/analyze.ts +25 -0
  264. package/tools/art/build.d.ts +34 -0
  265. package/tools/art/build.js +175 -0
  266. package/tools/art/build.ts +153 -0
  267. package/tools/art.d.ts +1 -0
  268. package/tools/art.js +31 -0
  269. package/tools/art.ts +29 -0
  270. package/tools/asm.d.ts +1 -0
  271. package/tools/asm.js +61 -0
  272. package/tools/asm.ts +55 -0
  273. package/tools/cli.d.ts +1 -0
  274. package/tools/cli.js +99 -0
  275. package/tools/cli.ts +100 -0
  276. package/tools/editor-server.d.ts +29 -0
  277. package/tools/editor-server.js +162 -0
  278. package/tools/editor-server.ts +176 -0
  279. package/tools/explore.d.ts +1 -0
  280. package/tools/explore.js +305 -0
  281. package/tools/explore.ts +250 -0
  282. package/tools/game/audio.d.ts +19 -0
  283. package/tools/game/audio.js +143 -0
  284. package/tools/game/audio.ts +130 -0
  285. package/tools/game/build.d.ts +46 -0
  286. package/tools/game/build.js +259 -0
  287. package/tools/game/build.ts +283 -0
  288. package/tools/game/defaults.d.ts +26 -0
  289. package/tools/game/defaults.js +82 -0
  290. package/tools/game/defaults.ts +83 -0
  291. package/tools/game/font.d.ts +3 -0
  292. package/tools/game/font.js +124 -0
  293. package/tools/game/font.ts +126 -0
  294. package/tools/game/kit.d.ts +7 -0
  295. package/tools/game/kit.js +7 -0
  296. package/tools/game/kit.ts +10 -0
  297. package/tools/game/new.d.ts +8 -0
  298. package/tools/game/new.js +39 -0
  299. package/tools/game/new.ts +38 -0
  300. package/tools/game/target.d.ts +7 -0
  301. package/tools/game/target.js +41 -0
  302. package/tools/game/target.ts +42 -0
  303. package/tools/game/template/resources.ts.tmpl +43 -0
  304. package/tools/game/template/rooms/1.room.yaml +9 -0
  305. package/tools/game/template/rooms/1.yarn +17 -0
  306. package/tools/game/template/scripts/0.sc +24 -0
  307. package/tools/game.d.ts +1 -0
  308. package/tools/game.js +77 -0
  309. package/tools/game.ts +70 -0
  310. package/tools/mod-build.d.ts +28 -0
  311. package/tools/mod-build.js +173 -0
  312. package/tools/mod-build.ts +179 -0
  313. package/tools/mod-target.d.ts +16 -0
  314. package/tools/mod-target.js +26 -0
  315. package/tools/mod-target.ts +32 -0
  316. package/tools/mod.d.ts +1 -0
  317. package/tools/mod.js +47 -0
  318. package/tools/mod.ts +45 -0
  319. package/tools/msg.d.ts +1 -0
  320. package/tools/msg.js +40 -0
  321. package/tools/msg.ts +36 -0
  322. package/tools/node-files.d.ts +3 -0
  323. package/tools/node-files.js +34 -0
  324. package/tools/node-files.ts +33 -0
  325. package/tools/patches.d.ts +3 -0
  326. package/tools/patches.js +13 -0
  327. package/tools/patches.ts +13 -0
  328. package/tools/pic-build.d.ts +9 -0
  329. package/tools/pic-build.js +26 -0
  330. package/tools/pic-build.ts +27 -0
  331. package/tools/pic.d.ts +1 -0
  332. package/tools/pic.js +64 -0
  333. package/tools/pic.ts +61 -0
  334. package/tools/play.d.ts +1 -0
  335. package/tools/play.js +54 -0
  336. package/tools/play.ts +54 -0
  337. package/tools/png.d.ts +16 -0
  338. package/tools/png.js +124 -0
  339. package/tools/png.ts +123 -0
  340. package/tools/res.d.ts +1 -0
  341. package/tools/res.js +48 -0
  342. package/tools/res.ts +50 -0
  343. package/tools/scenario.d.ts +1 -0
  344. package/tools/scenario.js +74 -0
  345. package/tools/scenario.ts +80 -0
  346. package/tools/site/build.d.ts +8 -0
  347. package/tools/site/build.js +110 -0
  348. package/tools/site/build.ts +116 -0
  349. package/tools/site/markdown.d.ts +11 -0
  350. package/tools/site/markdown.js +88 -0
  351. package/tools/site/markdown.ts +84 -0
  352. package/tools/view-build.d.ts +34 -0
  353. package/tools/view-build.js +47 -0
  354. package/tools/view-build.ts +71 -0
  355. package/tools/view.d.ts +1 -0
  356. package/tools/view.js +54 -0
  357. package/tools/view.ts +52 -0
@@ -0,0 +1,527 @@
1
+ import {
2
+ ResourceType,
3
+ ScriptWorld,
4
+ disassemble,
5
+ formatDisassembly,
6
+ celToRgba,
7
+ emptyPalette,
8
+ hasRef,
9
+ mergePalette,
10
+ parseFont,
11
+ parseMessages,
12
+ renderText,
13
+ wrapText,
14
+ parseHunkPalette,
15
+ parsePic,
16
+ parseView,
17
+ resourceTypeName,
18
+ type Cel,
19
+ type Font,
20
+ type Palette,
21
+ type ResourceInfo,
22
+ } from "../../../packages/sci/src/index.ts";
23
+ import { assetKey, buildAssetIndex, matches, type AssetIndex } from "./asset-index.ts";
24
+ import { openGame } from "./files.ts";
25
+ import { renderSound, soundSummary, stopSoundPreview, toggleSoundPreview } from "./sound-preview.ts";
26
+
27
+
28
+ const hexDump = (data: Uint8Array, max = 2048): string => {
29
+ const lines: string[] = [];
30
+ for (let i = 0; i < Math.min(data.length, max); i += 16) {
31
+ const row = data.subarray(i, i + 16);
32
+ const hex = [...row].map((b) => b.toString(16).padStart(2, "0")).join(" ");
33
+ const ascii = [...row].map((b) => (b >= 32 && b < 127 ? String.fromCharCode(b) : ".")).join("");
34
+ lines.push(`${i.toString(16).padStart(8, "0")} ${hex.padEnd(47)} ${ascii}`);
35
+ }
36
+ if (data.length > max) lines.push(`… ${data.length - max} more bytes`);
37
+ return lines.join("\n");
38
+ };
39
+
40
+ const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
41
+ const el = <K extends keyof HTMLElementTagNameMap>(tag: K, props: Record<string, unknown> = {}): HTMLElementTagNameMap[K] =>
42
+ Object.assign(document.createElement(tag), props);
43
+
44
+ // Mods show up as patch resources (?mods=, or the player's last choice).
45
+ const { rm } = await openGame();
46
+ const all = rm.list();
47
+ const basePalette =
48
+ parseHunkPalette((await rm.load({ type: ResourceType.Palette, number: 999 })).data) ?? emptyPalette();
49
+
50
+ /** Palette a room pic installs, used to colour views the way they'd appear in that room. */
51
+ const roomPalette = async (picNo: number | undefined): Promise<Palette> =>
52
+ picNo === undefined ? basePalette : mergePalette(basePalette, parsePic((await rm.load({ type: ResourceType.Pic, number: picNo })).data).palette);
53
+
54
+ // SCI's 320x200 was shown on 4:3 monitors, so pixels were 1.2x taller than wide.
55
+ let aspect = true;
56
+ const SCALE = 3;
57
+ // Remap colours (shadows): approximate as translucent black until scripts drive them.
58
+ const REMAP = new Map([[253, 64], [254, 110]]);
59
+
60
+ const celCanvas = (cel: Cel, palette: Palette, mirror = false): HTMLCanvasElement => {
61
+ const canvas = el("canvas", { width: Math.max(cel.width, 1), height: Math.max(cel.height, 1) });
62
+ if (cel.width && cel.height) {
63
+ canvas.getContext("2d")!.putImageData(new ImageData(celToRgba(cel, palette, { mirror, remap: REMAP }), cel.width, cel.height), 0, 0);
64
+ }
65
+ return sized(canvas);
66
+ };
67
+
68
+ const sized = (canvas: HTMLCanvasElement, scale = SCALE) => {
69
+ canvas.style.width = `${canvas.width * scale}px`;
70
+ canvas.style.height = `${canvas.height * scale * (aspect ? 1.2 : 1)}px`;
71
+ return canvas;
72
+ };
73
+
74
+ // --- Renderers ---------------------------------------------------------------
75
+
76
+ const renderPalette = (pal: Palette): HTMLElement => {
77
+ const grid = el("div", { className: "swatches" });
78
+ for (let i = 0; i < 256; i++) {
79
+ const [r, g, b] = pal.rgb.subarray(i * 3, i * 3 + 3);
80
+ grid.append(
81
+ el("div", {
82
+ className: pal.used[i] ? "sw" : "sw unused",
83
+ title: `${i}: rgb(${r}, ${g}, ${b})${pal.used[i] ? "" : " (not set here)"}`,
84
+ style: `background: rgb(${r} ${g} ${b})`,
85
+ }),
86
+ );
87
+ }
88
+ return grid;
89
+ };
90
+
91
+ const renderPic = (data: Uint8Array): HTMLElement => {
92
+ const pic = parsePic(data);
93
+ const canvas = composePic(data);
94
+ const wrap = el("div");
95
+ wrap.append(
96
+ sized(canvas),
97
+ el("p", {
98
+ className: "meta",
99
+ textContent: `${pic.width}×${pic.height} · ${pic.cels.length} layer(s): ${pic.cels.map((c) => `${c.width}×${c.height} @${c.x},${c.y} pri ${c.priority}`).join("; ")}`,
100
+ }),
101
+ );
102
+ return wrap;
103
+ };
104
+
105
+ let animTimer: number | undefined;
106
+
107
+ const renderView = async (data: Uint8Array): Promise<HTMLElement> => {
108
+ const view = parseView(data);
109
+ const wrap = el("div");
110
+
111
+ const picSelect = el("select");
112
+ picSelect.append(el("option", { value: "", textContent: "base palette only" }));
113
+ for (const p of rm.list(ResourceType.Pic)) picSelect.append(el("option", { value: String(p.number), textContent: `room palette: pic ${p.number}` }));
114
+ const body = el("div");
115
+
116
+ const draw = async () => {
117
+ clearInterval(animTimer);
118
+ const room = await roomPalette(picSelect.value ? Number(picSelect.value) : undefined);
119
+ const palette = mergePalette(room, view.palette);
120
+ body.replaceChildren();
121
+
122
+ view.loops.forEach((loop, i) => {
123
+ const row = el("div", { className: "loop" });
124
+ row.append(el("span", { className: "meta", textContent: `loop ${i}${loop.mirror ? " (mirror)" : ""}` }));
125
+ for (const cel of loop.cels) row.append(celCanvas(cel, palette, loop.mirror));
126
+ body.append(row);
127
+ });
128
+
129
+ // Animate loop 0 like the game would, anchored at the cel's origin point.
130
+ const loop = view.loops[0];
131
+ if (loop && loop.cels.length > 1) {
132
+ const stage = el("div", { className: "stage" });
133
+ body.prepend(stage);
134
+ let frame = 0;
135
+ const tick = () => stage.replaceChildren(celCanvas(loop.cels[frame++ % loop.cels.length]!, palette, loop.mirror));
136
+ tick();
137
+ animTimer = window.setInterval(tick, 120);
138
+ }
139
+ };
140
+
141
+ picSelect.onchange = draw;
142
+ wrap.append(
143
+ el("p", { className: "meta", textContent: `${view.loops.length} loops · ${view.palette ? "embedded palette" : "no embedded palette"}` }),
144
+ picSelect,
145
+ body,
146
+ );
147
+ await draw();
148
+ return wrap;
149
+ };
150
+
151
+ /** Draws a 1-bit text mask onto a canvas in the given colour. */
152
+ const textCanvas = (font: Font, lines: string[], color: [number, number, number], align: "left" | "center" = "left") => {
153
+ const bmp = renderText(font, lines, align);
154
+ const canvas = el("canvas", { width: bmp.width, height: bmp.height });
155
+ const rgba = new Uint8ClampedArray(bmp.width * bmp.height * 4);
156
+ bmp.pixels.forEach((ink, i) => {
157
+ if (ink) rgba.set([...color, 255], i * 4);
158
+ });
159
+ canvas.getContext("2d")!.putImageData(new ImageData(rgba, bmp.width, bmp.height), 0, 0);
160
+ return sized(canvas);
161
+ };
162
+
163
+ const loadFont = async (n: number) => parseFont((await rm.load({ type: ResourceType.Font, number: n })).data);
164
+
165
+ const renderFont = (data: Uint8Array): HTMLElement => {
166
+ const font = parseFont(data);
167
+ const wrap = el("div");
168
+ const chars = font.glyphs.map((_, i) => (i >= 32 ? String.fromCharCode(i) : "")).join("");
169
+ const rows = chars.match(/.{1,32}/g) ?? [];
170
+ wrap.append(
171
+ el("p", { className: "meta", textContent: `${font.glyphs.length} glyphs · line height ${font.height}px` }),
172
+ textCanvas(font, rows, [232, 176, 74]),
173
+ el("p", { className: "meta", textContent: "Sample" }),
174
+ textCanvas(font, wrapText(font, "The quick brown fox jumps over the lazy dog. 0123456789, punctuation: ?!;:", 200), [221, 221, 221]),
175
+ );
176
+ return wrap;
177
+ };
178
+
179
+ // Talker ids come from scripts; these two are clear from usage counts across all messages.
180
+ const TALKERS: Record<number, string> = { 97: "Hero", 99: "Narrator" };
181
+
182
+ const renderMessages = async (data: Uint8Array, room: number): Promise<HTMLElement> => {
183
+ const file = parseMessages(data);
184
+ const fontSelect = el("select");
185
+ for (const f of rm.list(ResourceType.Font)) fontSelect.append(el("option", { value: String(f.number), textContent: `font ${f.number}` }));
186
+ const box = el("div", { className: "dialog" });
187
+ const table = el("table", { className: "messages" });
188
+ table.innerHTML = "<thead><tr><th>noun</th><th>verb</th><th>cond</th><th>seq</th><th>talker</th><th>text</th></tr></thead>";
189
+ const tbody = el("tbody");
190
+ table.append(tbody);
191
+
192
+ let selected = file.records[0];
193
+ const preview = async () => {
194
+ if (!selected) return;
195
+ const font = await loadFont(Number(fontSelect.value));
196
+ const target = hasRef(selected) ? file.records.find((r) => r.noun === selected!.ref.noun && r.verb === selected!.ref.verb && r.cond === selected!.ref.cond && r.seq === selected!.ref.seq) : selected;
197
+ const text = target?.text ?? "(missing reference)";
198
+ box.replaceChildren(textCanvas(font, wrapText(font, text, 200), [255, 255, 255]));
199
+ };
200
+
201
+ for (const r of file.records) {
202
+ const tr = el("tr");
203
+ const text = hasRef(r) ? `→ ${r.ref.noun}/${r.ref.verb}/${r.ref.cond}/${r.ref.seq}` : r.text;
204
+ for (const cell of [r.noun, r.verb, r.cond, r.seq, TALKERS[r.talker] ?? r.talker, text]) tr.append(el("td", { textContent: String(cell) }));
205
+ tr.onclick = () => {
206
+ tbody.querySelector(".active")?.classList.remove("active");
207
+ tr.classList.add("active");
208
+ selected = r;
209
+ preview();
210
+ };
211
+ tbody.append(tr);
212
+ }
213
+ fontSelect.onchange = preview;
214
+ await preview();
215
+
216
+ const wrap = el("div");
217
+ wrap.append(
218
+ el("p", { className: "meta", textContent: `room/module ${room} · format v${file.version} · ${file.records.length} messages · click a row to preview in a game font` }),
219
+ fontSelect,
220
+ box,
221
+ table,
222
+ );
223
+ return wrap;
224
+ };
225
+
226
+ let worldPromise: Promise<ScriptWorld> | undefined;
227
+ const getWorld = () =>
228
+ (worldPromise ??= ScriptWorld.open(rm).then(async (w) => {
229
+ await w.loadAllClasses();
230
+ return w;
231
+ }));
232
+
233
+ const escapeHtml = (s: string) => s.replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]!);
234
+
235
+ const renderScript = async (number: number): Promise<HTMLElement> => {
236
+ const world = await getWorld();
237
+ const script = await world.script(number);
238
+ const functions = disassemble(script, world.context());
239
+ const listing = el("pre", { className: "asm" });
240
+ listing.innerHTML = formatDisassembly(script, functions)
241
+ .split("\n")
242
+ .map((line) => {
243
+ const html = escapeHtml(line);
244
+ if (line.startsWith("(")) return `<span class="fn" id="fn-${line.slice(1, -1).replace(/\W/g, "_")}">${html}</span>`;
245
+ if (line.startsWith(";")) return `<span class="c">${html}</span>`;
246
+ return html.replace(/(;.*)$/, '<span class="c">$1</span>');
247
+ })
248
+ .join("\n");
249
+
250
+ const jump = el("select");
251
+ jump.append(el("option", { textContent: `${functions.length} functions…` }));
252
+ for (const fn of functions) jump.append(el("option", { value: fn.label.replace(/\W/g, "_"), textContent: fn.label }));
253
+ jump.onchange = () => document.getElementById(`fn-${jump.value}`)?.scrollIntoView({ block: "start" });
254
+
255
+ const wrap = el("div");
256
+ wrap.append(
257
+ el("p", {
258
+ className: "meta",
259
+ textContent: `${script.objects.length} objects · ${script.locals.length} locals · ${functions.reduce((n, f) => n + f.instructions.length, 0)} instructions`,
260
+ }),
261
+ jump,
262
+ listing,
263
+ );
264
+ return wrap;
265
+ };
266
+
267
+ // --- Thumbnails ----------------------------------------------------------------
268
+
269
+ /** A picture's layers composed at full size (the list scales it down with CSS). */
270
+ const composePic = (data: Uint8Array): HTMLCanvasElement => {
271
+ const pic = parsePic(data);
272
+ const palette = mergePalette(basePalette, pic.palette);
273
+ const canvas = el("canvas", { width: pic.width, height: pic.height });
274
+ const ctx = canvas.getContext("2d")!;
275
+ for (const cel of [...pic.cels].sort((a, b) => a.priority - b.priority)) {
276
+ if (!cel.width || !cel.height) continue;
277
+ const layer = el("canvas", { width: cel.width, height: cel.height });
278
+ layer.getContext("2d")!.putImageData(new ImageData(celToRgba(cel, palette, { remap: REMAP }), cel.width, cel.height), 0, 0);
279
+ ctx.drawImage(layer, cel.x, cel.y);
280
+ }
281
+ return canvas;
282
+ };
283
+
284
+ /** What a list row shows next to the number: a picture, a sprite, a font sample, a line. */
285
+ const thumbnail = async (info: ResourceInfo): Promise<HTMLElement | undefined> => {
286
+ const { data } = await rm.load(info);
287
+ switch (info.type) {
288
+ case ResourceType.Pic:
289
+ return composePic(data);
290
+ case ResourceType.View: {
291
+ const view = parseView(data);
292
+ const cel = view.loops.flatMap((l) => l.cels).find((c) => c.width && c.height);
293
+ if (!cel) return undefined;
294
+ const palette = mergePalette(basePalette, view.palette);
295
+ const canvas = el("canvas", { width: cel.width, height: cel.height });
296
+ canvas.getContext("2d")!.putImageData(new ImageData(celToRgba(cel, palette, { remap: REMAP }), cel.width, cel.height), 0, 0);
297
+ return canvas;
298
+ }
299
+ case ResourceType.Font: {
300
+ const font = parseFont(data);
301
+ const bmp = renderText(font, ["Aa Bb 123"], "left");
302
+ const canvas = el("canvas", { width: Math.max(bmp.width, 1), height: Math.max(bmp.height, 1) });
303
+ const rgba = new Uint8ClampedArray(canvas.width * canvas.height * 4);
304
+ bmp.pixels.forEach((ink, i) => ink && rgba.set([221, 221, 221, 255], i * 4));
305
+ canvas.getContext("2d")!.putImageData(new ImageData(rgba, canvas.width, canvas.height), 0, 0);
306
+ return canvas;
307
+ }
308
+ case ResourceType.Message: {
309
+ const first = parseMessages(data).records.find((r) => r.text);
310
+ return first ? el("span", { className: "snippet", textContent: first.text }) : undefined;
311
+ }
312
+ case ResourceType.Sound:
313
+ return el("span", { className: "snippet", textContent: soundSummary(data) });
314
+ default:
315
+ return undefined;
316
+ }
317
+ };
318
+
319
+ /** Rows render their thumbnail when they scroll into view. */
320
+ const thumbs = new IntersectionObserver(
321
+ (entries) => {
322
+ for (const entry of entries) {
323
+ if (!entry.isIntersecting) continue;
324
+ const li = entry.target as HTMLLIElement & { info?: ResourceInfo };
325
+ thumbs.unobserve(li);
326
+ const slot = li.querySelector(".thumb");
327
+ if (!slot || !li.info) continue;
328
+ thumbnail(li.info)
329
+ .then((t) => t && slot.replaceChildren(t))
330
+ .catch(() => slot.replaceChildren());
331
+ }
332
+ },
333
+ { root: $("list"), rootMargin: "200px 0px" },
334
+ );
335
+
336
+ // --- Shell -------------------------------------------------------------------
337
+
338
+ const types = [...new Set(all.map((r) => r.type))];
339
+ const typeSelect = $<HTMLSelectElement>("type");
340
+ typeSelect.innerHTML = types
341
+ .map((t) => `<option value="${t}">${resourceTypeName[t]} (${all.filter((r) => r.type === t).length})</option>`)
342
+ .join("");
343
+
344
+ let current: { info: ResourceInfo; li: HTMLLIElement } | undefined;
345
+ /** Only the latest selection may fill the view (arrowing quickly starts many loads). */
346
+ let showing = 0;
347
+
348
+ const show = async (info: ResourceInfo, li: HTMLLIElement) => {
349
+ const token = ++showing;
350
+ current = { info, li };
351
+ clearInterval(animTimer);
352
+ stopSoundPreview();
353
+ document.querySelector("li.active")?.classList.remove("active");
354
+ li.classList.add("active");
355
+ li.scrollIntoView({ block: "nearest" });
356
+ history.replaceState(null, "", `#${resourceTypeName[info.type]}/${info.number}`);
357
+ const res = await rm.load(info);
358
+ if (token !== showing) return;
359
+ $("title").textContent = `${resourceTypeName[info.type]} ${info.number} · ${res.data.length} bytes · ${info.source}`;
360
+ showUses(info);
361
+
362
+ const view = $("view");
363
+ let content: HTMLElement | undefined;
364
+ try {
365
+ switch (info.type) {
366
+ case ResourceType.Pic: content = renderPic(res.data); break;
367
+ case ResourceType.View: content = await renderView(res.data); break;
368
+ case ResourceType.Script: case ResourceType.Heap: content = await renderScript(info.number); break;
369
+ case ResourceType.Font: content = renderFont(res.data); break;
370
+ case ResourceType.Message: content = await renderMessages(res.data, info.number); break;
371
+ case ResourceType.Palette: content = renderPalette(parseHunkPalette(res.data) ?? emptyPalette()); break;
372
+ case ResourceType.Sound: content = await renderSound(rm, info.number, res.data, () => token === showing); break;
373
+ }
374
+ } catch (e) {
375
+ content = el("p", { className: "error", textContent: String(e) });
376
+ }
377
+ if (token !== showing) {
378
+ // A view's animation started for a resource that's no longer selected.
379
+ if (info.type === ResourceType.View) clearInterval(animTimer);
380
+ return;
381
+ }
382
+ view.replaceChildren(...(content ? [content] : []));
383
+ $("hex").textContent = hexDump(res.data);
384
+ };
385
+
386
+ /** The objects and rooms that use the resource, and what the game says about them. */
387
+ const showUses = (info: ResourceInfo) => {
388
+ const meta = assetIndex?.get(assetKey(info.type, info.number));
389
+ $("uses").replaceChildren(
390
+ ...(meta?.uses ?? []).slice(0, 12).map((u) => {
391
+ const p = el("p", { className: "use" });
392
+ p.append(el("b", { textContent: u.label }));
393
+ if (u.text) p.append(" · ", el("q", { textContent: u.text }));
394
+ return p;
395
+ }),
396
+ );
397
+ };
398
+
399
+ const THUMBNAILS = new Set<number>([ResourceType.Pic, ResourceType.View, ResourceType.Font, ResourceType.Message, ResourceType.Sound]);
400
+
401
+ const rows = (): HTMLLIElement[] => [...$("list").querySelectorAll<HTMLLIElement>("li")].filter((li) => "info" in li);
402
+
403
+ const search = $<HTMLInputElement>("search");
404
+ let assetIndex: AssetIndex | undefined;
405
+
406
+ const renderList = () => {
407
+ const type = Number(typeSelect.value);
408
+ const withThumbs = THUMBNAILS.has(type);
409
+ $("list").className = withThumbs ? `thumbs type-${resourceTypeName[type as ResourceType]}` : "";
410
+ thumbs.disconnect();
411
+ const items = all
412
+ .filter((r) => r.type === type)
413
+ .filter((r) => matches(search.value, r.number, assetIndex?.get(assetKey(r.type, r.number))))
414
+ .map((r) => {
415
+ const li = Object.assign(el("li"), { info: r });
416
+ if (withThumbs) li.append(el("span", { className: "thumb" }));
417
+ const label = el("span", { className: "num", innerHTML: `<span>${r.number}${r.source === "patch" ? " <small>patch</small>" : ""}</span>` });
418
+ const tags = assetIndex?.get(assetKey(r.type, r.number))?.tags;
419
+ if (tags?.length) label.append(el("span", { className: "tags", textContent: tags.slice(0, 3).join(" · "), title: tags.join(" · ") }));
420
+ li.append(label);
421
+ li.onclick = () => show(r, li);
422
+ if (withThumbs) thumbs.observe(li);
423
+ // Keep the selection across filtering.
424
+ if (current && current.info === r) (li.classList.add("active"), (current.li = li));
425
+ return li;
426
+ });
427
+ $("list").replaceChildren(...(items.length ? items : [el("li", { className: "empty", textContent: search.value ? "Nothing matches" : "None" })]));
428
+ };
429
+
430
+ /** Selects the row `delta` away from the current one (or the first/last). */
431
+ const step = (delta: number | "first" | "last") => {
432
+ const list = rows();
433
+ if (!list.length) return;
434
+ const at = current ? list.indexOf(current.li) : -1;
435
+ const next = delta === "first" ? 0 : delta === "last" ? list.length - 1 : Math.max(0, Math.min(list.length - 1, at < 0 ? 0 : at + delta));
436
+ const li = list[next]!;
437
+ void show((li as HTMLLIElement & { info: ResourceInfo }).info, li);
438
+ };
439
+
440
+ const switchType = (delta: number) => {
441
+ const i = Math.max(0, Math.min(types.length - 1, typeSelect.selectedIndex + delta));
442
+ if (i === typeSelect.selectedIndex) return;
443
+ typeSelect.selectedIndex = i;
444
+ renderList();
445
+ step("first");
446
+ };
447
+
448
+ // Keyboard: ↑/↓ (k/j) next and previous, PageUp/PageDown by ten, Home/End, ←/→ the type,
449
+ // "/" the search box (where ↑/↓ and Enter still move through the results).
450
+ window.addEventListener("keydown", (e) => {
451
+ const target = e.target instanceof HTMLElement ? e.target : document.body;
452
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
453
+ if (target === search) {
454
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") (e.preventDefault(), step(e.key === "ArrowDown" ? 1 : -1));
455
+ else if (e.key === "Enter") (e.preventDefault(), current ? search.blur() : step("first"));
456
+ else if (e.key === "Escape") (search.value = "", renderList(), search.blur());
457
+ return;
458
+ }
459
+ if (e.key === "/" && !target.matches("input, textarea")) {
460
+ e.preventDefault();
461
+ search.focus();
462
+ search.select();
463
+ return;
464
+ }
465
+ if (target.matches("input, textarea, select:not(#type)")) return;
466
+ if (e.key === " " && !target.matches("button") && toggleSoundPreview()) return void e.preventDefault();
467
+ const keys: Record<string, () => void> = {
468
+ ArrowDown: () => step(1), j: () => step(1),
469
+ ArrowUp: () => step(-1), k: () => step(-1),
470
+ PageDown: () => step(10), PageUp: () => step(-10),
471
+ Home: () => step("first"), End: () => step("last"),
472
+ ArrowLeft: () => switchType(-1), ArrowRight: () => switchType(1),
473
+ };
474
+ const action = keys[e.key];
475
+ if (!action) return;
476
+ e.preventDefault();
477
+ action();
478
+ });
479
+
480
+ search.addEventListener("input", () => {
481
+ renderList();
482
+ if (!current || !rows().includes(current.li)) step("first");
483
+ });
484
+
485
+ typeSelect.onchange = () => {
486
+ renderList();
487
+ step("first");
488
+ typeSelect.blur(); // so the arrow keys go back to the list
489
+ };
490
+ $<HTMLInputElement>("aspect").onchange = (e) => {
491
+ aspect = (e.target as HTMLInputElement).checked;
492
+ if (current) show(current.info, current.li);
493
+ };
494
+
495
+ /** Goes where the URL says (#pic/130), else to the first picture; also on edits and Back. */
496
+ const goToHash = () => {
497
+ const [hashType, hashNumber] = decodeURIComponent(location.hash.slice(1)).split("/");
498
+ const type = types.find((t) => resourceTypeName[t] === hashType) ?? ResourceType.Pic;
499
+ if (Number(typeSelect.value) !== type || !rows().length) {
500
+ typeSelect.value = String(type);
501
+ renderList();
502
+ }
503
+ const find = () => rows().find((li) => (li as HTMLLIElement & { info: ResourceInfo }).info.number === Number(hashNumber));
504
+ let row = find();
505
+ if (!row && search.value && hashNumber) {
506
+ // Asked for something the search hides: show everything again.
507
+ search.value = "";
508
+ renderList();
509
+ row = find();
510
+ }
511
+ if (row) void show((row as HTMLLIElement & { info: ResourceInfo }).info, row);
512
+ else step("first");
513
+ };
514
+ window.addEventListener("hashchange", goToHash);
515
+ goToHash();
516
+
517
+ // Names and descriptions from the scripts (a couple of seconds): then search finds them.
518
+ const indexStatus = $("index-status");
519
+ getWorld()
520
+ .then((world) => buildAssetIndex(rm, world, (done, total) => (indexStatus.textContent = `Indexing the scripts… ${done}/${total}`)))
521
+ .then((index) => {
522
+ assetIndex = index;
523
+ indexStatus.textContent = `${index.size} assets named by the game's scripts`;
524
+ renderList();
525
+ if (current) (showUses(current.info), current.li.scrollIntoView({ block: "nearest" }));
526
+ })
527
+ .catch((e) => (indexStatus.textContent = `No index: ${(e as Error).message}`));
@@ -0,0 +1,14 @@
1
+ import type { MidiOutput } from "../../../packages/sci/src/index.ts";
2
+ /**
3
+ * General MIDI output through a SoundFont synthesizer (SpessaSynth, in an AudioWorklet).
4
+ * Messages sent before the SoundFont has loaded are dropped, as a real MIDI device that
5
+ * isn't powered on yet would; the game re-sends program/volume setup on each song start.
6
+ */
7
+ export declare class SoundFontMidi implements MidiOutput {
8
+ private readonly ctx;
9
+ private synth;
10
+ constructor(ctx: AudioContext);
11
+ load(soundFontUrl: string): Promise<void>;
12
+ get ready(): boolean;
13
+ send(bytes: number[]): void;
14
+ }
@@ -0,0 +1,32 @@
1
+ import { WorkletSynthesizer } from "spessasynth_lib";
2
+ import processorUrl from "spessasynth_lib/dist/spessasynth_processor.min.js?url";
3
+ /**
4
+ * General MIDI output through a SoundFont synthesizer (SpessaSynth, in an AudioWorklet).
5
+ * Messages sent before the SoundFont has loaded are dropped, as a real MIDI device that
6
+ * isn't powered on yet would; the game re-sends program/volume setup on each song start.
7
+ */
8
+ export class SoundFontMidi {
9
+ ctx;
10
+ synth;
11
+ constructor(ctx) {
12
+ this.ctx = ctx;
13
+ }
14
+ async load(soundFontUrl) {
15
+ const res = await fetch(soundFontUrl);
16
+ if (!res.ok)
17
+ throw new Error(`no SoundFont at ${soundFontUrl} (${res.status}): music is silent`);
18
+ const bank = await res.arrayBuffer();
19
+ await this.ctx.audioWorklet.addModule(processorUrl);
20
+ const synth = new WorkletSynthesizer(this.ctx);
21
+ synth.connect(this.ctx.destination);
22
+ await synth.soundBankManager.addSoundBank(bank, "main");
23
+ await synth.isReady;
24
+ this.synth = synth;
25
+ }
26
+ get ready() {
27
+ return this.synth !== undefined;
28
+ }
29
+ send(bytes) {
30
+ this.synth?.sendMessage(bytes);
31
+ }
32
+ }
@@ -0,0 +1,34 @@
1
+ import type { MidiOutput } from "../../../packages/sci/src/index.ts";
2
+ import { WorkletSynthesizer } from "spessasynth_lib";
3
+ import processorUrl from "spessasynth_lib/dist/spessasynth_processor.min.js?url";
4
+
5
+ /**
6
+ * General MIDI output through a SoundFont synthesizer (SpessaSynth, in an AudioWorklet).
7
+ * Messages sent before the SoundFont has loaded are dropped, as a real MIDI device that
8
+ * isn't powered on yet would; the game re-sends program/volume setup on each song start.
9
+ */
10
+ export class SoundFontMidi implements MidiOutput {
11
+ private synth: WorkletSynthesizer | undefined;
12
+
13
+ constructor(private readonly ctx: AudioContext) {}
14
+
15
+ async load(soundFontUrl: string): Promise<void> {
16
+ const res = await fetch(soundFontUrl);
17
+ if (!res.ok) throw new Error(`no SoundFont at ${soundFontUrl} (${res.status}): music is silent`);
18
+ const bank = await res.arrayBuffer();
19
+ await this.ctx.audioWorklet.addModule(processorUrl);
20
+ const synth = new WorkletSynthesizer(this.ctx);
21
+ synth.connect(this.ctx.destination);
22
+ await synth.soundBankManager.addSoundBank(bank, "main");
23
+ await synth.isReady;
24
+ this.synth = synth;
25
+ }
26
+
27
+ get ready(): boolean {
28
+ return this.synth !== undefined;
29
+ }
30
+
31
+ send(bytes: number[]): void {
32
+ this.synth?.sendMessage(bytes);
33
+ }
34
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,47 @@
1
+ /**
2
+ * AudioWorklet running the OPL2 emulator. The main thread's AdLib driver posts register
3
+ * writes as flat [reg, value, reg, value, ...] arrays; this renders the chip at its native
4
+ * 49,716 Hz and linearly resamples to the AudioContext's rate.
5
+ */
6
+ import { OPL_RATE, Opl2 } from "../../../packages/sci/src/audio/opl2.js";
7
+ const Base = globalThis.AudioWorkletProcessor;
8
+ class OplProcessor extends Base {
9
+ chip = new Opl2();
10
+ one = new Float32Array(1);
11
+ prev = 0;
12
+ next = 0;
13
+ frac = 0;
14
+ constructor() {
15
+ super();
16
+ this.port.onmessage = (e) => {
17
+ if (e.data === "reset") {
18
+ this.chip = new Opl2();
19
+ return;
20
+ }
21
+ const w = e.data;
22
+ for (let i = 0; i + 1 < w.length; i += 2)
23
+ this.chip.write(w[i], w[i + 1]);
24
+ };
25
+ }
26
+ process(_inputs, outputs) {
27
+ const out = outputs[0];
28
+ if (!out?.[0])
29
+ return true;
30
+ const step = OPL_RATE / sampleRate;
31
+ const left = out[0];
32
+ for (let i = 0; i < left.length; i++) {
33
+ this.frac += step;
34
+ while (this.frac >= 1) {
35
+ this.prev = this.next;
36
+ this.chip.generate(this.one, 0, 1);
37
+ this.next = this.one[0];
38
+ this.frac -= 1;
39
+ }
40
+ left[i] = this.prev + (this.next - this.prev) * this.frac;
41
+ }
42
+ for (let c = 1; c < out.length; c++)
43
+ out[c].set(left);
44
+ return true;
45
+ }
46
+ }
47
+ registerProcessor("opl2", OplProcessor);