mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-06 13:38:30 +09:00
Compare commits
237 Commits
libtav
...
051177f7f7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
051177f7f7 | ||
|
|
5f873fa2d1 | ||
|
|
a7db53e81c | ||
|
|
8d473c223c | ||
|
|
5a25d394b9 | ||
|
|
15587a0d76 | ||
|
|
a716807b36 | ||
|
|
b103e3c690 | ||
|
|
7edc3e32b1 | ||
|
|
6db6a2e7ed | ||
|
|
0d564d5f82 | ||
|
|
6d20d346f5 | ||
|
|
de82435f6e | ||
|
|
054295fdab | ||
|
|
26303c63af | ||
|
|
2ff471a066 | ||
|
|
dfcc0c7729 | ||
|
|
4e7fe82690 | ||
|
|
13eaf1b999 | ||
|
|
6623ff62bc | ||
|
|
3c43aa8aa6 | ||
|
|
848ee491d1 | ||
|
|
eddd65fa13 | ||
|
|
1e2814af87 | ||
|
|
61a721d628 | ||
|
|
9723c33dfc | ||
|
|
065e586cd6 | ||
|
|
83d9cde0bd | ||
|
|
0b82d4b32c | ||
|
|
277693989b | ||
|
|
db3ffdedb6 | ||
|
|
5b9b96c8de | ||
|
|
8e8374ba99 | ||
|
|
34fba4b2f2 | ||
|
|
1d28c89937 | ||
|
|
61524b3685 | ||
|
|
e6f77c4789 | ||
|
|
00c0e18c1a | ||
|
|
135c7b9c4e | ||
|
|
295c1f7fe2 | ||
|
|
e74a373605 | ||
|
|
b1a0a9f801 | ||
|
|
bdc2578072 | ||
|
|
e3bd4a1b59 | ||
|
|
70d953a784 | ||
|
|
f3ece28a10 | ||
|
|
3ecf842ac0 | ||
|
|
6004060344 | ||
|
|
7d89605302 | ||
|
|
11bc1ca125 | ||
|
|
6a72a81198 | ||
|
|
8380d1e845 | ||
|
|
4ea9ade060 | ||
|
|
46ae6511f6 | ||
|
|
577d46d31e | ||
|
|
2ffdf32c91 | ||
|
|
a28fcbcefc | ||
|
|
ebba33a5c3 | ||
|
|
ab0b215759 | ||
|
|
2c2ad70a23 | ||
|
|
fb42ab4413 | ||
|
|
2177ddbd6b | ||
|
|
aa32c70d8a | ||
|
|
72761c0552 | ||
|
|
2cdd731c3b | ||
|
|
b27ef0dbf9 | ||
|
|
ddeab1c782 | ||
|
|
f69108c40d | ||
|
|
74cba0a893 | ||
|
|
bc235ebb17 | ||
|
|
9f01bdfee9 | ||
|
|
3c57e33f8f | ||
|
|
935fbe04a6 | ||
|
|
6b02d73600 | ||
|
|
8e6f597e9b | ||
|
|
ed3bbb6ffe | ||
|
|
27b0f2e63f | ||
|
|
dcd191b734 | ||
|
|
d706f27e18 | ||
|
|
e49140902b | ||
|
|
3182ae9146 | ||
|
|
34b3b83d65 | ||
|
|
a767eebc2e | ||
|
|
6ce8d2cc1e | ||
|
|
9017b76f6d | ||
|
|
449885c1ea | ||
|
|
59bbe9e503 | ||
|
|
cc492c4ead | ||
|
|
ec0f41b574 | ||
|
|
937d3e27ed | ||
|
|
e64e335db3 | ||
|
|
0124b062d0 | ||
|
|
18881a6d16 | ||
|
|
5a4d200fdc | ||
|
|
75ddfcde0f | ||
|
|
d058f11329 | ||
|
|
60b07a325a | ||
|
|
1e482e32a8 | ||
|
|
4ff48bba1c | ||
|
|
2dcdff83c8 | ||
|
|
89d3c5d776 | ||
|
|
517d0ad9a7 | ||
|
|
9524bf36e0 | ||
|
|
8e17256224 | ||
|
|
ac409bf961 | ||
|
|
94e3ce55ce | ||
|
|
9a9893b9a3 | ||
|
|
789c78f1e7 | ||
|
|
c7e7ee650d | ||
|
|
5d968fecf5 | ||
|
|
aaf3cc28b2 | ||
|
|
24375727db | ||
|
|
6a7ef670d9 | ||
|
|
1bbf0de381 | ||
|
|
5e6ac17146 | ||
|
|
d2b1e792b9 | ||
|
|
219ca1e475 | ||
|
|
902ab00132 | ||
|
|
5dc87a80be | ||
|
|
2b91251d6e | ||
|
|
f84d317f95 | ||
|
|
f295223f15 | ||
|
|
6de9476c4f | ||
|
|
e317d79a21 | ||
|
|
fe59df18f7 | ||
|
|
a4adc428d0 | ||
|
|
31e46b78ce | ||
|
|
ac94a52329 | ||
|
|
01ff4b1d47 | ||
|
|
50802186ce | ||
|
|
7184392521 | ||
|
|
018b9f5eb3 | ||
|
|
bb0810798d | ||
|
|
909f970d60 | ||
|
|
80c26c6b35 | ||
|
|
515e0268e6 | ||
|
|
606fa736af | ||
|
|
89effb5b24 | ||
|
|
376c3c4766 | ||
|
|
0a247897e4 | ||
|
|
b838b35525 | ||
|
|
1148454fb3 | ||
|
|
cfb7b97bf0 | ||
|
|
176aa824fe | ||
|
|
d33484c3c8 | ||
|
|
737b1cebe7 | ||
|
|
176766b793 | ||
|
|
191c733913 | ||
|
|
895f1b27ef | ||
|
|
538d718568 | ||
|
|
b3c5719e3a | ||
|
|
27e4bc1ae5 | ||
|
|
2282e0c10b | ||
|
|
e7287fae37 | ||
|
|
65d89db9c6 | ||
|
|
53173a359c | ||
|
|
8d7d534bc8 | ||
|
|
dc3c73252e | ||
|
|
2053526dfa | ||
|
|
6bc49e3f0b | ||
|
|
02c4f9590c | ||
|
|
34afa95137 | ||
|
|
284108f07f | ||
|
|
76011d4fa9 | ||
|
|
b44d9c6b68 | ||
|
|
e47e9e1259 | ||
|
|
c5789ec28b | ||
|
|
93f7f436a3 | ||
|
|
3ca31e57a1 | ||
|
|
1f630aee62 | ||
|
|
3f3644d165 | ||
|
|
e29f9c3032 | ||
|
|
85b8586a3a | ||
|
|
92b9984ef8 | ||
|
|
3f98d25828 | ||
|
|
d4ea9b2d29 | ||
|
|
a1b62f3155 | ||
|
|
4802e10dfc | ||
|
|
6d70960e5c | ||
|
|
3d99568359 | ||
|
|
1fe966ca09 | ||
|
|
037b2c1a16 | ||
|
|
ea09065802 | ||
|
|
8d28bde119 | ||
|
|
755afb7df4 | ||
|
|
539df453ec | ||
|
|
5e3ffea6d3 | ||
|
|
4bda55d511 | ||
|
|
852c0e6e80 | ||
|
|
25309cf5b6 | ||
|
|
44f11120d8 | ||
|
|
bc16ffabb4 | ||
|
|
ad5e5b62bc | ||
|
|
887c2fbfba | ||
|
|
e58eb2c12b | ||
|
|
3a91edb379 | ||
|
|
74d94b350c | ||
|
|
4559e4f3f6 | ||
|
|
c5bc0d6526 | ||
|
|
a92727862e | ||
|
|
31d25d940b | ||
|
|
460046c4ed | ||
|
|
6eeccb4baa | ||
|
|
a7c44bd05f | ||
|
|
4e647f9fe1 | ||
|
|
b2faab9377 | ||
|
|
e833d75b2c | ||
|
|
f84ea5e68a | ||
|
|
5374ca43c3 | ||
|
|
bef85f6e2f | ||
|
|
f02ad1de79 | ||
|
|
7f0ff3e653 | ||
|
|
8702104bfe | ||
|
|
7d899936e2 | ||
|
|
6aa2542bb8 | ||
|
|
2ac084acd7 | ||
|
|
1208690c4f | ||
|
|
ca977b074d | ||
|
|
102801d8b0 | ||
|
|
10351bafb1 | ||
|
|
9310885260 | ||
|
|
b10d5d3a34 | ||
|
|
86b44565e0 | ||
|
|
54b61fb436 | ||
|
|
4afe3816c7 | ||
|
|
f09dd66185 | ||
|
|
b590415231 | ||
|
|
237d3d6fd2 | ||
|
|
3421d71012 | ||
|
|
5d5576c077 | ||
|
|
64026be133 | ||
|
|
96d697e158 | ||
|
|
1680137b7d | ||
|
|
c71920b95d | ||
|
|
629ed5fb12 | ||
|
|
4f6efbe000 | ||
|
|
4362610c70 |
8
.gitignore
vendored
8
.gitignore
vendored
@@ -62,7 +62,15 @@ tsvmman.pdf
|
||||
*.ilg
|
||||
*.ind
|
||||
|
||||
assets/disk0/tvdos/bin/tautfont.png
|
||||
|
||||
video_encoder/*
|
||||
|
||||
.idea/vcs.xml
|
||||
|
||||
# in-dev stuffs
|
||||
assets/disk0/home/basic/*
|
||||
assets/disk0/movtestimg/*.jpg
|
||||
assets/disk0/*.mov
|
||||
assets/diskMediabin/*
|
||||
assets/disk0/hopper/*
|
||||
|
||||
54
.idea/artifacts/TerranBASIC.xml
generated
54
.idea/artifacts/TerranBASIC.xml
generated
@@ -21,36 +21,54 @@
|
||||
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-reflect/1.8.21/kotlin-reflect-1.8.21.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-test/1.8.21/kotlin-test-1.8.21.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$MAVEN_REPOSITORY$/org/jetbrains/kotlin/kotlin-stdlib-common/1.8.21/kotlin-stdlib-common-1.8.21.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/graal-sdk-22.3.1.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/icu4j-71.1.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/js-22.3.1-edit.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/js-scriptengine-22.3.1.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/regex-22.3.1-edit.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/truffle-api-22.3.1.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/polyglot-23.1.10.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/js-language-23.1.10.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/js-scriptengine-23.1.10.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/truffle-api-23.1.10.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/truffle-runtime-23.1.10.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/truffle-compiler-23.1.10.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/compiler-23.1.10.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/compiler-management-23.1.10.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/word-23.1.10.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/regex-23.1.10.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/icu4j-23.1.10.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/collections-23.1.10.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/nativeimage-23.1.10.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/jniutils-23.1.10.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/gdx-1.12.1.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/lwjgl-3.3.3.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/lwjgl-stb-3.3.3.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/lwjgl-glfw-3.3.3.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/js-22.3.1-javadoc.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/js-22.3.1-sources.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/js-language-23.1.10-javadoc.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/js-language-23.1.10-sources.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/gdx-1.12.1-javadoc.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/gdx-1.12.1-sources.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/icu4j-71.1-javadoc.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/icu4j-71.1-sources.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/icu4j-23.1.10-javadoc.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/icu4j-23.1.10-sources.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/lwjgl-openal-3.3.3.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/lwjgl-opengl-3.3.3.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/lwjgl-3.3.3-javadoc.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/lwjgl-3.3.3-sources.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/lwjgl-jemalloc-3.3.3.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/regex-22.3.1-javadoc.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/regex-22.3.1-sources.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/regex-23.1.10-javadoc.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/regex-23.1.10-sources.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/TerranVirtualDisk-src.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/jorbis-0.0.17-javadoc.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/jorbis-0.0.17-sources.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/lwjgl-stb-3.3.3-javadoc.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/lwjgl-stb-3.3.3-sources.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/graal-sdk-22.3.1-javadoc.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/graal-sdk-22.3.1-sources.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/polyglot-23.1.10-javadoc.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/polyglot-23.1.10-sources.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/truffle-api-23.1.10-javadoc.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/truffle-api-23.1.10-sources.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/truffle-runtime-23.1.10-javadoc.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/truffle-runtime-23.1.10-sources.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/collections-23.1.10-javadoc.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/collections-23.1.10-sources.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/nativeimage-23.1.10-javadoc.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/nativeimage-23.1.10-sources.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/jniutils-23.1.10-javadoc.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/jniutils-23.1.10-sources.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/jlayer-1.0.1-gdx-javadoc.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/jlayer-1.0.1-gdx-sources.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/lwjgl-glfw-3.3.3-javadoc.jar" path-in-jar="/" />
|
||||
@@ -62,15 +80,15 @@
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/lwjgl-openal-3.3.3-sources.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/lwjgl-opengl-3.3.3-javadoc.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/lwjgl-opengl-3.3.3-sources.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/truffle-api-22.3.1-javadoc.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/truffle-api-22.3.1-sources.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/regex-23.1.10-javadoc.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/regex-23.1.10-sources.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/lwjgl-3.3.3-natives-windows.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/lwjgl-jemalloc-3.3.3-javadoc.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/lwjgl-jemalloc-3.3.3-sources.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/lwjgl-stb-3.3.3-natives-linux.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/lwjgl-stb-3.3.3-natives-macos.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/js-scriptengine-22.3.1-javadoc.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/js-scriptengine-22.3.1-sources.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/js-scriptengine-23.1.10-javadoc.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/js-scriptengine-23.1.10-sources.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/lwjgl-glfw-3.3.3-natives-linux.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/lwjgl-glfw-3.3.3-natives-macos.jar" path-in-jar="/" />
|
||||
<element id="extracted-dir" path="$PROJECT_DIR$/lib/gdx-jnigen-loader-2.3.1-javadoc.jar" path-in-jar="/" />
|
||||
|
||||
133
.idea/cody_history.xml
generated
Normal file
133
.idea/cody_history.xml
generated
Normal file
@@ -0,0 +1,133 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ChatHistory">
|
||||
<accountData>
|
||||
<list>
|
||||
<AccountData>
|
||||
<accountId value="VXNlcjo1NTUxMzY=" />
|
||||
<chats>
|
||||
<list>
|
||||
<chat>
|
||||
<internalId value="8a32c880-d8ec-4d84-b5f7-a62b2edf63c3" />
|
||||
<llm>
|
||||
<llm>
|
||||
<model value="anthropic/claude-3-5-sonnet-20240620" />
|
||||
<provider value="Anthropic" />
|
||||
<title value="Claude 3.5 Sonnet" />
|
||||
<usage>
|
||||
<list>
|
||||
<option value="chat" />
|
||||
<option value="edit" />
|
||||
</list>
|
||||
</usage>
|
||||
</llm>
|
||||
</llm>
|
||||
</chat>
|
||||
<chat>
|
||||
<internalId value="f4e7338b-57d3-44e0-b1d9-04aa2dfb4bae" />
|
||||
<llm>
|
||||
<llm>
|
||||
<model value="anthropic/claude-3-5-sonnet-20240620" />
|
||||
<provider value="Anthropic" />
|
||||
<title value="Claude 3.5 Sonnet" />
|
||||
<usage>
|
||||
<list>
|
||||
<option value="chat" />
|
||||
<option value="edit" />
|
||||
</list>
|
||||
</usage>
|
||||
</llm>
|
||||
</llm>
|
||||
</chat>
|
||||
<chat>
|
||||
<internalId value="26ac815b-5db9-488b-be0b-ab174fbc5646" />
|
||||
<llm>
|
||||
<llm>
|
||||
<model value="anthropic/claude-3-5-sonnet-20240620" />
|
||||
<provider value="Anthropic" />
|
||||
<title value="Claude 3.5 Sonnet" />
|
||||
<usage>
|
||||
<list>
|
||||
<option value="chat" />
|
||||
<option value="edit" />
|
||||
</list>
|
||||
</usage>
|
||||
</llm>
|
||||
</llm>
|
||||
</chat>
|
||||
<chat>
|
||||
<internalId value="8ab39b26-cdda-4256-878f-e0416e66bbea" />
|
||||
<llm>
|
||||
<llm>
|
||||
<model value="anthropic/claude-3-5-sonnet-20240620" />
|
||||
<provider value="Anthropic" />
|
||||
<title value="Claude 3.5 Sonnet" />
|
||||
<usage>
|
||||
<list>
|
||||
<option value="chat" />
|
||||
<option value="edit" />
|
||||
</list>
|
||||
</usage>
|
||||
</llm>
|
||||
</llm>
|
||||
</chat>
|
||||
<chat>
|
||||
<internalId value="f79a288a-adc5-4d45-a069-4bf024d90236" />
|
||||
<llm>
|
||||
<llm>
|
||||
<model value="anthropic/claude-3-5-sonnet-20240620" />
|
||||
<provider value="Anthropic" />
|
||||
<title value="Claude 3.5 Sonnet" />
|
||||
<usage>
|
||||
<list>
|
||||
<option value="chat" />
|
||||
<option value="edit" />
|
||||
</list>
|
||||
</usage>
|
||||
</llm>
|
||||
</llm>
|
||||
</chat>
|
||||
<chat>
|
||||
<internalId value="20bc02fd-c6b5-4590-a00b-b7012a630ef4" />
|
||||
<llm>
|
||||
<llm>
|
||||
<model value="anthropic/claude-3-5-sonnet-20240620" />
|
||||
<provider value="Anthropic" />
|
||||
<title value="Claude 3.5 Sonnet" />
|
||||
<usage>
|
||||
<list>
|
||||
<option value="chat" />
|
||||
<option value="edit" />
|
||||
</list>
|
||||
</usage>
|
||||
</llm>
|
||||
</llm>
|
||||
</chat>
|
||||
</list>
|
||||
</chats>
|
||||
<defaultLlm>
|
||||
<llm>
|
||||
<model value="anthropic/claude-3-5-sonnet-20240620" />
|
||||
<provider value="Anthropic" />
|
||||
<tags>
|
||||
<list>
|
||||
<option value="gateway" />
|
||||
<option value="accuracy" />
|
||||
<option value="recommended" />
|
||||
<option value="free" />
|
||||
</list>
|
||||
</tags>
|
||||
<title value="Claude 3.5 Sonnet" />
|
||||
<usage>
|
||||
<list>
|
||||
<option value="chat" />
|
||||
<option value="edit" />
|
||||
</list>
|
||||
</usage>
|
||||
</llm>
|
||||
</defaultLlm>
|
||||
</AccountData>
|
||||
</list>
|
||||
</accountData>
|
||||
</component>
|
||||
</project>
|
||||
11
.idea/libraries/badlogicgames_gdx.xml
generated
Normal file
11
.idea/libraries/badlogicgames_gdx.xml
generated
Normal file
@@ -0,0 +1,11 @@
|
||||
<component name="libraryTable">
|
||||
<library name="badlogicgames.gdx" type="repository">
|
||||
<properties maven-id="com.badlogicgames.gdx:gdx:1.12.1" />
|
||||
<CLASSES>
|
||||
<root url="jar://$MAVEN_REPOSITORY$/com/badlogicgames/gdx/gdx/1.12.1/gdx-1.12.1.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/com/badlogicgames/gdx/gdx-jnigen-loader/2.3.1/gdx-jnigen-loader-2.3.1.jar!/" />
|
||||
</CLASSES>
|
||||
<JAVADOC />
|
||||
<SOURCES />
|
||||
</library>
|
||||
</component>
|
||||
62
.idea/libraries/badlogicgames_gdx_backend_lwjgl3.xml
generated
Normal file
62
.idea/libraries/badlogicgames_gdx_backend_lwjgl3.xml
generated
Normal file
@@ -0,0 +1,62 @@
|
||||
<component name="libraryTable">
|
||||
<library name="badlogicgames.gdx.backend.lwjgl3" type="repository">
|
||||
<properties maven-id="com.badlogicgames.gdx:gdx-backend-lwjgl3:1.12.1" />
|
||||
<CLASSES>
|
||||
<root url="jar://$MAVEN_REPOSITORY$/com/badlogicgames/gdx/gdx-backend-lwjgl3/1.12.1/gdx-backend-lwjgl3-1.12.1.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/com/badlogicgames/gdx/gdx/1.12.1/gdx-1.12.1.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/com/badlogicgames/gdx/gdx-jnigen-loader/2.3.1/gdx-jnigen-loader-2.3.1.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-linux.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-linux-arm32.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-linux-arm64.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-macos.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-macos-arm64.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-windows.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl/3.3.3/lwjgl-3.3.3-natives-windows-x86.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-linux.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-linux-arm32.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-linux-arm64.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-macos.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-macos-arm64.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-windows.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-glfw/3.3.3/lwjgl-glfw-3.3.3-natives-windows-x86.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-linux.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-linux-arm32.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-linux-arm64.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-macos.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-macos-arm64.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-windows.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-jemalloc/3.3.3/lwjgl-jemalloc-3.3.3-natives-windows-x86.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-linux.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-linux-arm32.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-linux-arm64.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-macos.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-macos-arm64.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-windows.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-openal/3.3.3/lwjgl-openal-3.3.3-natives-windows-x86.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-linux.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-linux-arm32.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-linux-arm64.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-macos.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-macos-arm64.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-windows.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-opengl/3.3.3/lwjgl-opengl-3.3.3-natives-windows-x86.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-linux.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-linux-arm32.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-linux-arm64.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-macos.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-macos-arm64.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-windows.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/lwjgl/lwjgl-stb/3.3.3/lwjgl-stb-3.3.3-natives-windows-x86.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/com/badlogicgames/jlayer/jlayer/1.0.1-gdx/jlayer-1.0.1-gdx.jar!/" />
|
||||
<root url="jar://$MAVEN_REPOSITORY$/org/jcraft/jorbis/0.0.17/jorbis-0.0.17.jar!/" />
|
||||
</CLASSES>
|
||||
<JAVADOC />
|
||||
<SOURCES />
|
||||
</library>
|
||||
</component>
|
||||
9
.idea/markdown.xml
generated
Normal file
9
.idea/markdown.xml
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="MarkdownSettings">
|
||||
<option name="previewPanelProviderInfo">
|
||||
<ProviderInfo name="Compose (experimental)" className="com.intellij.markdown.compose.preview.ComposePanelProvider" />
|
||||
</option>
|
||||
<option name="splitLayout" value="SHOW_EDITOR" />
|
||||
</component>
|
||||
</project>
|
||||
2
.idea/misc.xml
generated
2
.idea/misc.xml
generated
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="17" project-jdk-type="JavaSDK">
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="21" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
||||
17
.idea/runConfigurations/AppLoader.xml
generated
Normal file
17
.idea/runConfigurations/AppLoader.xml
generated
Normal file
@@ -0,0 +1,17 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="AppLoader" type="Application" factoryName="Application" nameIsGenerated="true">
|
||||
<option name="ALTERNATIVE_JRE_PATH" value="21" />
|
||||
<option name="MAIN_CLASS_NAME" value="net.torvald.tsvm.AppLoader" />
|
||||
<module name="tsvm_executable" />
|
||||
<option name="VM_PARAMETERS" value="-ea --upgrade-module-path=lib/compiler-23.1.10.jar:lib/compiler-management-23.1.10.jar:lib/truffle-compiler-23.1.10.jar:lib/truffle-api-23.1.10.jar:lib/truffle-runtime-23.1.10.jar:lib/polyglot-23.1.10.jar:lib/collections-23.1.10.jar:lib/word-23.1.10.jar:lib/nativeimage-23.1.10.jar:lib/jniutils-23.1.10.jar -XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCI -XX:+UseZGC -XX:+UseDynamicNumberOfGCThreads --add-exports=java.base/jdk.internal.misc=jdk.internal.vm.compiler" />
|
||||
<extension name="coverage">
|
||||
<pattern>
|
||||
<option name="PATTERN" value="net.torvald.tsvm.*" />
|
||||
<option name="ENABLED" value="true" />
|
||||
</pattern>
|
||||
</extension>
|
||||
<method v="2">
|
||||
<option name="Make" enabled="true" />
|
||||
</method>
|
||||
</configuration>
|
||||
</component>
|
||||
16
.idea/runConfigurations/TerranBASIC.xml
generated
Normal file
16
.idea/runConfigurations/TerranBASIC.xml
generated
Normal file
@@ -0,0 +1,16 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="TerranBASIC" type="Application" factoryName="Application" nameIsGenerated="true">
|
||||
<option name="MAIN_CLASS_NAME" value="net.torvald.tsvm.TerranBASIC" />
|
||||
<module name="TerranBASICexecutable" />
|
||||
<option name="VM_PARAMETERS" value="--upgrade-module-path=lib/compiler-23.1.10.jar:lib/compiler-management-23.1.10.jar:lib/truffle-compiler-23.1.10.jar:lib/truffle-api-23.1.10.jar:lib/truffle-runtime-23.1.10.jar:lib/polyglot-23.1.10.jar:lib/collections-23.1.10.jar:lib/word-23.1.10.jar:lib/nativeimage-23.1.10.jar:lib/jniutils-23.1.10.jar -XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCI --add-exports=java.base/jdk.internal.misc=jdk.internal.vm.compiler" />
|
||||
<extension name="coverage">
|
||||
<pattern>
|
||||
<option name="PATTERN" value="net.torvald.tsvm.*" />
|
||||
<option name="ENABLED" value="true" />
|
||||
</pattern>
|
||||
</extension>
|
||||
<method v="2">
|
||||
<option name="Make" enabled="true" />
|
||||
</method>
|
||||
</configuration>
|
||||
</component>
|
||||
16
.idea/runConfigurations/TsvmEmulator.xml
generated
Normal file
16
.idea/runConfigurations/TsvmEmulator.xml
generated
Normal file
@@ -0,0 +1,16 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="TsvmEmulator" type="Application" factoryName="Application" nameIsGenerated="true">
|
||||
<option name="MAIN_CLASS_NAME" value="net.torvald.tsvm.TsvmEmulator" />
|
||||
<module name="tsvm_executable" />
|
||||
<option name="VM_PARAMETERS" value="-ea --upgrade-module-path=lib/compiler-23.1.10.jar:lib/compiler-management-23.1.10.jar:lib/truffle-compiler-23.1.10.jar:lib/truffle-api-23.1.10.jar:lib/truffle-runtime-23.1.10.jar:lib/polyglot-23.1.10.jar:lib/collections-23.1.10.jar:lib/word-23.1.10.jar:lib/nativeimage-23.1.10.jar:lib/jniutils-23.1.10.jar -XX:+UnlockExperimentalVMOptions -XX:+EnableJVMCI -XX:+UseZGC -XX:+UseDynamicNumberOfGCThreads --add-exports=java.base/jdk.internal.misc=jdk.internal.vm.compiler" />
|
||||
<extension name="coverage">
|
||||
<pattern>
|
||||
<option name="PATTERN" value="net.torvald.tsvm.*" />
|
||||
<option name="ENABLED" value="true" />
|
||||
</pattern>
|
||||
</extension>
|
||||
<method v="2">
|
||||
<option name="Make" enabled="true" />
|
||||
</method>
|
||||
</configuration>
|
||||
</component>
|
||||
2
.idea/vcs.xml
generated
2
.idea/vcs.xml
generated
@@ -2,5 +2,7 @@
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/assets/disk0/home/tetrino" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/assets/disk0/home/tvnes" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
12
2taud.sh
Executable file
12
2taud.sh
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env fish
|
||||
|
||||
for f in *.mod; python3 mod2taud.py $f assets/disk0/home/music/(basename $f .mod).taud; end
|
||||
for f in *.MOD; python3 mod2taud.py $f assets/disk0/home/music/(basename $f .MOD).taud; end
|
||||
for f in *.s3m; python3 s3m2taud.py $f assets/disk0/home/music/(basename $f .s3m).taud; end
|
||||
for f in *.S3M; python3 s3m2taud.py $f assets/disk0/home/music/(basename $f .S3M).taud; end
|
||||
for f in *.it; python3 it2taud.py $f assets/disk0/home/music/(basename $f .it).taud; end
|
||||
for f in *.IT; python3 it2taud.py $f assets/disk0/home/music/(basename $f .IT).taud; end
|
||||
for f in *.xm; python3 xm2taud.py $f assets/disk0/home/music/(basename $f .xm).taud; end
|
||||
for f in *.XM; python3 xm2taud.py $f assets/disk0/home/music/(basename $f .XM).taud; end
|
||||
for f in *.mon; python3 mon2taud.py $f assets/disk0/home/music/(basename $f .mon).taud; end
|
||||
for f in *.MON; python3 mon2taud.py $f assets/disk0/home/music/(basename $f .MON).taud; end
|
||||
53
CLAUDE.md
53
CLAUDE.md
@@ -12,6 +12,37 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
- TerranBASIC integration
|
||||
- Multiple platform build system
|
||||
|
||||
## Documentations
|
||||
|
||||
Documentation for TSVM and TVDOS are available on `./doc/*.tex` as machine-readable format.
|
||||
|
||||
Documentatino for TSVM architecture is available on `terranmon.txt`
|
||||
|
||||
## Reference Materials
|
||||
|
||||
Third-party source-code references that inform TSVM implementations live in
|
||||
`reference_materials/<topic>/`. Each topic folder has a `README.md` that
|
||||
summarises the takeaway and points back into the verbatim source files.
|
||||
**Consult these before reimplementing tracker / codec / DSP behaviour from
|
||||
memory** — TSVM aims to match the audible behaviour of the originals.
|
||||
|
||||
Current topics:
|
||||
|
||||
- `reference_materials/tracker_filter/` — Impulse Tracker / OpenMPT / Schism
|
||||
Tracker resonant low-pass filter source. Defines the cutoff formula, the
|
||||
resonance damping curve, and the **IIR-only 2-pole topology** (NOT a
|
||||
biquad — no feedforward x[n−1] / x[n−2] terms) that `AudioAdapter.kt` uses
|
||||
for Taud playback.
|
||||
- `reference_materials/ft2-clone` — Modernised clone for the original FastTracker 2
|
||||
- `reference_materials/impulse-tracker` — The original source code for ImpulseTracker
|
||||
- `reference_materials/MilkyTracker` — FastTracker 2 compatible tracker
|
||||
- `reference_materials/schismtracker` — Open-source re-implementation of ImpulseTracker
|
||||
- `reference_materials/pt2-clone` — Open-source re-implementation of ProTracker 2
|
||||
|
||||
When fetching new references, copy the relevant upstream files verbatim into
|
||||
a topic folder, write a `README.md` summarising the relevant maths /
|
||||
algorithms with file:line citations, and add an entry here.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
@@ -62,12 +93,12 @@ Use the build scripts in `buildapp/`:
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. Download JDK 17 runtimes to `~/Documents/openjdk/*` with specific naming:
|
||||
- `jdk-17.0.1-x86` (Linux AMD64)
|
||||
- `jdk-17.0.1-arm` (Linux Aarch64)
|
||||
- `jdk-17.0.1-windows` (Windows AMD64)
|
||||
- `jdk-17.0.1.jdk-arm` (macOS Apple Silicon)
|
||||
- `jdk-17.0.1.jdk-x86` (macOS Intel)
|
||||
1. Download JDK 21 runtimes to `~/Documents/openjdk/*` with specific naming:
|
||||
- `jdk-21.0.1-x86` (Linux AMD64)
|
||||
- `jdk-21.0.1-arm` (Linux Aarch64)
|
||||
- `jdk-21.0.1-windows` (Windows AMD64)
|
||||
- `jdk-21.0.1.jdk-arm` (macOS Apple Silicon)
|
||||
- `jdk-21.0.1.jdk-x86` (macOS Intel)
|
||||
|
||||
2. Run `jlink` commands to create custom Java runtimes in `out/runtime-*` directories
|
||||
|
||||
@@ -85,6 +116,16 @@ Use the build scripts in `buildapp/`:
|
||||
- `My_BASIC_Programs/`: Example BASIC programs for testing
|
||||
- TVDOS filesystem uses custom format with specialised drivers
|
||||
|
||||
### TSVM JavaScript Source Encoding
|
||||
|
||||
**Do not normalise `\uXXXX` or `\xXX` escapes in .js / .mjs files that run inside
|
||||
TSVM.** TSVM's character set is not Unicode, and the JS string literal parser
|
||||
behaves differently for raw bytes vs. escape sequences. Both forms appear in
|
||||
existing code intentionally — leave each one as-is. When writing new content,
|
||||
prefer raw UTF-8 characters in string literals (e.g. write the character `ù`
|
||||
directly, rather than a `\uXXXX`-style escape) unless you are matching a
|
||||
pattern already established in the surrounding code.
|
||||
|
||||
## Videotron2K
|
||||
|
||||
The Videotron2K is a specialised video display controller with:
|
||||
|
||||
224
README.md
224
README.md
@@ -1,8 +1,222 @@
|
||||

|
||||
|
||||
**tsvm** /tiː.ɛs.viː.ɛm/ is a virtual machine with the architecture that mimics the 8-bit era of
|
||||
computers, and runs programs written in Javascript.
|
||||
# tsvm
|
||||
|
||||
**tsvm** repository includes the virtual machine itself, the reference BIOS
|
||||
implementation and a DOS; BASIC is provided by the [TerranBASIC](https://github.com/curioustorvald/TerranBASIC)
|
||||
repository.
|
||||
**tsvm** /tiː.ɛs.viː.ɛm/ is a fantasy computer platform: a virtual machine whose
|
||||
architecture is inspired by the 8-bit and early 16-bit home computers, built
|
||||
from the ground up around running JavaScript as its native machine code.
|
||||
|
||||
What started as "an 8-bit-flavoured VM that runs JS" has grown into a complete,
|
||||
self-hosted retro computing ecosystem — with its own BIOS, operating system,
|
||||
filesystem, video and audio codecs, video display coprocessor with its own
|
||||
assembly language, tracker music format, and a stack of userland tools that
|
||||
together come closer to a small alternate-history computer line than a
|
||||
single-binary emulator.
|
||||
|
||||
This repository contains the virtual machine core, the reference BIOS
|
||||
implementations, the **TVDOS** operating system, the **Videotron2K** video
|
||||
display controller, hardware-accelerated codec backends for the **TEV / TAV /
|
||||
TAD** media formats, and the multi-platform packaging scripts. The
|
||||
[TerranBASIC](https://github.com/curioustorvald/TerranBASIC) repository
|
||||
provides the matching BASIC dialect that ships on the system disk.
|
||||
|
||||
## What's actually in here
|
||||
|
||||
### The virtual machine
|
||||
|
||||
- **VM core** (`tsvm_core/`) — memory model, peripheral bus, MMIO, JS
|
||||
sandboxing through GraalVM, watchdog, DMA engine, and cooperative scheduling.
|
||||
Up to 8 hot-pluggable peripheral slots, each with a dedicated MMIO window
|
||||
and memory-space window mapped into the VM's negative address range.
|
||||
- **Multiple BIOS implementations** (`assets/bios/`) — including the reference
|
||||
`tsvmbios.js`, an OpenBIOS variant, the TBM-BIOS for TerranBASIC machines,
|
||||
and the Pip-Boy-style `pipboot.rom`. BIOSes are first-class swappable
|
||||
components, not a fixed boot blob.
|
||||
- **Reference monitor / debugger** (`mon.js`) for poking at memory and
|
||||
peripherals from a running machine.
|
||||
- **Multi-platform packaging** (`buildapp/`) — scripts to produce Linux x86_64
|
||||
/ ARM64 AppImages, macOS Intel / Apple Silicon bundles, and Windows builds,
|
||||
each with its own `jlink`-trimmed JDK 21 runtime.
|
||||
|
||||
### Peripherals (the "hardware")
|
||||
|
||||
Living under `tsvm_core/src/net/torvald/tsvm/peripheral/`:
|
||||
|
||||
- **Graphics adapters** — the standard `GraphicsAdapter`, plus `TexticsAdapter`
|
||||
for text-mode framebuffers, `ExtDisp` for external displays, and a
|
||||
`RemoteGraphicsAdapter` for networked rendering.
|
||||
- **Audio devices** — `AudioAdapter` (the main programmable sound chip with
|
||||
PCM channels, an Impulse Tracker-style resonant low-pass filter, and a
|
||||
hardware-accelerated **TAD** decoder), `OpenALBufferedAudioDevice`, and the
|
||||
`MP2Env` MPEG audio environment.
|
||||
- **Disk drives** — `TevdDiskDrive` (TEVD custom filesystem),
|
||||
`ClusteredDiskDrive`, `TestDiskDrive`, and a latency-simulator script for
|
||||
testing slow-storage behaviour.
|
||||
- **Networking and serial** — `HttpModem`, `HSDPA` / `HostFileHSDPA` for
|
||||
high-speed packet I/O, `SerialStdioHost`, `BlockTransferInterface` /
|
||||
`BlockTransferPort`.
|
||||
- **Terminals and displays** — `TTY`, `GlassTty`, `TermSim`, and a
|
||||
`CharacterLCDdisplay` for HD44780-flavoured projects.
|
||||
- **Memory expansion** — `RamBank` for bank-switched memory, plus a
|
||||
programmable `TestFunctionGenerator`.
|
||||
|
||||
### Videotron2K — the video coprocessor
|
||||
|
||||
Videotron2K is a programmable video display controller with its **own
|
||||
assembly-like language**, six general registers (`r1`–`r6`), special registers
|
||||
(`tmr`, `frm`, `px`, `py`, `c1`–`c6`), a scene-based programming model, and
|
||||
conditional postfixes (`zr`, `nz`, `gt`, `ls`, `ge`, `le`). Programs declare
|
||||
`SCENE` blocks and dispatch them with `perform`. Drawing primitives include
|
||||
`plot`, `fillin`, `fillscr`, and `goto`. See `Videotron2K.md` and the VDC
|
||||
implementation under `tsvm_core/.../vdc/`.
|
||||
|
||||
### TVDOS — the operating system
|
||||
|
||||
`assets/disk0/tvdos/` is a complete DOS-style userland:
|
||||
|
||||
- **Kernel and drivers** — `TVDOS.SYS`, `HSDPADRV.SYS`, `hyve.SYS`,
|
||||
installable drivers under `moviedev/` and `tuidev/`.
|
||||
- **Custom filesystem** — TEVD, with the on-disk format documented in
|
||||
`tvdos/filesystem.md`.
|
||||
- **Internationalisation** — Colemak / Dvorak / QWERTY keymaps and an `i18n/`
|
||||
resource tree.
|
||||
- **Userland binaries** (`tvdos/bin/`) — a shell (`command.js`), file tools
|
||||
(`hexdump`, `less`, `tee`, `touch`, `printfile`, `writeto`, `defrag`,
|
||||
`lfs`, `drives`), an editor (`edit.js`), a file manager (`zfm.js`), a
|
||||
network fetcher (`geturl`), gzip/Zstd helpers, palette tools, and a battery
|
||||
of media players (`playmp2`, `playpcm`, `playwav`, `playmv1`, `playtev`,
|
||||
`playtav`, `playtad`, `playucf`).
|
||||
- **Taut tracker** — a full in-VM tracker (`taut.js`,
|
||||
`taut_instredit.js`, `taut_sampleedit.js`, `taut_notationedit.js`,
|
||||
`taut_fileop.js`) with its own font and chrome assets.
|
||||
|
||||
### Codecs and media formats
|
||||
|
||||
tsvm ships a small but serious codec lab. Encoders are written in C and live
|
||||
in `video_encoder/`; decoders are split between JavaScript players in TVDOS
|
||||
and hardware-accelerated Kotlin backends in the VM core.
|
||||
|
||||
- **iPF (Type 1 / 2 / 1-delta)** — picture and legacy movie format. Encoders:
|
||||
`encodeipf.js`, `encodemov.js`, `encodemov2.js`. Documented in
|
||||
`terranmon.txt`.
|
||||
- **TEV (TSVM Enhanced Video)** — modern DCT codec with motion compensation,
|
||||
16×16 blocks, YCoCg-R 4:2:0, and either quality-mode or bitrate-mode rate
|
||||
control. Encoder: `video_encoder/encoder_tev.c`. Decoder: `playtev.js`,
|
||||
with `tevDecode` / `tevIdct8x8` / `tevMotionCopy8x8` accelerated in
|
||||
`GraphicsJSR223Delegate.kt`.
|
||||
- **TAV (TSVM Advanced Video)** — successor to TEV based on the Discrete
|
||||
Wavelet Transform. Five wavelet types (5/3 reversible, 9/7 irreversible,
|
||||
CDF 13/7, DD-4, Haar), 6-level decomposition, EZBC sparsity coding,
|
||||
perceptual quantisation, and an optional **3D temporal DWT** that encodes
|
||||
whole groups of pictures as one unified wavelet tree. Includes a packet
|
||||
inspector (`tav_inspector.c`) and coefficient visualiser
|
||||
(`tav_visualise_coefficients.c`).
|
||||
- **TAD (TSVM Advanced Audio)** — perceptual audio codec at 32 kHz stereo,
|
||||
using CDF 9/7 wavelets, M/S decorrelation, gamma compression, pre-emphasis,
|
||||
EZBC, and Zstd. Achieves ~2.5:1 compression vs. PCMu8 at quality 3 while
|
||||
preserving the full 0–16 kHz band. Designed to be embeddable inside TAV so
|
||||
audio chunks can align with video GOP boundaries.
|
||||
- **Taud** — tracker module format with conversion tools from
|
||||
the major formats: `it2taud.py` (Impulse Tracker), `mod2taud.py`
|
||||
(ProTracker / FastTracker), `s3m2taud.py` (Scream Tracker 3), plus
|
||||
`2taud.sh` and shared helpers in `taud_common.py`. Note effects are
|
||||
documented in `TAUD_NOTE_EFFECTS.md`. The `AudioAdapter` runs the same
|
||||
IIR-only 2-pole resonant low-pass topology used by Impulse Tracker /
|
||||
OpenMPT / Schism.
|
||||
- **MP2** — reference MPEG-1 Layer II environment via `MP2Env.kt` and
|
||||
`playmp2.js`.
|
||||
|
||||
### Languages and runtimes
|
||||
|
||||
- **JavaScript** is the VM's native code, executed by GraalVM in a sandboxed
|
||||
context with a curated set of host bindings (graphics, audio, filesystem,
|
||||
DMA, compression, networking, low-level peek/poke).
|
||||
- **TerranBASIC** is provided by the
|
||||
[TerranBASIC](https://github.com/curioustorvald/TerranBASIC) repository and
|
||||
shipped as `tbas` on the system disk. The `TerranBASICexecutable/` subproject
|
||||
packages a BASIC-only flavour of the machine.
|
||||
- **Videotron2K assembly** for VDC programs.
|
||||
|
||||
### Documentation
|
||||
|
||||
- `terranmon.txt` — the architecture reference (memory map, peripheral
|
||||
protocol, codec bitstreams).
|
||||
- `doc/*.tex` — machine-readable LaTeX sources for the TSVM and TVDOS manuals,
|
||||
built with `doc/makepdf.sh`.
|
||||
- `Videotron2K.md` — VDC programming guide.
|
||||
- `TAUD_NOTE_EFFECTS.md` — tracker effect reference.
|
||||
- `CLAUDE.md` — a condensed map of the project for collaborators (and
|
||||
language-model assistants) working in the tree.
|
||||
|
||||
## Building and running
|
||||
|
||||
### Prerequisites
|
||||
|
||||
JDK 21 runtimes laid out under `~/Documents/openjdk/` with platform-specific
|
||||
names:
|
||||
|
||||
- `jdk-21.0.1-x86` — Linux AMD64
|
||||
- `jdk-21.0.1-arm` — Linux Aarch64
|
||||
- `jdk-21.0.1-windows` — Windows AMD64
|
||||
- `jdk-21.0.1.jdk-x86` — macOS Intel
|
||||
- `jdk-21.0.1.jdk-arm` — macOS Apple Silicon
|
||||
|
||||
`jlink` is then used to produce trimmed runtimes under `out/runtime-*`.
|
||||
|
||||
### Common entry points
|
||||
|
||||
- **Run the emulator** — `TsvmEmulator.java` (in `tsvm_executable/`).
|
||||
- **Run TerranBASIC-only build** — `TerranBASIC.java` (in
|
||||
`TerranBASICexecutable/`).
|
||||
- **Package an installable bundle** — pick the right script in `buildapp/`:
|
||||
- `build_app_linux_x86.sh`
|
||||
- `build_app_linux_arm.sh`
|
||||
- `build_app_mac_x86.sh`
|
||||
- `build_app_mac_arm.sh`
|
||||
- `build_app_windows_x86.sh`
|
||||
- **Build C encoders** — in `video_encoder/`: `make` (TEV), `make tav`,
|
||||
`make tad`.
|
||||
|
||||
### Encoding sample media
|
||||
|
||||
```bash
|
||||
# Quality-mode TEV encode
|
||||
./encoder_tev -i input.mp4 -o clip.tev -q 3
|
||||
|
||||
# TAV with 9/7 wavelet, quality 4
|
||||
./encoder_tav -i input.mp4 -w 1 -q 4 -o clip.tav
|
||||
|
||||
# TAV with 3D temporal DWT (GOP-unified encoding)
|
||||
./encoder_tav -i input.mp4 --temporal-dwt -o clip.tav
|
||||
|
||||
# TAD audio at the highest quality
|
||||
./encoder_tad -i input.mp4 -o track.tad -q 5
|
||||
```
|
||||
|
||||
Then, inside TVDOS:
|
||||
|
||||
```
|
||||
A:\> playtev clip.tev
|
||||
A:\> playtav clip.tav
|
||||
A:\> playtad track.tad
|
||||
```
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
tsvm_core/ VM core, peripherals, VDC, JS bindings (Kotlin)
|
||||
tsvm_executable/ Main emulator GUI (LibGDX)
|
||||
TerranBASICexecutable/ For creatingTerranBASIC executable
|
||||
assets/bios/ BIOS ROMs and source
|
||||
assets/disk0/ Boot disk image, including all of TVDOS
|
||||
video_encoder/ C encoders, decoder libs, inspectors (TEV / TAV / TAD)
|
||||
ipf_encoder/ Reference iPF encoder
|
||||
doc/ LaTeX sources for the TSVM / TVDOS manuals
|
||||
buildapp/ Per-platform packaging scripts
|
||||
My_BASIC_Programs/ Example BASIC programs
|
||||
*.py, *.sh, *.kts Conversion tools and ad-hoc utilities
|
||||
```
|
||||
|
||||
## Licence
|
||||
|
||||
See `COPYING`.
|
||||
|
||||
1423
TAUD_NOTE_EFFECTS.md
Normal file
1423
TAUD_NOTE_EFFECTS.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,5 +10,7 @@
|
||||
<orderEntry type="module" module-name="tsvm_core" />
|
||||
<orderEntry type="library" name="TerranVirtualDisk" level="project" />
|
||||
<orderEntry type="library" name="lib" level="project" />
|
||||
<orderEntry type="library" name="badlogicgames.gdx" level="project" />
|
||||
<orderEntry type="library" name="badlogicgames.gdx.backend.lwjgl3" level="project" />
|
||||
</component>
|
||||
</module>
|
||||
@@ -122,8 +122,39 @@ class VMGUI(val loaderInfo: EmulInstance, val viewportWidth: Int, val viewportHe
|
||||
private var rebootRequested = false
|
||||
|
||||
private fun reboot() {
|
||||
vmRunner.close()
|
||||
coroutineJob.interrupt()
|
||||
// Order is critical: stop ALL execution first, then dispose peripherals
|
||||
// before re-initialising. Without this, the old JS thread races the new
|
||||
// one on shared VM memory / IO state and can SIGSEGV on disposed peripherals.
|
||||
|
||||
// 1. Stop parallel/child contexts. park() interrupts and joins them.
|
||||
vm.park()
|
||||
vm.poke(-90L, -128)
|
||||
|
||||
// 2. Interrupt the main runner thread and cancel the GraalVM context.
|
||||
if (::coroutineJob.isInitialized) coroutineJob.interrupt()
|
||||
try { if (::vmRunner.isInitialized) vmRunner.close() } catch (_: Throwable) {}
|
||||
|
||||
// 3. Wait for the main runner thread to actually finish.
|
||||
if (::coroutineJob.isInitialized && coroutineJob !== Thread.currentThread()) {
|
||||
try {
|
||||
coroutineJob.join(2000L)
|
||||
if (coroutineJob.isAlive) {
|
||||
System.err.println("[VMGUI] runner ${vm.id} did not exit within 2s; proceeding anyway")
|
||||
coroutineJob.interrupt()
|
||||
}
|
||||
}
|
||||
catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Now it's safe to release native resources held by peripherals.
|
||||
for (i in 1 until vm.peripheralTable.size) {
|
||||
try {
|
||||
vm.peripheralTable[i].peripheral?.dispose()
|
||||
}
|
||||
catch (_: Throwable) {}
|
||||
}
|
||||
|
||||
vm.init()
|
||||
init()
|
||||
|
||||
Binary file not shown.
@@ -1,5 +1,5 @@
|
||||
con.reset_graphics();con.curs_set(0);con.clear();
|
||||
graphics.resetPalette();graphics.setBackground(0,0,0);
|
||||
graphics.resetPalette();graphics.setPalette(0, 0, 0, 0, 15);graphics.setBackground(0,0,0);
|
||||
|
||||
let logo = gzip.decomp(base64.atob("H4sICJoBTGECA3Rzdm1sb2dvLnJhdwDtneu2nCoQhPf7v6xLEMUL5lxyVk6yhxm7mmZGpfqnK7uC+gkN1TA/fhTFF+Ni8eOjwedPXsgLeSEvDPLCIC8M8sIgL+SFvJAX8kJeGOSFQV4Y5IVBXsgLeSEv5IW8MMgLow1e1i4XfH/kJR8deSEvcl48eSEvAC+RvJAXgJedvJAXOS9DR17Ii5yXSF7IC8DLTl7Ii5yX0JEX8iLnZSUv5EXOy7Nsl7yQF6h7IS/kBcheyAt5eYx+Jy/kRc7L0pEX8iLmZezIC3kR8zJ05IW8iHnxO3khL2JeDnAhL+Tlj8HoABfyQl6kqS55IS9/rrssHXkhL1Jewt6RF/Ii5GVYO4vYctouxGVLe2cXXvHg3TeN3eeu6rR9lRafl5ewGr3I6RHEOXXmMSse/PeSwTV7Vac9V2nxSXkZotmnv/ffvulYAZZ//h8HP/f+e0tC9qpK2+01WnxSXtZq372bu1oxwc/9u+mesld12lOVFp+Ul65SXtHHrl5s8HNfs+9vNdHeqrT4/rz8/kxC6mrGUJiR/hwfvIn2UKXFDfAyIhlgWSyFGenyopWo9lKlxffn5f9s122VcUHzx4casCF7VaXt9hotboCX+OsJpq56ROipj9mRczTRjlVa3AAvTmhym0QqykjHl3kqpp2qtPj+vKxY/1waoSAj/TlyDibaoUqLG+AlvG8w+h1PTUY6H+SpiPZapcX35yX18sWIN5tIDz2eP+oH5dq+Sosb4GV6z0RaY8lM2Q99MtGeq7S4AV4cOJqbm1XyjDQc5qli7X6v0uL787J8PfHv6sVobh3h2mOVFjfAi4fWIt5qIq3ZhZDVRHur0uL787J95auPTmAiPSwHOckikUx7qNLiBngZ35zsApZMzP5VNNFeqrT4/rz8zOTe3L3ILBnIOgK14aVJ3ES6Jy/z+7OX3+bwmHXUy/JUifZUpcUN8OIhJ+WtJhJmHWHaqUqL78/Lqkr+3mIi+ezI6U20Q5UWN8BL+ES2K7Nk5uzIOZtor1VafH9e/rOO0vt56RyakXp5nnqoXaXFDfAyfWLx5fe1N3lGugF5agQn6jYtboCXt1tHj664NCMdgZ7wQFvpfaS+dV6Wr8/MpgWWzJB9WYOJ9lilxQ3wMujWOt9hIi3ZwWAx0d6qtPj+vGyFz89k6UeY7TpsVdYbFUrJVS+wfxrBp2DxalIUf0gwXMytI5n2Ujp+t87LbrsQLk0TXlkye3adSG76vNAuqGqHTKT78vL6L3stL4cvZpIXSvXoPG4ytI503w55QeNoLTaJh7IJzrOSoXWkM5E4HqFxmFgO5tbRsXaZVzaQl2r57rFNswo7pkXhcq2G1pHKRLovL2Xz6T1tSwxOZQM7WaGUhwv6n2qXeh+OvNis16V5wBfeo6xQSrUqGw2tI42JdF9erPyAFB2onLdkZIVSq0b7kOBN1eK2eDH0G2eH9f5BkJHm99jvXqN9eKuDRrUxXkzrGWKPDHWr2jqKKu2jTmlRqTbGi229VArI7NVrC6W8Rlsww1eoNseLcT3mDKA4H2ZT69OruLZkBRFXbY4X63rvzYlX3x93ssv22AeNdi9xKPAWN8eLeQFvcmoTSWYd/XsV1j5EwZXZXs3wYl5ht3vpELAdZKTTi6uo9iYaalDVBnmxr/j+Zf2DJpLPLqjmr6LawlRWbXu1w0uFHUi/hiSsbEpWKLWotBdhx1FS6NUILxW2lGzS6mr3KiMdnl9FtQ/vcdSotslLjT0CMzApwayjDZrwwFO13iTjvTcvNc4jC7iJJLOORo1BBZifOturKV5qbFr777ECRo/QOurlC7ZBfoNeo9osLzU23Ue0bEp2PPOsKslCire0hV4t8VJjG5LDvmyxdfSF9xpQnwH0Re3yUuE8+BkzkWTHM6/Q0vSsKj43MJFuz0uN35tw0MxEbh3Bsx5wzmNgIt2flwq/ZxNlII7ZbDe/x/7b5ESoDW6eE6o2zov9kJSQlVXZ8cwRrD7eVGu20rXgtnmx/z2+QebcDLn1V/f19CriCg3SfwSrkpdatVOSzxuzjuTzukXVXRSbSI3wYvx7wklmyfydPz6svw7ZVdnhcPtJThtPRwSq5OXnVMLUS3LS6cmYJW18Oe2VaiumO8UmUjO8/J0zGA5KQbj80cv22E+KITT1muWUY1Xy8j8x0WpUisLl1Sk7wfWvp71C7cMO02tUA3n5Y4YwmyCzCC2ZlP3kZ9G66pH20dCymp4W0Cgv//QyIS5bKlvE25T+t3++897cWw86VUde8OgnoS+TFJhNwlWysp4wKVUjedHEa2B2XQXfUaGUZXVgVKq+znjJy7MeRvY/O/wHWQfpmkeRU/r0FMMyE+navPQf5wU6ZubZHvtnUXKEzaJWXa/MS61T6KzGI2jXrc9aR77Kjt5Br+ovzEu1U+iM8l2kgO/5Hnv74sCtQHW+MC8fOtUdeB3yk29D1joK6k5O2/OWlE2dnZflnLwsgCXzZ58UhNNeTBvyDUtMpLPzEs/JS1TUSrzaY29dhzEXqW7X5SWck5eAWDKwdQRrQylr0d77s/PizsmLw3Os/PHMS5X8bStUXS7Ly0d+tRNca5edoft6j/2z0P1q2lio+rzXOz0v8xl5mfGs9GCPvWnGe1gld6gaL8vLcEZeBjwpx6yjsoQ/Fqumy/JyxgEp4UkWaB2VJXCuXDVclpcTzqgjWoQk2WP/LPCfHlkNVNfL8nLCGZLDZ/2odVSyohAMVHd/VV7Ol/E+9gqHpdcpuxAvOoUdPvNIdO5Pr9x7fwFe3Om7F6ElA1lHehNpMlF9klpdgJezZTBRw/SIWkf678XZqI6X5aU/1RQp391LtqauAvDKPdfFSHW7LC/nMpGC1pIBrSOtieStVIfL8nKmlHdWWzJR2RFgJtJmprpcl5fzlE1takvGJ8n3W2wijWaq2f7vIry4k6QwyaktmUXdESAm0t7bqU7X5aXGKXQaI8/ZjZnyjgDRng1V04V5qXAKnQIXb1fatCOV6nJtb6kaLszLCYak5AyNHqQjkGuvpqrrlXmxP4UOTXWd5azfQ/cu1Q6mqpnh90K8fHhafdghQMuKG3bnQu3U26rGa/NifAodNBYJvlzE6Angncu0J2PVxyTrWrwYn0IHeEaSDxcwenZ0X6ZM21mrjhfnxfYUOvFQJHwPcqMnwvct0V7MVbfL82J5Cp1sJIrir1Zca7w7+K4l2oO9qr8+L19mp9AJYJmhdyCdwa2Kez7W3iqozrfg5cvmFLpXPUDalhjQbkBq9ATFDR9rjxVUv/eEl+WF8ZEgLwzywiAvDPLC509eyAt5IS8M8sIgLwzywiAv5IW8kBfyQl4Y5IVBXhjkhUFeyAt5IS/khbwwyAuDvDDIC+OWvPwFgd7gz8BmAQA="));
|
||||
|
||||
@@ -77,7 +77,7 @@ tmr = sys.nanoTime();
|
||||
while (sys.nanoTime() - tmr < 2147483648) sys.spin();
|
||||
// clear screen
|
||||
graphics.clearPixels(255);con.color_pair(239,255);
|
||||
con.clear();con.move(1,1);
|
||||
con.clear();con.move(1,1);graphics.resetPalette();
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
let p=_BIOS.FIRST_BOOTABLE_PORT;com.sendMessage(p[0],"DEVRST\x17");com.sendMessage(p[0],'OPENR"tvdos/hyve.SYS",'+p[1]);let r=com.getStatusCode(p[0]);if(0==r)if(com.sendMessage(p[0],"READ"),r=com.getStatusCode(p[0]),0==r){let g=com.pullMessage(p[0]);eval(g)}else println("I/O Error");else println("TVDOS.SYS not found");println("Shutting down...");println("It is now safe to turn off the power")
|
||||
let p=_BIOS.FIRST_BOOTABLE_PORT;com.sendMessage(p[0],"DEVRST\x17");com.sendMessage(p[0],'OPENR"tvdos/TVDOS.SYS",'+p[1]);let r=com.getStatusCode(p[0]);if(0==r)if(com.sendMessage(p[0],"READ"),r=com.getStatusCode(p[0]),0==r){let g=com.pullMessage(p[0]);eval(g)}else println("I/O Error");else println("TVDOS.SYS not found");println("Shutting down...");println("It is now safe to turn off the power")
|
||||
@@ -1,9 +1,12 @@
|
||||
echo "Starting TVDOS..."
|
||||
|
||||
rem put set-xxx commands here:
|
||||
set PATH=\tvdos\installer;\tvdos\tuidev;$PATH
|
||||
set PATH=\tvdos\installer;\tvdos\tuidev;\tbas;\hopper\bin;$PATH
|
||||
set INCLPATH=\hopper\include;$INCLPATH
|
||||
set HELPPATH=\hopper\help;$HELPPATH
|
||||
set KEYBOARD=us_colemak
|
||||
|
||||
rem this line specifies which shell to be presented after the boot precess:
|
||||
tvdos/i18n/korean
|
||||
zfm
|
||||
command -fancy
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Copyright (c) 2020-2024 CuriousTorvald
|
||||
Copyright (c) 2020-2026 CuriousTorvald
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
TVDOS (c) 2020-2024 CuriousTorvald
|
||||
TVDOS (c) 2020-2026 CuriousTorvald
|
||||
|
||||
TVDOS is provided "as is", without warranty of any kind; in no event shall the authors or copyright holders be liable for any claim, damages or other liabilities. Run 'less COPYING' for more information.
|
||||
@@ -1,24 +1,181 @@
|
||||
graphics.setBackground(2,1,3);
|
||||
graphics.resetPalette();
|
||||
graphics.setBackground(2,1,3)
|
||||
graphics.resetPalette()
|
||||
const GL = require("gl")
|
||||
const win = require("wintex")
|
||||
const keysym = require("keysym")
|
||||
|
||||
function captureUserInput() {
|
||||
sys.poke(-40, 1);
|
||||
sys.poke(-40, 1)
|
||||
}
|
||||
|
||||
function getKeyPushed(keyOrder) {
|
||||
return sys.peek(-41 - keyOrder);
|
||||
return sys.peek(-41 - keyOrder)
|
||||
}
|
||||
|
||||
let _fsh = {};
|
||||
_fsh.titlebarTex = new GL.Texture(2, 14, base64.atob("/u/+/v3+/f39/f39/f39/f39/P39/Pz8/Pv7+w=="));
|
||||
_fsh.scrdim = con.getmaxyx();
|
||||
_fsh.scrwidth = _fsh.scrdim[1];
|
||||
_fsh.scrheight = _fsh.scrdim[0];
|
||||
_fsh.brandName = "f\xb3Sh";
|
||||
function readMousePos() {
|
||||
let lx = sys.peek(-33) & 0xFF
|
||||
let hx = sys.peek(-34) & 0xFF
|
||||
let ly = sys.peek(-35) & 0xFF
|
||||
let hy = sys.peek(-36) & 0xFF
|
||||
return [(hx << 8) | lx, (hy << 8) | ly]
|
||||
}
|
||||
|
||||
function readMouseButtons() {
|
||||
return sys.peek(-37) & 0xFF
|
||||
}
|
||||
|
||||
// Returns true if any of the eight key event buffer slots holds keycode `kc`.
|
||||
function isKeyDown(kc) {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
if ((sys.peek(-41 - i) & 0xFF) === kc) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
let _fsh = {}
|
||||
|
||||
// Config file path
|
||||
_fsh.CONFIG_PATH = "A:/home/config/fshrc"
|
||||
|
||||
// Widget row caps (must match the loop bounds in draw())
|
||||
_fsh.TODO_MAX_ROWS = 13 // todoWidget draws i = 0..12
|
||||
_fsh.QA_MAX_ROWS = 22 // quickAccessWidget draws i = 0..21
|
||||
_fsh.TODO_TEXT_WIDTH = 24 // visible characters per todo row
|
||||
_fsh.QA_LABEL_WIDTH = 24 // visible characters per QA label
|
||||
_fsh.QA_CMD_WIDTH = 60 // command path field width in dialog
|
||||
|
||||
// Highlight foreground for keyboard focus on widget lists. The background
|
||||
// stays transparent (255) so the wallpaper continues to show through.
|
||||
_fsh.HL_FG = 230
|
||||
_fsh.HL_BG = 255
|
||||
|
||||
// Default Quick Access entries when fshrc is missing or empty
|
||||
_fsh.DEFAULT_QA = [
|
||||
["Files", "/tvdos/bin/zsh.js"],
|
||||
["Editor", "/tvdos/bin/edit.js"],
|
||||
["BASIC", "/tbas/basic.js"],
|
||||
["DOS Shell", "/tvdos/bin/command.js /fancy"]
|
||||
]
|
||||
|
||||
// Mouse button bits (MMIO[36] layout per IOSpace.kt)
|
||||
_fsh.MB_LEFT = 1
|
||||
_fsh.MB_RIGHT = 2
|
||||
|
||||
// Current focus: null or {widgetId: string, index: number}.
|
||||
// Index uses the same convention as hitTest: 0..length-1 are entries,
|
||||
// `length` is the "+ Click to add" row.
|
||||
_fsh.focus = null
|
||||
|
||||
// Parse fshrc text into {todos: [[text, done], ...], qa: [[label, cmd], ...]}.
|
||||
// Returns null for both arrays when input is empty/whitespace.
|
||||
_fsh.parseConfig = function(text) {
|
||||
let todos = []
|
||||
let qa = []
|
||||
let section = null
|
||||
if (!text) return {todos: todos, qa: qa}
|
||||
let lines = text.split("\n")
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let line = lines[i]
|
||||
// strip trailing \r if any
|
||||
if (line.length && line.charCodeAt(line.length - 1) === 13) {
|
||||
line = line.substring(0, line.length - 1)
|
||||
}
|
||||
if (line.length === 0) continue
|
||||
if (line.charAt(0) === "[") {
|
||||
let close = line.indexOf("]")
|
||||
if (close > 0) {
|
||||
let name = line.substring(1, close).trim().toUpperCase()
|
||||
if (name === "TODO" || name === "QUICK_ACCESS") section = name
|
||||
else section = null // unknown section: ignore until next header
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (section === "TODO") {
|
||||
if (line.length < 2) continue
|
||||
let marker = line.charAt(0)
|
||||
if ((marker === "+" || marker === "-") && line.charAt(1) === " ") {
|
||||
todos.push([line.substring(2), marker === "+"])
|
||||
}
|
||||
} else if (section === "QUICK_ACCESS") {
|
||||
let comma = line.indexOf(",")
|
||||
if (comma <= 0) continue // need a non-empty label
|
||||
let label = line.substring(0, comma)
|
||||
let cmd = line.substring(comma + 1)
|
||||
qa.push([label, cmd])
|
||||
}
|
||||
}
|
||||
return {todos: todos, qa: qa}
|
||||
}
|
||||
|
||||
// Build fshrc text from in-memory model. Inverse of parseConfig.
|
||||
_fsh.serializeConfig = function(todos, qa) {
|
||||
let out = "[TODO]\n"
|
||||
for (let i = 0; i < todos.length; i++) {
|
||||
let t = todos[i]
|
||||
out += (t[1] ? "+ " : "- ") + t[0] + "\n"
|
||||
}
|
||||
out += "\n[QUICK_ACCESS]\n"
|
||||
for (let i = 0; i < qa.length; i++) {
|
||||
out += qa[i][0] + "," + qa[i][1] + "\n"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Read fshrc; populate todoWidget.todoList and quickAccessWidget.entries.
|
||||
// Falls back to defaults on missing/empty/malformed file.
|
||||
_fsh.loadConfig = function() {
|
||||
let f = files.open(_fsh.CONFIG_PATH)
|
||||
let parsed = {todos: [], qa: []}
|
||||
if (f.exists) {
|
||||
try {
|
||||
parsed = _fsh.parseConfig(f.sread())
|
||||
} catch (e) {
|
||||
serial.printerr("fsh.loadConfig: parse failed: " + e)
|
||||
parsed = {todos: [], qa: []}
|
||||
}
|
||||
}
|
||||
todoWidget.todoList = parsed.todos
|
||||
quickAccessWidget.entries = (parsed.qa.length > 0)
|
||||
? parsed.qa
|
||||
: _fsh.DEFAULT_QA.slice() // copy so saves don't mutate the constant
|
||||
}
|
||||
|
||||
// Persist the current in-memory todos + QA entries to fshrc.
|
||||
_fsh.saveConfig = function() {
|
||||
try {
|
||||
let f = files.open(_fsh.CONFIG_PATH)
|
||||
if (!f.exists) f.mkFile()
|
||||
f.swrite(_fsh.serializeConfig(todoWidget.todoList, quickAccessWidget.entries))
|
||||
} catch (e) {
|
||||
serial.printerr("fsh.saveConfig: write failed: " + e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Map (mouse char x, mouse char y) to a row index for a widget drawn at
|
||||
// (xoff, yoff) with `length` existing entries and `maxRows` total rows.
|
||||
// Returns null / {kind:"add"} / {kind:"item", index: i}.
|
||||
_fsh.hitTestList = function(charX, charY, xoff, yoff, textWidth, length, maxRows) {
|
||||
// Each row sits at (yoff + i + 2, xoff..xoff + textWidth + 1).
|
||||
// Column range: icon at xoff, text at xoff+2 .. xoff+1+textWidth.
|
||||
// Allow clicks anywhere on the row's char cells (icon + text region).
|
||||
let relY = charY - yoff - 2
|
||||
if (relY < 0 || relY >= maxRows) return null
|
||||
if (charX < xoff || charX > xoff + 1 + textWidth) return null
|
||||
if (relY < length) return {kind: "item", index: relY}
|
||||
if (relY === length) return {kind: "add"}
|
||||
return null
|
||||
}
|
||||
|
||||
_fsh.titlebarTex = new GL.Texture(2, 14, base64.atob("/u/+/v3+/f39/f39/f39/f39/P39/Pz8/Pv7+w=="))
|
||||
_fsh.scrdim = con.getmaxyx()
|
||||
_fsh.scrwidth = _fsh.scrdim[1]
|
||||
_fsh.scrheight = _fsh.scrdim[0]
|
||||
_fsh.brandName = "f\xb3Sh"
|
||||
_fsh.brandLogoTexSmall = new GL.Texture(24, 14, gzip.decomp(base64.atob(
|
||||
"H4sIAAAAAAAAAPv/Hy/4Qbz458+fIeILQQBIwoSh6qECuMVBukCmIJkDVQ+RQNgLE0MX/w+1lyhxqIUwTLJ/sQMAcIXsbVABAAA="
|
||||
)));
|
||||
_fsh.scrlayout = ["com.fsh.clock","com.fsh.calendar","com.fsh.todo_list", "com.fsh.quick_access"];
|
||||
)))
|
||||
_fsh.scrlayout = ["com.fsh.clock","com.fsh.calendar","com.fsh.todo_list", "com.fsh.quick_access"]
|
||||
|
||||
_fsh.drawWallpaper = function() {
|
||||
let wp = files.open("A:/home/wall.bytes")
|
||||
@@ -28,85 +185,85 @@ _fsh.drawWallpaper = function() {
|
||||
wp.pread(b, 250880, 0)
|
||||
dma.ramToFrame(b, 0, 250880)
|
||||
sys.free(b)
|
||||
};
|
||||
}
|
||||
|
||||
_fsh.drawTitlebar = function(titletext) {
|
||||
GL.drawTexPattern(_fsh.titlebarTex, 0, 0, 560, 14);
|
||||
GL.drawTexPattern(_fsh.titlebarTex, 0, 0, 560, 14)
|
||||
if (titletext === undefined || titletext.length == 0) {
|
||||
con.move(1,1);
|
||||
print(" ".repeat(_fsh.scrwidth));
|
||||
GL.drawTexImageOver(_fsh.brandLogoTexSmall, 268, 0);
|
||||
con.move(1,1)
|
||||
print(" ".repeat(_fsh.scrwidth))
|
||||
GL.drawTexImageOver(_fsh.brandLogoTexSmall, 268, 0)
|
||||
}
|
||||
else {
|
||||
con.color_pair(240, 255);
|
||||
GL.drawTexPattern(_fsh.titlebarTex, 268, 0, 24, 14);
|
||||
con.move(1, 1 + (_fsh.scrwidth - titletext.length) / 2);
|
||||
print(titletext);
|
||||
con.color_pair(240, 255)
|
||||
GL.drawTexPattern(_fsh.titlebarTex, 268, 0, 24, 14)
|
||||
con.move(1, 1 + (_fsh.scrwidth - titletext.length) / 2)
|
||||
print(titletext)
|
||||
}
|
||||
con.color_pair(254, 255);
|
||||
};
|
||||
con.color_pair(254, 255)
|
||||
}
|
||||
|
||||
|
||||
_fsh.Widget = function(id, w, h) {
|
||||
this.identifier = id;
|
||||
this.width = w;
|
||||
this.height = h;
|
||||
this.identifier = id
|
||||
this.width = w
|
||||
this.height = h
|
||||
|
||||
if (!this.identifier) {
|
||||
this.identifier = "";
|
||||
this.identifier = ""
|
||||
}
|
||||
|
||||
//this.update = function() {};
|
||||
//this.update = function() {}
|
||||
/**
|
||||
* Params charXoff and charYoff are ZERO-BASED!
|
||||
*/
|
||||
this.draw = function(charXoff, charYoff) {};
|
||||
this.draw = function(charXoff, charYoff) {}
|
||||
}
|
||||
|
||||
_fsh.widgets = {}
|
||||
_fsh.registerNewWidget = function(widget) {
|
||||
_fsh.widgets[widget.identifier] = widget;
|
||||
_fsh.widgets[widget.identifier] = widget
|
||||
}
|
||||
|
||||
let clockWidget = new _fsh.Widget("com.fsh.clock", _fsh.scrwidth - 8, 7*2);
|
||||
let clockWidget = new _fsh.Widget("com.fsh.clock", _fsh.scrwidth - 8, 7*2)
|
||||
clockWidget.numberSheet = new GL.SpriteSheet(19, 22, new GL.Texture(190, 22, gzip.decomp(base64.atob(
|
||||
"H4sIAAAAAAAAAMWVW3LEMAgE739aHcFJJV5ZMD2I9ToVfcl4GBr80HF8r/FaR1ozMuIyoUu87lEXI0al5qVR5AebSwchSaNE6Nyo1Nw5HXF3SfPT4Bshl"+
|
||||
"EycA8RD96mLlHbuhTgOrfLnUDZspafbSQWk56WEGvQEtWaWwgb8iz7a8AOXhsraO/q9Qw2/GnXovfVN+q2wM/p/oddn2cjF239GX3y11+SWCtc6FTHC1v"+
|
||||
"TVPkDPWWn0w+DDz93UX9v9mF5KIsQ6OdN2KJoB4ui1bXXr0AMp0YfiQo//4XhpK8555dsNehAqVS5uhb5iHn3Kko769J59KmLBe/TSR7hcsd+hr+HnrwR"+
|
||||
"9uvRF9+D3MP14gN7lqx+8OuNT+uqt3NFX3SN9fTbeeHNq+C29pRWzX5+Rcm7SZyjOKJ/2hkSPqul4xN279DrSYvCrNu2NI7ZMp1ouBxK3KBVVnEeAUWbK"+
|
||||
"MUDn5DPsPxmUqHZQjGpy2hergM3EVBAAAA=="
|
||||
))));
|
||||
))))
|
||||
|
||||
clockWidget.clockColon = new GL.Texture(4, 3, base64.atob("7+/v7+/v7+/v7+/v"));
|
||||
clockWidget.monthNames = ["Spring", "Summer", "Autumn", "Winter"];
|
||||
clockWidget.dayNames = ["Mondag ", "Tysdag ", "Midtveke", "Torsdag ", "Fredag ", "Laurdag ", "Sundag ", "Verddag "];
|
||||
clockWidget.clockColon = new GL.Texture(4, 3, base64.atob("7+/v7+/v7+/v7+/v"))
|
||||
clockWidget.monthNames = ["Spring", "Summer", "Autumn", "Winter"]
|
||||
clockWidget.dayNames = ["Mondag ", "Tysdag ", "Midtveke", "Torsdag ", "Fredag ", "Laurdag ", "Sundag ", "Verddag "]
|
||||
clockWidget.draw = function(charXoff, charYoff) {
|
||||
con.color_pair(254, 255);
|
||||
let xoff = charXoff * 7;
|
||||
let yoff = charYoff * 14 + 3;
|
||||
let timeInMinutes = ((sys.currentTimeInMills() / 60000)|0);
|
||||
let mins = timeInMinutes % 60;
|
||||
let hours = ((timeInMinutes / 60)|0) % 24;
|
||||
let ordinalDay = ((timeInMinutes / (60*24))|0) % 120;
|
||||
let visualDay = (ordinalDay % 30) + 1;
|
||||
let months = ((timeInMinutes / (60*24*30))|0) % 4;
|
||||
let dayName = ordinalDay % 7; // 0 for Mondag
|
||||
if (ordinalDay == 119) dayName = 7; // Verddag
|
||||
let years = ((timeInMinutes / (60*24*30*120))|0) + 125;
|
||||
con.color_pair(254, 255)
|
||||
let xoff = charXoff * 7
|
||||
let yoff = charYoff * 14 + 3
|
||||
let timeInMinutes = ((sys.currentTimeInMills() / 60000)|0)
|
||||
let mins = timeInMinutes % 60
|
||||
let hours = ((timeInMinutes / 60)|0) % 24
|
||||
let ordinalDay = ((timeInMinutes / (60*24))|0) % 120
|
||||
let visualDay = (ordinalDay % 30) + 1
|
||||
let months = ((timeInMinutes / (60*24*30))|0) % 4
|
||||
let dayName = ordinalDay % 7 // 0 for Mondag
|
||||
if (ordinalDay == 119) dayName = 7 // Verddag
|
||||
let years = ((timeInMinutes / (60*24*30*120))|0) + 125
|
||||
// draw timepiece
|
||||
GL.drawSprite(clockWidget.numberSheet, (hours / 10)|0, 0, xoff, yoff, 1);
|
||||
GL.drawSprite(clockWidget.numberSheet, hours % 10, 0, xoff + 24, yoff, 1);
|
||||
GL.drawTexImage(clockWidget.clockColon, xoff + 48, yoff + 5, 1);
|
||||
GL.drawTexImage(clockWidget.clockColon, xoff + 48, yoff + 14, 1);
|
||||
GL.drawSprite(clockWidget.numberSheet, (mins / 10)|0, 0, xoff + 57, yoff, 1);
|
||||
GL.drawSprite(clockWidget.numberSheet, mins % 10, 0, xoff + 81, yoff, 1);
|
||||
GL.drawSprite(clockWidget.numberSheet, (hours / 10)|0, 0, xoff, yoff, 1)
|
||||
GL.drawSprite(clockWidget.numberSheet, hours % 10, 0, xoff + 24, yoff, 1)
|
||||
GL.drawTexImage(clockWidget.clockColon, xoff + 48, yoff + 5, 1)
|
||||
GL.drawTexImage(clockWidget.clockColon, xoff + 48, yoff + 14, 1)
|
||||
GL.drawSprite(clockWidget.numberSheet, (mins / 10)|0, 0, xoff + 57, yoff, 1)
|
||||
GL.drawSprite(clockWidget.numberSheet, mins % 10, 0, xoff + 81, yoff, 1)
|
||||
// print month and date
|
||||
con.move(1 + charYoff, 17 + charXoff);
|
||||
print(clockWidget.monthNames[months]+" "+visualDay);
|
||||
con.move(1 + charYoff, 17 + charXoff)
|
||||
print(clockWidget.monthNames[months]+" "+visualDay)
|
||||
// print year and dayname
|
||||
con.move(2 + charYoff, 17 + charXoff);
|
||||
print("\xE7"+years+" "+clockWidget.dayNames[dayName]);
|
||||
};
|
||||
con.move(2 + charYoff, 17 + charXoff)
|
||||
print("\xE7"+years+" "+clockWidget.dayNames[dayName])
|
||||
}
|
||||
|
||||
|
||||
let calendarWidget = new _fsh.Widget("com.fsh.calendar", (_fsh.scrwidth - 8) / 2, 7*6)
|
||||
@@ -171,70 +328,284 @@ calendarWidget.draw = function(charXoff, charYoff) {
|
||||
let todoWidget = new _fsh.Widget("com.fsh.todo_list", (_fsh.scrwidth - 8) / 2, 7*10)
|
||||
todoWidget.todoList = [["Hello, world!", true]]
|
||||
todoWidget.draw = function(charXoff, charYoff) {
|
||||
let focusIndex = (_fsh.focus && _fsh.focus.widgetId === todoWidget.identifier)
|
||||
? _fsh.focus.index : -1
|
||||
|
||||
con.color_pair(254, 255)
|
||||
let xoff = charXoff * 7
|
||||
let yoff = charYoff * 14 + 3
|
||||
|
||||
con.move(charYoff, charXoff)
|
||||
print("========== TODO ==========")
|
||||
print('\u00CD'.repeat(10)+" TODO "+'\u00CD'.repeat(10))
|
||||
|
||||
for (let i = 0; i <= 12; i++) {
|
||||
let list = todoWidget.todoList[i] || ["Click to add", null]
|
||||
let list = todoWidget.todoList[i] || ["Click to add"+" ".repeat(_fsh.TODO_TEXT_WIDTH - 12), null]
|
||||
let isFocused = (i === focusIndex)
|
||||
|
||||
if (list[1] === null) con.color_pair(249, 255)
|
||||
if (isFocused) con.color_pair(_fsh.HL_FG, _fsh.HL_BG)
|
||||
else if (list[1] === null) con.color_pair(249, 255)
|
||||
else con.color_pair(254, 255)
|
||||
|
||||
con.move(charYoff + i + 2, charXoff)
|
||||
con.addch((list[1] === null) ? 43 : (list[1]) ? 0x9F : 0x9E)
|
||||
|
||||
if (i > todoWidget.todoList.length) {
|
||||
// Filler row \u2014 keep underscores but don't highlight (can't focus here)
|
||||
con.color_pair(254, 255)
|
||||
for (let k = 0; k < 24; k++) {
|
||||
con.mvaddch(charYoff + i + 2, charXoff + 2 + k, 95)
|
||||
}
|
||||
}
|
||||
else {
|
||||
con.move(charYoff + i + 2, charXoff + 2)
|
||||
print(`${list[0]}`)
|
||||
// Pad text to TODO_TEXT_WIDTH so the highlight bar covers full row
|
||||
let text = `${list[0]}`
|
||||
if (text.length > _fsh.TODO_TEXT_WIDTH) text = text.substring(0, _fsh.TODO_TEXT_WIDTH)
|
||||
if (isFocused) text = text + " ".repeat(_fsh.TODO_TEXT_WIDTH - text.length)
|
||||
print(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let quickAccessWidget = new _fsh.Widget("com.fsh.quick_access", (_fsh.scrwidth - 8) / 2, 7*20)
|
||||
quickAccessWidget.entries = [
|
||||
["Files", "/tvdos/bin/explorer.js"],
|
||||
quickAccessWidget.entries = [ // TODO read from /home/config/fshrc
|
||||
["Files", "/tvdos/bin/zfm.js"],
|
||||
["Editor", "/tvdos/bin/edit.js"],
|
||||
["BASIC", "/tbas/basic.js"],
|
||||
["DOS Shell", "/tvdos/bin/command.js /fancy"]
|
||||
["DOS Shell", "/tvdos/bin/command.js -fancy"]
|
||||
]
|
||||
quickAccessWidget.draw = function(charXoff, charYoff) {
|
||||
let focusIndex = (_fsh.focus && _fsh.focus.widgetId === quickAccessWidget.identifier)
|
||||
? _fsh.focus.index : -1
|
||||
|
||||
con.color_pair(254, 255)
|
||||
let xoff = charXoff * 7
|
||||
let yoff = charYoff * 14 + 3
|
||||
|
||||
con.move(charYoff, charXoff)
|
||||
print("====== QUICK ACCESS ======")
|
||||
print('\u00CD'.repeat(6)+" QUICK ACCESS "+'\u00CD'.repeat(6))
|
||||
|
||||
for (let i = 0; i <= 21; i++) {
|
||||
let list = quickAccessWidget.entries[i] || ["Click to add", null]
|
||||
let list = quickAccessWidget.entries[i] || ["Click to add"+" ".repeat(_fsh.QA_LABEL_WIDTH - 12), null]
|
||||
let isFocused = (i === focusIndex)
|
||||
|
||||
if (list[1] === null) con.color_pair(249, 255)
|
||||
if (isFocused) con.color_pair(_fsh.HL_FG, _fsh.HL_BG)
|
||||
else if (list[1] === null) con.color_pair(249, 255)
|
||||
else con.color_pair(254, 255)
|
||||
|
||||
con.move(charYoff + i + 2, charXoff)
|
||||
con.addch((list[1] === null) ? 0xF9 : (list[1]) ? 7 : 0x7F)
|
||||
|
||||
if (i > quickAccessWidget.entries.length) {
|
||||
con.color_pair(254, 255)
|
||||
for (let k = 0; k < 24; k++) {
|
||||
con.mvaddch(charYoff + i + 2, charXoff + 2 + k, 95)
|
||||
}
|
||||
}
|
||||
else {
|
||||
con.move(charYoff + i + 2, charXoff + 2)
|
||||
print(`${list[0]}`)
|
||||
let text = `${list[0]}`
|
||||
if (text.length > _fsh.QA_LABEL_WIDTH) text = text.substring(0, _fsh.QA_LABEL_WIDTH)
|
||||
if (isFocused) text = text + " ".repeat(_fsh.QA_LABEL_WIDTH - text.length)
|
||||
print(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
todoWidget.hitTest = function(charX, charY, xoff, yoff) {
|
||||
return _fsh.hitTestList(charX, charY, xoff, yoff,
|
||||
_fsh.TODO_TEXT_WIDTH, todoWidget.todoList.length, _fsh.TODO_MAX_ROWS)
|
||||
}
|
||||
|
||||
quickAccessWidget.hitTest = function(charX, charY, xoff, yoff) {
|
||||
return _fsh.hitTestList(charX, charY, xoff, yoff,
|
||||
_fsh.QA_LABEL_WIDTH, quickAccessWidget.entries.length, _fsh.QA_MAX_ROWS)
|
||||
}
|
||||
|
||||
|
||||
// Re-render the whole shell. Use after a dialog closes (which clobbered
|
||||
// the underlying char cells) or after execApp returns.
|
||||
_fsh.redrawAll = function() {
|
||||
con.color_pair(254, 255)
|
||||
con.clear()
|
||||
graphics.clearPixels(255)
|
||||
graphics.clearPixels2(255)
|
||||
graphics.setFramebufferScroll(0, 0)
|
||||
_fsh.drawWallpaper()
|
||||
_fsh.drawTitlebar()
|
||||
_fsh.widgets["com.fsh.clock"].draw(25, 3)
|
||||
_fsh.widgets["com.fsh.calendar"].draw(12, 8)
|
||||
_fsh.widgets["com.fsh.todo_list"].draw(10, 17)
|
||||
_fsh.widgets["com.fsh.quick_access"].draw(47, 8)
|
||||
}
|
||||
|
||||
_fsh.openAddTodoDialog = function() {
|
||||
let res = win.showDialog({
|
||||
title: "New Todo",
|
||||
fields: [{label: "Text:", initial: "", width: _fsh.TODO_TEXT_WIDTH}],
|
||||
allowDelete: false
|
||||
})
|
||||
_fsh.redrawAll()
|
||||
if (res.action !== "ok") return
|
||||
let text = res.values[0].trim()
|
||||
if (text.length === 0) return
|
||||
if (todoWidget.todoList.length >= _fsh.TODO_MAX_ROWS) return
|
||||
todoWidget.todoList.push([text, false])
|
||||
_fsh.saveConfig()
|
||||
}
|
||||
|
||||
_fsh.openEditTodoDialog = function(index) {
|
||||
let entry = todoWidget.todoList[index]
|
||||
if (!entry) return
|
||||
let res = win.showDialog({
|
||||
title: "Edit Todo",
|
||||
fields: [{label: "Text:", initial: entry[0], width: _fsh.TODO_TEXT_WIDTH}],
|
||||
allowDelete: true
|
||||
})
|
||||
_fsh.redrawAll()
|
||||
if (res.action === "cancel") return
|
||||
if (res.action === "delete") {
|
||||
todoWidget.todoList.splice(index, 1)
|
||||
_fsh.saveConfig()
|
||||
return
|
||||
}
|
||||
let text = res.values[0].trim()
|
||||
if (text.length === 0) return
|
||||
todoWidget.todoList[index] = [text, entry[1]]
|
||||
_fsh.saveConfig()
|
||||
}
|
||||
|
||||
_fsh.openAddQaDialog = function() {
|
||||
let res = win.showDialog({
|
||||
title: "New Quick Access",
|
||||
fields: [
|
||||
{label: "Label:", initial: "", width: _fsh.QA_LABEL_WIDTH},
|
||||
{label: "Command:", initial: "", width: _fsh.QA_CMD_WIDTH}
|
||||
],
|
||||
allowDelete: false
|
||||
})
|
||||
_fsh.redrawAll()
|
||||
if (res.action !== "ok") return
|
||||
let label = res.values[0].trim()
|
||||
let cmd = res.values[1].trim()
|
||||
if (label.length === 0 || cmd.length === 0) return
|
||||
if (quickAccessWidget.entries.length >= _fsh.QA_MAX_ROWS) return
|
||||
quickAccessWidget.entries.push([label, cmd])
|
||||
_fsh.saveConfig()
|
||||
}
|
||||
|
||||
_fsh.openEditQaDialog = function(index) {
|
||||
let entry = quickAccessWidget.entries[index]
|
||||
if (!entry) return
|
||||
let res = win.showDialog({
|
||||
title: "Edit Quick Access",
|
||||
fields: [
|
||||
{label: "Label:", initial: entry[0], width: _fsh.QA_LABEL_WIDTH},
|
||||
{label: "Command:", initial: entry[1], width: _fsh.QA_CMD_WIDTH}
|
||||
],
|
||||
allowDelete: true
|
||||
})
|
||||
_fsh.redrawAll()
|
||||
if (res.action === "cancel") return
|
||||
if (res.action === "delete") {
|
||||
quickAccessWidget.entries.splice(index, 1)
|
||||
_fsh.saveConfig()
|
||||
return
|
||||
}
|
||||
let label = res.values[0].trim()
|
||||
let cmd = res.values[1].trim()
|
||||
if (label.length === 0 || cmd.length === 0) return
|
||||
quickAccessWidget.entries[index] = [label, cmd]
|
||||
_fsh.saveConfig()
|
||||
}
|
||||
|
||||
_fsh.toggleTodoDone = function(index) {
|
||||
let entry = todoWidget.todoList[index]
|
||||
if (!entry) return
|
||||
entry[1] = !entry[1]
|
||||
_fsh.saveConfig()
|
||||
}
|
||||
|
||||
// Launch a Quick Access entry. cmd is the verbatim string the user typed.
|
||||
// We split on first space to derive a program path + args; if the path
|
||||
// has no leading "/", we treat it as relative to the current drive.
|
||||
_fsh.launchEntry = function(label, cmd) {
|
||||
let firstSpace = cmd.indexOf(" ")
|
||||
let progPath = (firstSpace >= 0) ? cmd.substring(0, firstSpace) : cmd
|
||||
let argTail = (firstSpace >= 0) ? cmd.substring(firstSpace + 1) : ""
|
||||
let fullPath = progPath.startsWith("/") ? ("A:" + progPath) : progPath
|
||||
|
||||
try {
|
||||
let f = files.open(fullPath)
|
||||
if (!f.exists) {
|
||||
serial.printerr("fsh.launchEntry: not found: " + fullPath)
|
||||
return
|
||||
}
|
||||
let code = f.sread()
|
||||
let tokens = [progPath].concat(argTail.length ? argTail.split(" ") : [])
|
||||
|
||||
// erase all pixels and draw wallpaper
|
||||
con.reset_graphics()
|
||||
con.clear()
|
||||
graphics.clearPixels(255)
|
||||
graphics.clearPixels2(255)
|
||||
_fsh.drawWallpaper()
|
||||
con.curs_set(1)
|
||||
|
||||
execApp(code, tokens)
|
||||
} catch (e) {
|
||||
serial.printerr("fsh.launchEntry: " + label + " failed: " + e)
|
||||
}
|
||||
con.curs_set(0)
|
||||
graphics.setBackground(2,1,3)
|
||||
graphics.resetPalette()
|
||||
// Apps (e.g. zfm) may switch to graphics mode 0; restore mode 3 so the
|
||||
// clock widget on framebuffer 2 is composited again.
|
||||
graphics.setGraphicsMode(3)
|
||||
_fsh.redrawAll()
|
||||
}
|
||||
|
||||
// Layout map: widget positions hard-coded to match the draw calls below.
|
||||
_fsh.layouts = {
|
||||
"com.fsh.todo_list": {xoff: 10, yoff: 17, widget: null},
|
||||
"com.fsh.quick_access": {xoff: 47, yoff: 8, widget: null}
|
||||
}
|
||||
|
||||
// Find which widget (if any) was hit by (charX, charY). Returns
|
||||
// {widgetId, hit} or null.
|
||||
_fsh.findHit = function(charX, charY) {
|
||||
let ids = ["com.fsh.todo_list", "com.fsh.quick_access"]
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
let id = ids[i]
|
||||
let layout = _fsh.layouts[id]
|
||||
let widget = _fsh.widgets[id]
|
||||
let hit = widget.hitTest(charX, charY, layout.xoff, layout.yoff)
|
||||
if (hit) return {widgetId: id, hit: hit}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
_fsh.dispatchLeft = function(widgetId, hit) {
|
||||
if (hit.kind === "add") {
|
||||
if (widgetId === "com.fsh.todo_list") _fsh.openAddTodoDialog()
|
||||
else _fsh.openAddQaDialog()
|
||||
return
|
||||
}
|
||||
// hit.kind === "item"
|
||||
if (widgetId === "com.fsh.todo_list") {
|
||||
_fsh.toggleTodoDone(hit.index)
|
||||
} else {
|
||||
let entry = quickAccessWidget.entries[hit.index]
|
||||
if (entry) _fsh.launchEntry(entry[0], entry[1])
|
||||
}
|
||||
}
|
||||
|
||||
_fsh.dispatchRight = function(widgetId, hit) {
|
||||
if (hit.kind !== "item") return
|
||||
if (widgetId === "com.fsh.todo_list") _fsh.openEditTodoDialog(hit.index)
|
||||
else _fsh.openEditQaDialog(hit.index)
|
||||
}
|
||||
|
||||
|
||||
// change graphics mode and check if it's supported
|
||||
graphics.setGraphicsMode(3)
|
||||
@@ -260,29 +631,130 @@ _fsh.drawWallpaper()
|
||||
_fsh.drawTitlebar()
|
||||
|
||||
|
||||
// TEST
|
||||
con.move(2,1);
|
||||
print("fSh is very much in-dev! Hit backspace to exit")
|
||||
// Load persisted state before the first draw
|
||||
_fsh.loadConfig();
|
||||
|
||||
// keyEventBuffers (read via sys.peek(-41-i)) holds *raw libGDX keycodes*,
|
||||
// not the cooked TSVM scancodes that con.getch() returns. Existing fsh.js
|
||||
// already uses 67 for Backspace (libGDX DEL); follow the same scheme here.
|
||||
const KEY_ESC = keysym.ESCAPE
|
||||
const KEY_ENTER = keysym.ENTER
|
||||
const KEY_UP = keysym.UP
|
||||
const KEY_DOWN = keysym.DOWN
|
||||
const KEY_LEFT = keysym.LEFT
|
||||
const KEY_RIGHT = keysym.RIGHT
|
||||
const KEY_LSHIFT = keysym.SHIFT_LEFT
|
||||
const KEY_RSHIFT = keysym.SHIFT_RIGHT
|
||||
|
||||
let prevButtons = 0
|
||||
let prevMouseCharX = -1
|
||||
let prevMouseCharY = -1
|
||||
let keyLatch = {} // {keycode: true} while the key is held — debounces "just pressed"
|
||||
|
||||
// TODO update for events: key down (updates some widgets), timer (updates clock and calendar widgets)
|
||||
while (true) {
|
||||
captureUserInput();
|
||||
if (getKeyPushed(0) == 67) break;
|
||||
captureUserInput()
|
||||
|
||||
_fsh.widgets["com.fsh.clock"].draw(25, 3);
|
||||
_fsh.widgets["com.fsh.calendar"].draw(12, 8);
|
||||
_fsh.widgets["com.fsh.todo_list"].draw(10, 17);
|
||||
_fsh.widgets["com.fsh.quick_access"].draw(47, 8);
|
||||
// -- keyboard --
|
||||
if (isKeyDown(KEY_ESC)) break;
|
||||
|
||||
sys.spin();sys.spin()
|
||||
let shiftDown = isKeyDown(KEY_LSHIFT) || isKeyDown(KEY_RSHIFT)
|
||||
let enterPressed = false
|
||||
|
||||
// Edge-detect each navigation key
|
||||
function edge(kc) {
|
||||
let down = isKeyDown(kc)
|
||||
let was = !!keyLatch[kc]
|
||||
keyLatch[kc] = down
|
||||
return down && !was
|
||||
}
|
||||
|
||||
if (edge(KEY_ENTER)) enterPressed = true;
|
||||
let navUp = edge(KEY_UP)
|
||||
let navDown = edge(KEY_DOWN)
|
||||
let navLeft = edge(KEY_LEFT)
|
||||
let navRight = edge(KEY_RIGHT)
|
||||
|
||||
// -- mouse --
|
||||
// MMIO returns VM-screen pixel coords (origin at the top-left of the framebuffer).
|
||||
// Widget xoff/yoff are passed straight into con.move(y, x), which is 1-indexed, so
|
||||
// we offset by +1 here. Without this the click registers one cell up-and-left from
|
||||
// where the user's pointer is, because pixel 0 = con.move(1, 1).
|
||||
let pos = readMousePos()
|
||||
let charX = (pos[0] / 7 | 0) + 1
|
||||
let charY = (pos[1] / 14 | 0) + 1
|
||||
let mouseMoved = (charX !== prevMouseCharX || charY !== prevMouseCharY)
|
||||
prevMouseCharX = charX
|
||||
prevMouseCharY = charY
|
||||
|
||||
let buttons = readMouseButtons()
|
||||
let leftEdge = ((buttons & _fsh.MB_LEFT) !== 0) && ((prevButtons & _fsh.MB_LEFT) === 0)
|
||||
let rightEdge = ((buttons & _fsh.MB_RIGHT) !== 0) && ((prevButtons & _fsh.MB_RIGHT) === 0)
|
||||
prevButtons = buttons
|
||||
|
||||
// -- focus update --
|
||||
if (navUp || navDown || navLeft || navRight) {
|
||||
if (!_fsh.focus) _fsh.focus = {widgetId: "com.fsh.todo_list", index: 0}
|
||||
if (navUp || navDown) {
|
||||
let layout = _fsh.layouts[_fsh.focus.widgetId]
|
||||
let maxRows = (_fsh.focus.widgetId === "com.fsh.todo_list")
|
||||
? _fsh.TODO_MAX_ROWS : _fsh.QA_MAX_ROWS
|
||||
let length = (_fsh.focus.widgetId === "com.fsh.todo_list")
|
||||
? todoWidget.todoList.length : quickAccessWidget.entries.length
|
||||
let maxIdx = Math.min(length, maxRows - 1)
|
||||
let next = _fsh.focus.index + (navDown ? 1 : -1)
|
||||
if (next < 0) next = 0
|
||||
if (next > maxIdx) next = maxIdx
|
||||
_fsh.focus.index = next
|
||||
} else {
|
||||
// Left/right switches widget
|
||||
let other = (_fsh.focus.widgetId === "com.fsh.todo_list")
|
||||
? "com.fsh.quick_access" : "com.fsh.todo_list"
|
||||
let otherLength = (other === "com.fsh.todo_list")
|
||||
? todoWidget.todoList.length : quickAccessWidget.entries.length
|
||||
let otherMaxRows = (other === "com.fsh.todo_list")
|
||||
? _fsh.TODO_MAX_ROWS : _fsh.QA_MAX_ROWS
|
||||
let otherMaxIdx = Math.min(otherLength, otherMaxRows - 1)
|
||||
_fsh.focus = {widgetId: other, index: Math.min(_fsh.focus.index, otherMaxIdx)}
|
||||
}
|
||||
} else if (mouseMoved) {
|
||||
let h = _fsh.findHit(charX, charY)
|
||||
_fsh.focus = h ? {widgetId: h.widgetId, index: h.hit.kind === "add"
|
||||
? ((h.widgetId === "com.fsh.todo_list")
|
||||
? todoWidget.todoList.length
|
||||
: quickAccessWidget.entries.length)
|
||||
: h.hit.index} : null
|
||||
}
|
||||
|
||||
// -- mouse click dispatch --
|
||||
if (leftEdge) {
|
||||
let h = _fsh.findHit(charX, charY)
|
||||
if (h) _fsh.dispatchLeft(h.widgetId, h.hit)
|
||||
} else if (rightEdge) {
|
||||
let h = _fsh.findHit(charX, charY)
|
||||
if (h) _fsh.dispatchRight(h.widgetId, h.hit)
|
||||
}
|
||||
|
||||
// -- keyboard dispatch (synthesise click at focus) --
|
||||
if (enterPressed && _fsh.focus) {
|
||||
let length = (_fsh.focus.widgetId === "com.fsh.todo_list")
|
||||
? todoWidget.todoList.length : quickAccessWidget.entries.length
|
||||
let hit = (_fsh.focus.index < length)
|
||||
? {kind: "item", index: _fsh.focus.index}
|
||||
: (_fsh.focus.index === length ? {kind: "add"} : null)
|
||||
if (hit) {
|
||||
if (shiftDown) _fsh.dispatchRight(_fsh.focus.widgetId, hit)
|
||||
else _fsh.dispatchLeft(_fsh.focus.widgetId, hit)
|
||||
}
|
||||
}
|
||||
|
||||
// -- redraw --
|
||||
_fsh.widgets["com.fsh.clock"].draw(25, 3)
|
||||
_fsh.widgets["com.fsh.calendar"].draw(12, 8)
|
||||
_fsh.widgets["com.fsh.todo_list"].draw(10, 17)
|
||||
_fsh.widgets["com.fsh.quick_access"].draw(47, 8)
|
||||
|
||||
sys.spin(); sys.spin()
|
||||
}
|
||||
|
||||
con.move(3,1);
|
||||
con.color_pair(201,255);
|
||||
print("cya!");
|
||||
|
||||
let konsht = 3412341241;
|
||||
println(konsht);
|
||||
|
||||
let pppp = graphics.getCursorYX();
|
||||
println(pppp.toString());
|
||||
con.reset_graphics()
|
||||
con.clear()
|
||||
@@ -1,6 +1,6 @@
|
||||
if (exec_args[1] === undefined) {
|
||||
println("Usage: compile -le/-lo myfile.js")
|
||||
println(" The compiled and linked file will be myfile.out")
|
||||
println(" The compiled and linked file will be myfile.exc")
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ if (exec_args[2]) {
|
||||
_G.shell.execute(`rm ${tempFilename}.gz`)
|
||||
|
||||
_G.shell.execute(`link -${exec_args[1][2]} ${tempFilename}.bin`)
|
||||
_G.shell.execute(`mv ${tempFilename}.out ${filenameWithoutExt}.out`)
|
||||
_G.shell.execute(`mv ${tempFilename}.exc ${filenameWithoutExt}.exc`)
|
||||
_G.shell.execute(`rm ${tempFilename}.bin`)
|
||||
}
|
||||
// with no linking
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
if (exec_args[1] === undefined) {
|
||||
println("Usage: decompile myfile.bin")
|
||||
println("The compiled file will be myfile.bin.js")
|
||||
println("Usage: decompile myfile.exc")
|
||||
println("The compiled file will be myfile.exc.js")
|
||||
return 1
|
||||
}
|
||||
_G.shell.execute(`enc ${exec_args[1]} ${exec_args[1]}.gz`)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// a simple, symmetric obfuscator with infinite-length key
|
||||
|
||||
function seq(s) {
|
||||
let out = ""
|
||||
let cnt = 0
|
||||
|
||||
@@ -11,7 +11,6 @@ let infile = files.open(infilePath)
|
||||
|
||||
if (!infile.exists) throw Error("No such file: " + infilePath)
|
||||
|
||||
let outfile = files.open(infilePath.substringBeforeLast(".") + ".out")
|
||||
let outMode = exec_args[1].toLowerCase()
|
||||
|
||||
let type = {
|
||||
@@ -21,6 +20,13 @@ let type = {
|
||||
"-c": "\x04"
|
||||
}
|
||||
|
||||
let ext = {
|
||||
"-r": ".exc", // executable
|
||||
"-e": ".exc", // executable
|
||||
"-o": ".lib", // library
|
||||
"-c": ".cob" // core object
|
||||
}
|
||||
|
||||
function toI32(num) {
|
||||
const buffer = new ArrayBuffer(4)
|
||||
const view = new DataView(buffer)
|
||||
@@ -40,6 +46,7 @@ let addr = 0
|
||||
if (exec_args[3] !== undefined && exec_args[3].toLowerCase() == "-a" && exec_args[4] !== undefined)
|
||||
addr = parseInt(exec_args[4], 16)
|
||||
|
||||
let outfile = files.open(infilePath.substringBeforeLast(".") + ext[exec_args[3].toLowerCase()])
|
||||
outfile.sappend("\x20\xC0\xCC\x0A")
|
||||
outfile.sappend(type[outMode] || "\x00")
|
||||
outfile.bappend(toI24(addr))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
if (exec_args[1] === undefined) {
|
||||
println("Usage: load myfile.out")
|
||||
println("Usage: load myfile.exc")
|
||||
println(" This will load the binary image onto the Core Memory")
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ music.pread(samples, 65534)
|
||||
|
||||
audio.setPcmMode(0)
|
||||
audio.setMasterVolume(0, 255)
|
||||
audio.putPcmDataByPtr(samples, 65534, 0)
|
||||
audio.putPcmDataByPtr(0, samples, 65534, 0)
|
||||
audio.setLoopPoint(0, 65534)
|
||||
audio.play(0)*/
|
||||
|
||||
@@ -127,7 +127,7 @@ while (sampleSize > 0) {
|
||||
let readLength = (sampleSize < BLOCK_SIZE) ? sampleSize : BLOCK_SIZE
|
||||
readBytes(readLength, decodePtr)
|
||||
|
||||
audio.putPcmDataByPtr(decodePtr, readLength, 0)
|
||||
audio.putPcmDataByPtr(0, decodePtr, readLength, 0)
|
||||
audio.setSampleUploadLength(0, readLength)
|
||||
audio.startSampleUpload(0)
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
let url="http:localhost/testnet/test.txt"
|
||||
/*let url="https:raw.githubusercontent.com/curioustorvald/hopper-mirror/refs/heads/master/aa.hop.per"
|
||||
|
||||
let file = files.open("B:\\"+url)
|
||||
|
||||
if (!file.exists) {
|
||||
printerrln("No such URL: "+url)
|
||||
return 1
|
||||
}
|
||||
}*/
|
||||
|
||||
let text = file.sread()
|
||||
let net = require("A:/tvdos/include/net.mjs")
|
||||
let text = net.fetchText("https://raw.githubusercontent.com/curioustorvald/hopper-mirror/refs/heads/master/aa.hop.per")
|
||||
if (text === null) { printerrln("No such URL"); return 1 }
|
||||
println(text)
|
||||
|
||||
@@ -32,7 +32,7 @@ if (exec_args !== undefined && exec_args[1] !== undefined && exec_args[1].starts
|
||||
return 0
|
||||
}
|
||||
|
||||
const THEVERSION = "1.2.1"
|
||||
const THEVERSION = "1.2.2"
|
||||
|
||||
const PROD = true
|
||||
let INDEX_BASE = 0
|
||||
@@ -4197,7 +4197,7 @@ bF.load = function(args) { // LOAD function
|
||||
if (args[1] === undefined) throw lang.missingOperand
|
||||
var fileOpened = fs.open(args[1], "R")
|
||||
|
||||
|
||||
serial.printerr('load '+args[1])
|
||||
if (replUsrConfirmed || cmdbuf.length == 0) {
|
||||
if (!fileOpened) {
|
||||
fileOpened = fs.open(args[1]+".BAS", "R")
|
||||
@@ -4241,7 +4241,7 @@ bF.yes = function() {
|
||||
}
|
||||
}
|
||||
bF.catalog = function(args) { // CATALOG function
|
||||
if (args[1] === undefined) args[1] = "\\"
|
||||
if (args[1] === undefined) args[1] = BASIC_HOME_PATH
|
||||
var pathOpened = fs.open(args[1], 'R')
|
||||
if (!pathOpened) {
|
||||
throw lang.noSuchFile
|
||||
@@ -4251,6 +4251,57 @@ bF.catalog = function(args) { // CATALOG function
|
||||
com.sendMessage(port, "LIST")
|
||||
println(com.pullMessage(port))
|
||||
}
|
||||
// Load a file by absolute disk path (bypasses BASIC_HOME_PATH).
|
||||
// Used by COMPILE to fetch /tbas/compile.js.
|
||||
bF._slurpAbsolute = function(path) {
|
||||
var port = _BIOS.FIRST_BOOTABLE_PORT
|
||||
com.sendMessage(port[0], "FLUSH")
|
||||
com.sendMessage(port[0], "CLOSE")
|
||||
com.sendMessage(port[0], 'OPENR"' + path + '",' + port[1])
|
||||
if (com.getStatusCode(port[0]) != 0) return undefined
|
||||
com.sendMessage(port[0], "READ")
|
||||
if (com.getStatusCode(port[0]) >= 128) return undefined
|
||||
var s = com.pullMessage(port[0])
|
||||
com.sendMessage(port[0], "FLUSH"); com.sendMessage(port[0], "CLOSE")
|
||||
return s
|
||||
}
|
||||
bF.compile = function(args) { // COMPILE "OUT.JS" -- transpile cmdbuf to JS
|
||||
if (args[1] === undefined) {
|
||||
println("Usage: COMPILE \"out.js\""); return
|
||||
}
|
||||
if (cmdbuf.length === 0) {
|
||||
println("No program loaded"); return
|
||||
}
|
||||
if (bS._compileImpl === undefined) {
|
||||
// Lazy-load compile.js from /tbas/compile.js
|
||||
var src = bF._slurpAbsolute("/tbas/compile.js")
|
||||
if (src === undefined) {
|
||||
println("Cannot load /tbas/compile.js")
|
||||
return
|
||||
}
|
||||
try { eval(src) } catch (e) {
|
||||
println("Failed to load compiler: " + e); return
|
||||
}
|
||||
if (bS._compileImpl === undefined) {
|
||||
println("compile.js loaded but did not define bS._compileImpl"); return
|
||||
}
|
||||
}
|
||||
var outpath = args[1]
|
||||
// Strip surrounding quotes if any
|
||||
if ((outpath.charAt(0) === '"' || outpath.charAt(0) === "'") &&
|
||||
outpath.charAt(outpath.length - 1) === outpath.charAt(0)) {
|
||||
outpath = outpath.substring(1, outpath.length - 1)
|
||||
}
|
||||
// Default to .js extension if missing
|
||||
if (!/\.[A-Za-z0-9]+$/.test(outpath)) outpath += ".js"
|
||||
try {
|
||||
var n = bS._compileImpl(outpath)
|
||||
println("Wrote " + n + " bytes to " + outpath)
|
||||
} catch (e) {
|
||||
serial.printerr(e + "\n" + (e.stack || ""))
|
||||
println("Compile error: " + e)
|
||||
}
|
||||
}
|
||||
Object.freeze(bF)
|
||||
|
||||
if (exec_args !== undefined && exec_args[1] !== undefined) {
|
||||
|
||||
564
assets/disk0/tbas/compile.js
Normal file
564
assets/disk0/tbas/compile.js
Normal file
@@ -0,0 +1,564 @@
|
||||
// Terran BASIC -> JavaScript compiler
|
||||
// Loaded into basic.js's context by `bF.compile`. Re-uses bF._interpretLine
|
||||
// (tokeniser + elaborator + parser + pruner) verbatim and emits a self-
|
||||
// contained JS program that does its work via `let bS = require("tbas")`.
|
||||
//
|
||||
// On load, attaches `bS._compileImpl` to the live bS object.
|
||||
|
||||
;(function() {
|
||||
|
||||
// ---------- helpers ----------------------------------------------------------
|
||||
|
||||
function isValidJsId(s) {
|
||||
return /^[A-Z_][A-Z0-9_]*$/i.test(s)
|
||||
}
|
||||
function varRef(name) {
|
||||
const u = String(name).toUpperCase()
|
||||
return isValidJsId(u) ? `bS.__state.vars.${u}` : `bS.__state.vars[${JSON.stringify(u)}]`
|
||||
}
|
||||
function jsLit(v) { return JSON.stringify(v) }
|
||||
|
||||
// Resolve a literal AST node down to a raw JS value at compile time. Used
|
||||
// for harvesting DATA constants. Only constant-propagatable types are
|
||||
// permitted; otherwise compile-time evaluation fails.
|
||||
function literalValue(node) {
|
||||
if (!node) return undefined
|
||||
switch (node.astType) {
|
||||
case "num": return Number(node.astValue)
|
||||
case "string": return String(node.astValue)
|
||||
case "bool": return Boolean(node.astValue)
|
||||
case "null": return undefined
|
||||
case "lit": return String(node.astValue) // bare identifier in DATA: keep as string
|
||||
default:
|
||||
throw Error("DATA: unsupported literal node type: " + node.astType)
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the maximum varIndex used at the immediate scope of a lambda body,
|
||||
// hence its arity.
|
||||
function lambdaArity(body) {
|
||||
let maxIdx = -1
|
||||
function walk(t, level) {
|
||||
if (!t || !t.astType) return
|
||||
if (t.astType === "defun_args" && t.astValue[0] === level) {
|
||||
if (t.astValue[1] > maxIdx) maxIdx = t.astValue[1]
|
||||
}
|
||||
// descend into nested usrdefun (its body lives in astValue, not leaves)
|
||||
if (t.astType === "usrdefun" && t.astValue && t.astValue.astLeaves !== undefined) {
|
||||
walk(t.astValue, level + 1)
|
||||
}
|
||||
// generic descent
|
||||
if (t.astLeaves) {
|
||||
for (let i = 0; i < t.astLeaves.length; i++) walk(t.astLeaves[i], level)
|
||||
}
|
||||
}
|
||||
walk(body, 0)
|
||||
return maxIdx + 1
|
||||
}
|
||||
|
||||
// ---------- expression lowering ---------------------------------------------
|
||||
|
||||
// `depth` tracks the number of enclosing lambdas during emission. When we
|
||||
// emit a lambda we increment it; defun_args [d, i] becomes _aN_i where
|
||||
// N = depth - 1 - d (the absolute lambda index of the binding scope).
|
||||
function compileExpr(tree, depth) {
|
||||
if (tree === undefined || tree === null) return "undefined"
|
||||
|
||||
// Empty parens / wrapper node: descend into the single child
|
||||
if (tree.astType === "null") {
|
||||
if (tree.astLeaves && tree.astLeaves[0] !== undefined) return compileExpr(tree.astLeaves[0], depth)
|
||||
return "undefined"
|
||||
}
|
||||
if (tree.astValue === undefined && tree.astLeaves && tree.astLeaves.length === 1) {
|
||||
return compileExpr(tree.astLeaves[0], depth)
|
||||
}
|
||||
|
||||
switch (tree.astType) {
|
||||
case "num": return String(Number(tree.astValue))
|
||||
case "string": return jsLit(String(tree.astValue))
|
||||
case "bool": return tree.astValue ? "true" : "false"
|
||||
case "lit": return compileLit(tree)
|
||||
case "defun_args": {
|
||||
const d = tree.astValue[0], i = tree.astValue[1]
|
||||
const scope = depth - 1 - d
|
||||
if (scope < 0) throw Error("defun_args refers to a scope outside the program (depth=" + depth + ", d=" + d + ")")
|
||||
return "_a" + scope + "_" + i
|
||||
}
|
||||
case "usrdefun": return compileLambdaExpr(tree, depth)
|
||||
case "array": return compileArrayRef(tree, depth)
|
||||
case "function": return compileFunctionExpr(tree, depth)
|
||||
case "op": return compileOpExpr(tree, depth)
|
||||
default:
|
||||
throw Error("Cannot compile expression node of type: " + tree.astType + " (value=" + tree.astValue + ")")
|
||||
}
|
||||
}
|
||||
|
||||
function compileLit(tree) {
|
||||
const name = String(tree.astValue).toUpperCase()
|
||||
// Built-in zero-arg / pass-as-value functions: when a builtin name is
|
||||
// referenced as a value (e.g. assigned to a variable for later use as a
|
||||
// higher-order arg), emit a JS function reference. For a plain variable
|
||||
// read, emit the vars table lookup.
|
||||
// Heuristic: if the name matches a builtin we know about, prefer the
|
||||
// function; otherwise, vars lookup.
|
||||
if (RUNTIME_BUILTINS.has(name)) {
|
||||
return "bS." + (isValidJsId(name) ? name : `[${jsLit(name)}]`)
|
||||
}
|
||||
return varRef(name)
|
||||
}
|
||||
|
||||
function compileArrayRef(tree, depth) {
|
||||
// tree.astValue = array variable name; tree.astLeaves = index expressions
|
||||
if (!tree.astLeaves || tree.astLeaves.length === 0) {
|
||||
return varRef(tree.astValue)
|
||||
}
|
||||
const indices = tree.astLeaves.map(l => compileExpr(l, depth))
|
||||
return `bS.__arrGet(${varRef(tree.astValue)}, [${indices.join(",")}])`
|
||||
}
|
||||
|
||||
function compileFunctionExpr(tree, depth) {
|
||||
const name = String(tree.astValue).toUpperCase()
|
||||
|
||||
if (name === "PRINT" || name === "EMIT") {
|
||||
// PRINT/EMIT used as expression — emit as IIFE returning undefined
|
||||
return "(" + compilePrintLike(tree, name, depth) + ", undefined)"
|
||||
}
|
||||
// user function call by name: <varname>(args) — when astType is "function"
|
||||
// and astValue is a string that matches a variable, the parser may have
|
||||
// generated this. Treat it as: invoke the var.
|
||||
if (!RUNTIME_BUILTINS.has(name)) {
|
||||
// Not a known builtin: treat as a user defined function call
|
||||
const args = (tree.astLeaves || []).map(l => compileExpr(l, depth))
|
||||
return `bS.__runFn(${varRef(name)}, [${args.join(",")}])`
|
||||
}
|
||||
|
||||
const args = (tree.astLeaves || []).map(l => compileExpr(l, depth))
|
||||
return `bS.${isValidJsId(name) ? name : `[${jsLit(name)}]`}(${args.join(",")})`
|
||||
}
|
||||
|
||||
const ARITH_OP = {
|
||||
"+": (l,r) => `bS.__add(${l},${r})`,
|
||||
"-": (l,r) => `((${l})-(${r}))`,
|
||||
"*": (l,r) => `((${l})*(${r}))`,
|
||||
"/": (l,r) => `bS.__div(${l},${r})`,
|
||||
"\\": (l,r) => `bS.__intdiv(${l},${r})`,
|
||||
"MOD":(l,r) => `bS.__mod(${l},${r})`,
|
||||
"^": (l,r) => `bS.__pow(${l},${r})`,
|
||||
"==": (l,r) => `((${l})==(${r}))`,
|
||||
"<>": (l,r) => `((${l})!=(${r}))`,
|
||||
"><": (l,r) => `((${l})!=(${r}))`,
|
||||
"<": (l,r) => `((${l})<(${r}))`,
|
||||
">": (l,r) => `((${l})>(${r}))`,
|
||||
"<=": (l,r) => `((${l})<=(${r}))`,
|
||||
"=<": (l,r) => `((${l})<=(${r}))`,
|
||||
">=": (l,r) => `((${l})>=(${r}))`,
|
||||
"=>": (l,r) => `((${l})>=(${r}))`,
|
||||
"AND":(l,r) => `bS.AND(${l},${r})`,
|
||||
"OR": (l,r) => `bS.OR(${l},${r})`,
|
||||
"<<": (l,r) => `((${l})<<(${r}))`,
|
||||
">>": (l,r) => `((${l})>>>(${r}))`,
|
||||
"BAND":(l,r) => `((${l})&(${r}))`,
|
||||
"BOR": (l,r) => `((${l})|(${r}))`,
|
||||
"BXOR":(l,r) => `((${l})^(${r}))`,
|
||||
}
|
||||
const UNARY_OP = {
|
||||
"UNARYMINUS": (a) => `(-(${a}))`,
|
||||
"UNARYPLUS": (a) => `(+(${a}))`,
|
||||
"UNARYLOGICNOT":(a) => `(!(${a}))`,
|
||||
"UNARYBNOT": (a) => `(~(${a}))`,
|
||||
}
|
||||
|
||||
function compileOpExpr(tree, depth) {
|
||||
const op = String(tree.astValue)
|
||||
const leaves = tree.astLeaves || []
|
||||
|
||||
// Unary
|
||||
if (UNARY_OP[op] && (leaves.length === 1 || leaves[1] === undefined)) {
|
||||
return UNARY_OP[op](compileExpr(leaves[0], depth))
|
||||
}
|
||||
|
||||
// Binary arithmetic / comparison / logic
|
||||
if (ARITH_OP[op] && leaves.length === 2) {
|
||||
return ARITH_OP[op](compileExpr(leaves[0], depth), compileExpr(leaves[1], depth))
|
||||
}
|
||||
|
||||
// Generator / range
|
||||
if (op === "TO" && leaves.length === 2) {
|
||||
return `new bS.__ForGen(${compileExpr(leaves[0], depth)}, ${compileExpr(leaves[1], depth)}, 1)`
|
||||
}
|
||||
if (op === "STEP" && leaves.length === 2) {
|
||||
return `bS.STEP(${compileExpr(leaves[0], depth)}, ${compileExpr(leaves[1], depth)})`
|
||||
}
|
||||
|
||||
// List ops
|
||||
if ((op === "!" || op === "~" || op === "#") && leaves.length === 2) {
|
||||
const fn = (op === "!") ? "['!']" : (op === "~") ? "['~']" : "['#']"
|
||||
return `bS${fn}(${compileExpr(leaves[0], depth)}, ${compileExpr(leaves[1], depth)})`
|
||||
}
|
||||
|
||||
// Assignment as expression — returns the assigned value
|
||||
if (op === "=" && leaves.length === 2) {
|
||||
return "(" + compileAssignExpr(tree, depth) + ")"
|
||||
}
|
||||
if (op === "IN" && leaves.length === 2) {
|
||||
// Used inside FOR/FOREACH; compileFor unwraps these. As a value, treat
|
||||
// as { asgnVarName, asgnValue } so a stray IN still works.
|
||||
const name = jsLit(String(leaves[0].astValue).toUpperCase())
|
||||
const rhs = compileExpr(leaves[1], depth)
|
||||
return `({asgnVarName: ${name}, asgnValue: ${rhs}})`
|
||||
}
|
||||
|
||||
// Functional / monad ops
|
||||
if ((op === ">>=" || op === ">>~" || op === "." || op === "$" ||
|
||||
op === "&" || op === "~<" || op === "<*>" || op === "<$>" ||
|
||||
op === "<~>") && leaves.length === 2) {
|
||||
return `bS[${jsLit(op)}](${compileExpr(leaves[0], depth)}, ${compileExpr(leaves[1], depth)})`
|
||||
}
|
||||
if (op === "@" && leaves.length === 1) {
|
||||
// Monad return as prefix
|
||||
return `bS.MRET(${compileExpr(leaves[0], depth)})`
|
||||
}
|
||||
if (op === "~>") {
|
||||
throw Error("Compiler: bare ~> survived prune (should be usrdefun)")
|
||||
}
|
||||
|
||||
throw Error("Cannot compile op '" + op + "' with " + leaves.length + " operand(s)")
|
||||
}
|
||||
|
||||
function compileLambdaExpr(tree, depth) {
|
||||
// tree.astType === "usrdefun"; tree.astValue holds the body AST; if
|
||||
// tree.astLeaves is non-empty, this is an immediate application.
|
||||
const body = tree.astValue
|
||||
if (!body || !body.astType) throw Error("Malformed usrdefun")
|
||||
|
||||
const arity = lambdaArity(body)
|
||||
const newDepth = depth + 1
|
||||
const params = []
|
||||
for (let i = 0; i < arity; i++) params.push("_a" + (newDepth - 1) + "_" + i)
|
||||
const bodyJs = compileExpr(body, newDepth)
|
||||
const arrow = `((${params.join(",")}) => (${bodyJs}))`
|
||||
|
||||
if (tree.astLeaves && tree.astLeaves.length > 0) {
|
||||
const args = tree.astLeaves.map(l => compileExpr(l, depth))
|
||||
return `${arrow}(${args.join(",")})`
|
||||
}
|
||||
return arrow
|
||||
}
|
||||
|
||||
function compileAssignExpr(tree, depth) {
|
||||
// op "=" with leaves[0] as target, leaves[1] as RHS
|
||||
const lhs = tree.astLeaves[0]
|
||||
const rhs = compileExpr(tree.astLeaves[1], depth)
|
||||
|
||||
if (lhs.astType === "lit") {
|
||||
const name = String(lhs.astValue).toUpperCase()
|
||||
return `(${varRef(name)} = ${rhs})`
|
||||
}
|
||||
// The parser emits "function" or "array" for `A(i,j) = ...` — both mean
|
||||
// "store into element of A".
|
||||
if (lhs.astType === "array" || lhs.astType === "function") {
|
||||
const indices = lhs.astLeaves.map(l => compileExpr(l, depth))
|
||||
return `(bS.__arrSet(${varRef(lhs.astValue)}, [${indices.join(",")}], ${rhs}), ${rhs})`
|
||||
}
|
||||
throw Error("Cannot assign to LHS of type " + lhs.astType)
|
||||
}
|
||||
|
||||
// ---------- statement lowering ----------------------------------------------
|
||||
|
||||
function compilePrintLike(tree, fname, depth) {
|
||||
const leaves = (tree.astLeaves || []).slice()
|
||||
const seps = (tree.astSeps || []).slice()
|
||||
|
||||
let suppressNewline = false
|
||||
if (leaves.length > 0 && leaves[leaves.length - 1] !== undefined &&
|
||||
leaves[leaves.length - 1].astType === "null") {
|
||||
suppressNewline = true
|
||||
leaves.pop()
|
||||
}
|
||||
|
||||
const valueExprs = leaves.map(l => compileExpr(l, depth))
|
||||
if (suppressNewline) valueExprs.push("bS.__PRINT_NONL")
|
||||
const sepArr = seps.slice(0, leaves.length - 1)
|
||||
|
||||
return `bS.${fname}([${valueExprs.join(", ")}], ${jsLit(sepArr)})`
|
||||
}
|
||||
|
||||
function setPc(pc) {
|
||||
if (pc[0] === Infinity) return "pc=[Infinity,0];"
|
||||
return "pc=[" + pc[0] + "," + pc[1] + "];"
|
||||
}
|
||||
|
||||
function compileStatement(tree, lnum, stmt, nextPc) {
|
||||
if (!tree) return setPc(nextPc)
|
||||
if (tree.astType === "null" && tree.astLeaves && tree.astLeaves[0]) {
|
||||
return compileStatement(tree.astLeaves[0], lnum, stmt, nextPc)
|
||||
}
|
||||
|
||||
const isFn = (tree.astType === "function" || tree.astType === "op")
|
||||
const fname = isFn ? String(tree.astValue).toUpperCase() : null
|
||||
|
||||
switch (fname) {
|
||||
case "GOTO": {
|
||||
const target = compileGotoTarget(tree.astLeaves[0])
|
||||
return `pc=${target};`
|
||||
}
|
||||
case "GOSUB": {
|
||||
const target = compileGotoTarget(tree.astLeaves[0])
|
||||
return `gosubStack.push([${nextPc[0]},${nextPc[1]}]); pc=${target};`
|
||||
}
|
||||
case "RETURN":
|
||||
return `pc=gosubStack.pop(); if(!pc) throw new Error("RETURN without GOSUB");`
|
||||
case "END":
|
||||
return "pc=[Infinity,0];"
|
||||
case "IF":
|
||||
return compileIf(tree, lnum, stmt, nextPc)
|
||||
case "ON":
|
||||
return compileOn(tree, lnum, stmt, nextPc)
|
||||
case "FOR":
|
||||
case "FOREACH":
|
||||
return compileFor(tree, lnum, stmt, nextPc, fname === "FOREACH")
|
||||
case "NEXT":
|
||||
return compileNext(tree, lnum, stmt, nextPc)
|
||||
case "READ": {
|
||||
const target = tree.astLeaves[0]
|
||||
if (target.astType !== "lit") throw Error("READ: target must be a variable")
|
||||
return `${varRef(target.astValue)}=bS.__readData(); ${setPc(nextPc)}`
|
||||
}
|
||||
case "RESTORE":
|
||||
return `bS.__state.dataCursor=0; ${setPc(nextPc)}`
|
||||
case "DATA":
|
||||
case "LABEL":
|
||||
return setPc(nextPc) // harvested at compile time
|
||||
case "DIM":
|
||||
return compileDim(tree, lnum, stmt, nextPc)
|
||||
case "PRINT":
|
||||
case "EMIT":
|
||||
return `${compilePrintLike(tree, fname, 0)}; ${setPc(nextPc)}`
|
||||
case "OPTIONBASE":
|
||||
return `bS.OPTIONBASE(${compileExpr(tree.astLeaves[0], 0)}); ${setPc(nextPc)}`
|
||||
case "OPTIONDEBUG":
|
||||
return `bS.OPTIONDEBUG(${compileExpr(tree.astLeaves[0], 0)}); ${setPc(nextPc)}`
|
||||
case "OPTIONTRACE":
|
||||
return `bS.OPTIONTRACE(${compileExpr(tree.astLeaves[0], 0)}); ${setPc(nextPc)}`
|
||||
case "INPUT": {
|
||||
// INPUT <var> -> read into var
|
||||
const target = tree.astLeaves[tree.astLeaves.length - 1]
|
||||
if (target.astType !== "lit") throw Error("INPUT: target must be a variable")
|
||||
return `${varRef(target.astValue)}=bS.INPUT(); ${setPc(nextPc)}`
|
||||
}
|
||||
case "=":
|
||||
return `${compileAssignExpr(tree, 0)}; ${setPc(nextPc)}`
|
||||
case "IN":
|
||||
// bare IN as a statement is unusual but harmless
|
||||
return `${compileExpr(tree, 0)}; ${setPc(nextPc)}`
|
||||
case "REM":
|
||||
return setPc(nextPc)
|
||||
}
|
||||
|
||||
// Default: evaluate as an expression for side effect, then advance
|
||||
return `${compileExpr(tree, 0)}; ${setPc(nextPc)}`
|
||||
}
|
||||
|
||||
function compileGotoTarget(leaf) {
|
||||
// Always route through __resolveTarget so non-existent line numbers snap
|
||||
// upward to the next existing line — matching basic.js's main loop,
|
||||
// which increments lnum until it finds a populated cmdbuf entry.
|
||||
if (leaf.astType === "num") return `bS.__resolveTarget(${Number(leaf.astValue)})`
|
||||
if (leaf.astType === "string") return `bS.__resolveTarget(${jsLit(leaf.astValue)})`
|
||||
if (leaf.astType === "lit") {
|
||||
const name = String(leaf.astValue)
|
||||
return `bS.__resolveTarget(bS.__state.gotoLabels[${jsLit(name)}]!==undefined ? ${jsLit(name)} : ${varRef(name)})`
|
||||
}
|
||||
return `bS.__resolveTarget(${compileExpr(leaf, 0)})`
|
||||
}
|
||||
|
||||
function compileIf(tree, lnum, stmt, nextPc) {
|
||||
const test = compileExpr(tree.astLeaves[0], 0)
|
||||
const thenStmt = compileStatement(tree.astLeaves[1], lnum, stmt, nextPc)
|
||||
const elseStmt = (tree.astLeaves[2])
|
||||
? compileStatement(tree.astLeaves[2], lnum, stmt, nextPc)
|
||||
: setPc(nextPc)
|
||||
return `if(bS.__test(${test})){${thenStmt}}else{${elseStmt}}`
|
||||
}
|
||||
|
||||
function compileOn(tree, lnum, stmt, nextPc) {
|
||||
// children: testExpr, jumpFnLit, target0, target1, ...
|
||||
const testExpr = compileExpr(tree.astLeaves[0], 0)
|
||||
const jmpFn = String(tree.astLeaves[1].astValue).toUpperCase()
|
||||
const targets = tree.astLeaves.slice(2)
|
||||
|
||||
const cases = targets.map((t, i) => {
|
||||
const tgt = compileGotoTarget(t)
|
||||
if (jmpFn === "GOSUB") {
|
||||
return `case ${i}: gosubStack.push([${nextPc[0]},${nextPc[1]}]); pc=${tgt}; break;`
|
||||
}
|
||||
return `case ${i}: pc=${tgt}; break;`
|
||||
})
|
||||
return `{const _o=(${testExpr})-bS.__state.indexBase; switch(_o){${cases.join(" ")} default: ${setPc(nextPc)}}}`
|
||||
}
|
||||
|
||||
function compileFor(tree, lnum, stmt, nextPc, isForEach) {
|
||||
const child = tree.astLeaves[0]
|
||||
if (child.astType !== "op" || (child.astValue !== "=" && child.astValue !== "IN")) {
|
||||
throw Error("FOR/FOREACH: expected = or IN, got " + child.astType + ":" + child.astValue)
|
||||
}
|
||||
const varname = String(child.astLeaves[0].astValue).toUpperCase()
|
||||
let iter = compileExpr(child.astLeaves[1], 0)
|
||||
if (isForEach) {
|
||||
// ensure we coerce generators into arrays for FOREACH semantics
|
||||
iter = `(function(_x){return bS.__isGenerator(_x)?bS.__genToArray(_x):_x})(${iter})`
|
||||
}
|
||||
// Pass nextPc — the PC of the loop body's first statement — so NEXT can
|
||||
// jump straight back without relying on fall-through.
|
||||
return `bS.__forSetup(${jsLit(varname)}, ${iter}, ${nextPc[0]}, ${nextPc[1]}); ${setPc(nextPc)}`
|
||||
}
|
||||
|
||||
function compileNext(tree, lnum, stmt, nextPc) {
|
||||
let argExpr = "undefined"
|
||||
const leaves = tree.astLeaves || []
|
||||
if (leaves.length === 1 && leaves[0] && leaves[0].astType === "lit") {
|
||||
argExpr = jsLit(String(leaves[0].astValue).toUpperCase())
|
||||
}
|
||||
return `{const _n=bS.__forNext(${argExpr}); if(_n){pc=_n;}else{${setPc(nextPc)}}}`
|
||||
}
|
||||
|
||||
function compileDim(tree, lnum, stmt, nextPc) {
|
||||
// tree.astLeaves contains array constructor calls: each leaf is either
|
||||
// an `array` node OR a `function` node (the parser doesn't distinguish
|
||||
// `A(5)` from a function call until runtime). astValue is the variable
|
||||
// name and astLeaves are the dimension expressions.
|
||||
const stmts = []
|
||||
for (let i = 0; i < tree.astLeaves.length; i++) {
|
||||
const leaf = tree.astLeaves[i]
|
||||
if (leaf.astType !== "array" && leaf.astType !== "function") {
|
||||
throw Error("DIM: expected array decl, got " + leaf.astType)
|
||||
}
|
||||
const name = String(leaf.astValue).toUpperCase()
|
||||
const dims = leaf.astLeaves.map(l => compileExpr(l, 0))
|
||||
stmts.push(`${varRef(name)}=bS.__dim([${dims.join(",")}]);`)
|
||||
}
|
||||
return stmts.join(" ") + " " + setPc(nextPc)
|
||||
}
|
||||
|
||||
// ---------- top-level entry --------------------------------------------------
|
||||
|
||||
// Set of builtin names exposed by tbas.mjs. Used to decide whether a `lit`
|
||||
// in expression position is a variable or a function reference.
|
||||
const RUNTIME_BUILTINS = new Set([
|
||||
"PRINT","EMIT","INPUT","CIN",
|
||||
"ABS","SGN","INT","FLOOR","CEIL","FIX","ROUND","SQR","CBR",
|
||||
"SIN","COS","TAN","ASN","ACO","ATN","SINH","COSH","TANH",
|
||||
"EXP","LOG","MIN","MAX","RND",
|
||||
"SPC","LEFT","RIGHT","MID","CHR",
|
||||
"LEN","HEAD","TAIL","INIT","LAST","MAP","FOLD","FILTER","ARRAY",
|
||||
"CLS","CLPX","PLOT","GOTOYX","TEXTFORE","TEXTBACK",
|
||||
"POKE","PEEK","GETKEYSDOWN","CPUT","CGET","CSTA",
|
||||
"TYPEOF","OPTIONBASE","OPTIONDEBUG","OPTIONTRACE",
|
||||
"MRET","MLIST","MJOIN",
|
||||
"AND","OR","NOT",
|
||||
"DO","CLEAR","END","TO","STEP",
|
||||
"FOR","FOREACH","NEXT","IF","ON","GOTO","GOSUB","RETURN",
|
||||
"DIM","DATA","READ","RESTORE","LABEL","REM",
|
||||
"TEST",
|
||||
])
|
||||
|
||||
bS._compileImpl = function(outpath) {
|
||||
if (typeof cmdbuf === "undefined") throw Error("compile.js: cmdbuf not available")
|
||||
if (typeof bF === "undefined") throw Error("compile.js: bF not available")
|
||||
if (typeof bF._interpretLine !== "function") throw Error("compile.js: bF._interpretLine not available")
|
||||
|
||||
// Reset parser-side state so we don't pollute the live interpreter
|
||||
if (typeof lambdaBoundVars !== "undefined") lambdaBoundVars.length = 0
|
||||
const savedPrescan = (typeof prescan !== "undefined") ? prescan : false
|
||||
if (typeof prescan !== "undefined") prescan = true // suppress execution of LABEL/DATA prescan side-effects
|
||||
|
||||
// ---- pass 1: parse every line ----
|
||||
const programTrees = [] // [lnum] -> array of statements
|
||||
for (let lnum = 0; lnum < cmdbuf.length; lnum++) {
|
||||
const linestr = cmdbuf[lnum]
|
||||
if (linestr === undefined) continue
|
||||
const trees = bF._interpretLine(lnum, String(linestr).trim())
|
||||
if (trees !== undefined) programTrees[lnum] = trees
|
||||
}
|
||||
if (typeof prescan !== "undefined") prescan = savedPrescan
|
||||
|
||||
// ---- pass 2: ordered list of populated lnums and successor table ----
|
||||
const linenums = []
|
||||
for (let lnum = 0; lnum < programTrees.length; lnum++) {
|
||||
if (programTrees[lnum] !== undefined) linenums.push(lnum)
|
||||
}
|
||||
|
||||
function nextPcOf(idx, stmtIdx) {
|
||||
const lnum = linenums[idx]
|
||||
const stmts = programTrees[lnum]
|
||||
if (stmtIdx + 1 < stmts.length) return [lnum, stmtIdx + 1]
|
||||
if (idx + 1 < linenums.length) return [linenums[idx + 1], 0]
|
||||
return [Infinity, 0]
|
||||
}
|
||||
|
||||
// ---- pass 3: harvest DATA constants and LABEL definitions ----
|
||||
const dataConsts = []
|
||||
const labelMap = {}
|
||||
for (let i = 0; i < linenums.length; i++) {
|
||||
const lnum = linenums[i]
|
||||
const stmts = programTrees[lnum]
|
||||
for (let s = 0; s < stmts.length; s++) {
|
||||
const t = stmts[s]
|
||||
if (!t) continue
|
||||
if (t.astValue === "DATA") {
|
||||
for (let k = 0; k < t.astLeaves.length; k++) {
|
||||
dataConsts.push(literalValue(t.astLeaves[k]))
|
||||
}
|
||||
} else if (t.astValue === "LABEL") {
|
||||
const lblNode = t.astLeaves[0]
|
||||
if (!lblNode) throw Error("LABEL with no name on line " + lnum)
|
||||
const lblName = String(lblNode.astValue)
|
||||
labelMap[lblName] = [lnum, s]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- pass 4: emit case bodies ----
|
||||
const cases = []
|
||||
for (let i = 0; i < linenums.length; i++) {
|
||||
const lnum = linenums[i]
|
||||
const stmts = programTrees[lnum]
|
||||
for (let s = 0; s < stmts.length; s++) {
|
||||
const next = nextPcOf(i, s)
|
||||
const body = compileStatement(stmts[s], lnum, s, next)
|
||||
cases.push(` case ${lnum}*32+${s}: { ${body} break; }`)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- pass 5: assemble final output ----
|
||||
const firstPc = (linenums.length > 0) ? `[${linenums[0]},0]` : `[Infinity,0]`
|
||||
const labelMapJs = "{" + Object.keys(labelMap).map(k =>
|
||||
`${jsLit(k)}: [${labelMap[k][0]}, ${labelMap[k][1]}]`
|
||||
).join(", ") + "}"
|
||||
|
||||
const out =
|
||||
`// Compiled by Terran BASIC -> JS compiler (assets/disk0/tbas/compile.js)
|
||||
// Source line count: ${linenums.length}
|
||||
let bS = require("tbas")
|
||||
bS.__reset()
|
||||
bS.__data(${jsLit(dataConsts)})
|
||||
bS.__labels(${labelMapJs})
|
||||
bS.__setLines(${jsLit(linenums)})
|
||||
let pc = ${firstPc}
|
||||
const gosubStack = []
|
||||
while (pc[0] !== Infinity) {
|
||||
switch (pc[0]*32 + pc[1]) {
|
||||
${cases.join("\n")}
|
||||
default: pc = [Infinity, 0]; break;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// ---- write to disk via basic.js's fs (writes under BASIC_HOME_PATH) ----
|
||||
const opened = fs.open(outpath, "W")
|
||||
if (!opened) throw Error("Cannot open " + outpath + " for writing")
|
||||
fs.write(out)
|
||||
return out.length
|
||||
}
|
||||
|
||||
})();
|
||||
21
assets/disk0/tracker_play.js
Normal file
21
assets/disk0/tracker_play.js
Normal file
@@ -0,0 +1,21 @@
|
||||
const taud = require("taud")
|
||||
|
||||
const fullFilePath = _G.shell.resolvePathInput(exec_args[1])
|
||||
if (fullFilePath === undefined) {
|
||||
println(`Usage: ${exec_args[0]} path_to.taud`)
|
||||
return 1
|
||||
}
|
||||
|
||||
const PLAYHEAD = 0
|
||||
|
||||
println("Playing "+fullFilePath.full)
|
||||
|
||||
audio.resetParams(PLAYHEAD)
|
||||
audio.purgeQueue(PLAYHEAD)
|
||||
audio.stop(PLAYHEAD)
|
||||
|
||||
taud.uploadTaudFile(fullFilePath.full, 0, PLAYHEAD)
|
||||
audio.setMasterVolume(PLAYHEAD, 255)
|
||||
audio.setMasterPan(PLAYHEAD, 128)
|
||||
audio.setCuePosition(PLAYHEAD, 0)
|
||||
audio.play(PLAYHEAD)
|
||||
186
assets/disk0/tracker_test.js
Normal file
186
assets/disk0/tracker_test.js
Normal file
@@ -0,0 +1,186 @@
|
||||
// Tracker Mode — Bach's Prelude in C Major (BWV 846)
|
||||
// Run from the TVDOS shell: js tracker_test.js
|
||||
// Uploads ~92 patterns on startup (takes a moment).
|
||||
|
||||
// -- Note table (12-TET, 4096-TET encoding) ------------------------------------
|
||||
// C3 = 0x4000; each semitone = 4096/12 ≈ 341.33 steps; each octave = 4096 steps.
|
||||
// Sharp suffix: s (e.g. Cs3); flat aliases also provided (e.g. Db3 = Cs3).
|
||||
// Special values: Note.OFF = key-off, Note.CUT = note cut, Note.NOP = no-op.
|
||||
var Note = (function() {
|
||||
var SEMITONE = 4096 / 12;
|
||||
var C3 = 0x4000;
|
||||
function n(oct, semi) { return Math.round(C3 + (oct - 3) * 4096 + semi * SEMITONE) & 0xFFFF; }
|
||||
var t = {};
|
||||
var names = ['C','Cs','D','Ds','E','F','Fs','G','Gs','A','As','B'];
|
||||
var flats = ['C','Db','D','Eb','E','F','Gb','G','Ab','A','Bb','B'];
|
||||
for (var oct = 0; oct <= 9; oct++) {
|
||||
for (var s = 0; s < 12; s++) {
|
||||
t[names[s] + oct] = n(oct, s);
|
||||
if (flats[s] !== names[s]) t[flats[s] + oct] = n(oct, s);
|
||||
}
|
||||
}
|
||||
t.NOP = 0x0000; // no-op (empty row)
|
||||
t.OFF = 0x0001; // key-off
|
||||
t.CUT = 0x0002; // note cut (immediate)
|
||||
return t;
|
||||
}());
|
||||
|
||||
var PLAYHEAD = 0;
|
||||
|
||||
// -- 1. Sample: triangle wave (256 samples @ C3) --------------------------------
|
||||
var SAMPLE_LEN = 256;
|
||||
var sampleBytes = new Array(SAMPLE_LEN);
|
||||
for (var i = 0; i < SAMPLE_LEN; i++) {
|
||||
var phase = (i / SAMPLE_LEN) * 2.0;
|
||||
var val_ = phase < 1.0 ? phase : 2.0 - phase;
|
||||
sampleBytes[i] = Math.round(val_ * 254) & 0xFF;
|
||||
}
|
||||
var memBase = audio.getMemAddr();
|
||||
for (var i = 0; i < SAMPLE_LEN; i++) {
|
||||
sys.poke(memBase - i, sampleBytes[i]);
|
||||
}
|
||||
|
||||
// -- 2. Instrument 0 -----------------------------------------------------------
|
||||
var instBytes = new Array(64).fill(0);
|
||||
instBytes[2] = 0; instBytes[3] = 1; // sampleLength = 256
|
||||
instBytes[4] = 0x00; instBytes[5] = 0x7D; // samplingRate = 32000
|
||||
instBytes[10] = 0x00; instBytes[11] = 0x01; // sampleLoopEnd = 256 (whole sample)
|
||||
instBytes[12] = 1; // loopMode = 1 (forward)
|
||||
instBytes[16] = 255; instBytes[17] = 0; // envelope: vol=255, hold
|
||||
audio.uploadInstrument(1, instBytes);
|
||||
|
||||
// -- 3. Piano-roll builder -----------------------------------------------------
|
||||
// Source convention: C1=0, C2=12, C3=24, C4=36 (i.e. C3=24, octave every 12).
|
||||
function midiToTsvm(n) {
|
||||
var oct = Math.floor(n / 12) + 1;
|
||||
return Math.round(0x3000 + oct * 4096 + (n % 12) * (4096 / 12)) & 0xFFFF;
|
||||
}
|
||||
|
||||
var noteMap = {}; // absRow → TSVM note value
|
||||
var rowCursor = 0;
|
||||
|
||||
function seq(notes, lens) {
|
||||
for (var i = 0; i < notes.length; i++) {
|
||||
noteMap[rowCursor] = midiToTsvm(notes[i]);
|
||||
rowCursor += lens[i];
|
||||
}
|
||||
}
|
||||
|
||||
var TD = 3; // rows per note step (= source TICK_DIVISOR)
|
||||
|
||||
function prel(n1, n2, n3, n4, n5) {
|
||||
seq([n1, n2, n3, n4, n5, n3, n4, n5, n1, n2, n3, n4, n5, n3, n4, n5],
|
||||
[TD+2, TD, TD, TD-1, TD, TD, TD, TD, TD, TD, TD, TD-1, TD, TD, TD, TD]);
|
||||
}
|
||||
function end1(n1,n2,n3,n4,n5,n6,n7,n8,n9) {
|
||||
seq([n1, n2, n3, n4, n5, n6, n5, n4, n5, n7, n8, n7, n8, n9, n8, n9],
|
||||
[TD+2, TD, TD, TD-1, TD, TD, TD, TD, TD, TD, TD, TD-1, TD, TD, TD, TD]);
|
||||
}
|
||||
function end2(n1,n2,n3,n4,n5,n6,n7,n8,n9) {
|
||||
seq([n1, n2, n3, n4, n5, n6, n5, n4, n5, n4, n3, n4, n7, n8, n9, n7],
|
||||
[TD+2, TD+1, TD+1, TD+1, TD+1, TD+2, TD+2, TD+2,
|
||||
TD+3, TD+3, TD+4, TD+4, TD+6, TD+8, TD+12, TD+24]);
|
||||
}
|
||||
function end3(ns) {
|
||||
for (var i = 0; i < ns.length; i++) {
|
||||
noteMap[rowCursor] = midiToTsvm(ns[i]);
|
||||
rowCursor += 1;
|
||||
}
|
||||
for (var i = 0; i < TD*2; i++) {
|
||||
noteMap[rowCursor] = Note.NOP
|
||||
rowCursor += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// -- 4. Build the piece --------------------------------------------------------
|
||||
rowCursor = 16 * TD; // 160-row intro silence
|
||||
|
||||
prel(24,28,31,36,40);
|
||||
prel(24,26,33,38,41);
|
||||
prel(23,26,31,38,41);
|
||||
prel(24,28,31,36,40);
|
||||
prel(24,28,33,40,45);
|
||||
prel(24,26,30,33,38);
|
||||
prel(23,26,31,38,43);
|
||||
prel(23,24,28,31,36);
|
||||
prel(21,24,28,31,36);
|
||||
prel(14,21,26,30,36);
|
||||
prel(19,23,26,31,35);
|
||||
prel(19,22,28,31,37);
|
||||
prel(17,21,26,33,38);
|
||||
prel(17,20,26,29,35);
|
||||
prel(16,19,24,31,36);
|
||||
prel(16,17,21,24,29);
|
||||
prel(14,17,21,24,29);
|
||||
prel( 7,14,19,23,29);
|
||||
prel(12,16,19,24,28);
|
||||
prel(12,19,22,24,28);
|
||||
prel( 5,17,21,24,28);
|
||||
prel( 6,12,21,24,27);
|
||||
prel( 8,17,23,24,26);
|
||||
prel( 7,17,19,23,26);
|
||||
prel( 7,16,19,24,28);
|
||||
prel( 7,14,19,24,29);
|
||||
prel( 7,14,19,23,29);
|
||||
prel( 7,15,21,24,30);
|
||||
prel( 7,16,19,24,31);
|
||||
prel( 7,14,19,24,29);
|
||||
prel( 7,14,19,23,29);
|
||||
prel( 0,12,19,22,28);
|
||||
end1( 0,12,17,21,24,29,21,17,14);
|
||||
end2( 0,11,31,35,38,41,26,29,28);
|
||||
end3([0,12,28,31,36]);
|
||||
|
||||
noteMap[rowCursor] = Note.OFF; // key-off at start of final silence
|
||||
rowCursor += 16 * TD - 5; // 155 more rows of silence
|
||||
|
||||
var totalRows = rowCursor; // 5836
|
||||
var NUM_ROWS = 64;
|
||||
var numPatterns = Math.ceil(totalRows / NUM_ROWS); // 92
|
||||
|
||||
// -- 5. Build and upload patterns ----------------------------------------------
|
||||
for (var p = 0; p < numPatterns; p++) {
|
||||
var patBytes = new Array(512).fill(0);
|
||||
for (var r = 0; r < NUM_ROWS; r++) {
|
||||
var absRow = p * NUM_ROWS + r;
|
||||
var noteVal = (noteMap[absRow] !== undefined) ? noteMap[absRow] : Note.NOP;
|
||||
var isOn = (noteVal !== Note.NOP && noteVal !== Note.OFF && noteVal !== Note.CUT);
|
||||
var off = r * 8;
|
||||
patBytes[off] = noteVal & 0xFF;
|
||||
patBytes[off + 1] = (noteVal >> 8) & 0xFF;
|
||||
patBytes[off + 2] = 1; // instrument 1
|
||||
patBytes[off + 3] = 63; // volume
|
||||
patBytes[off + 4] = 31; // pan (centre)
|
||||
}
|
||||
audio.uploadPattern(p, patBytes);
|
||||
}
|
||||
|
||||
// -- 6. Cue sheet: one entry per pattern, last halts -------------------------
|
||||
// Cue format: 32 bytes, 20 voices with 12-bit pattern numbers packed as:
|
||||
// bytes 0-9: low nybbles (byte i = voice i*2 in hi-nybble, voice i*2+1 in lo-nybble)
|
||||
// bytes 10-19: mid nybbles (same packing)
|
||||
// bytes 20-29: high nybbles (same packing)
|
||||
// byte 30: instruction (0=NOP, 1=Halt)
|
||||
// Voice 0 plays pattern c; voices 1-19 are disabled (0xFFF).
|
||||
for (var c = 0; c < numPatterns; c++) {
|
||||
var cueBytes = new Array(32).fill(0xFF);
|
||||
// voice 0 = c (12-bit), voice 1 = 0xFFF → byte0=(c&0xF)<<4|0xF
|
||||
cueBytes[0] = ((c & 0xF) << 4) | 0xF; // lo nybbles v0,v1
|
||||
cueBytes[10] = (((c >> 4) & 0xF) << 4) | 0xF; // mid nybbles v0,v1
|
||||
cueBytes[20] = (((c >> 8) & 0xF) << 4) | 0xF; // hi nybbles v0,v1
|
||||
cueBytes[30] = (c === numPatterns - 1) ? 0x01 : 0;
|
||||
audio.uploadCue(c, cueBytes);
|
||||
}
|
||||
|
||||
// -- 7. Playback ---------------------------------------------------------------
|
||||
// BPM=500, tickRate=1: 1 row = 5 ms; 10 rows/step × 16 steps/bar ≈ 75 bars/min.
|
||||
audio.setTrackerMode(PLAYHEAD);
|
||||
audio.setBPM(PLAYHEAD, 250);
|
||||
audio.setTickRate(PLAYHEAD, 6);
|
||||
audio.setMasterVolume(PLAYHEAD, 255);
|
||||
audio.setMasterPan(PLAYHEAD, 128);
|
||||
audio.setCuePosition(PLAYHEAD, 0);
|
||||
audio.play(PLAYHEAD);
|
||||
|
||||
println("Bach's Prelude in C Major -- " + numPatterns + " patterns loaded.");
|
||||
println("Stop: audio.stop(" + PLAYHEAD + ")");
|
||||
@@ -55,10 +55,12 @@ class PmemFSfile {
|
||||
// string representation (preferable)
|
||||
if (typeof bytes === 'string' || bytes instanceof String) {
|
||||
this.data = bytes
|
||||
this.length = bytes.length
|
||||
}
|
||||
// Javascript array OR JVM byte[]
|
||||
else if (Array.isArray(bytes) || bytes.toString().startsWith("[B")) {
|
||||
this.bdata = bytes[i]
|
||||
this.bdata = bytes
|
||||
this.length = bytes.length
|
||||
}
|
||||
else {
|
||||
throw Error("Invalid type for directory")
|
||||
@@ -76,10 +78,10 @@ class PmemFSfile {
|
||||
|
||||
dataAsBytes() {
|
||||
if (this.bdata !== undefined) return this.bdata
|
||||
this.bdata = new Int8Array(this.data.length)
|
||||
this.bdata = new Uint8Array(this.data.length)
|
||||
for (let i = 0; i < this.data.length; i++) {
|
||||
let p = this.data.charCodeAt(i)
|
||||
this.bdata[i] = (p > 127) ? p - 255 : p
|
||||
this.bdata[i] = p
|
||||
}
|
||||
return this.bdata
|
||||
}
|
||||
@@ -147,10 +149,12 @@ _TVDOS.variables = {
|
||||
LANG: "EN",
|
||||
KEYBOARD: "us_qwerty",
|
||||
PATH: "\\tvdos\\bin;\\home",
|
||||
PATHEXT: ".com;.bat;.app;.js",
|
||||
INCLPATH: "\\tvdos\\include;\\home",
|
||||
PATHEXT: ".com;.bat;.app;.js;.alias",
|
||||
HELPPATH: "\\tvdos\\help",
|
||||
OS_NAME: "TSVM Disk Operating System",
|
||||
OS_VERSION: _TVDOS.VERSION
|
||||
OS_VERSION: _TVDOS.VERSION,
|
||||
USERCONFIGPATH: "\\home\\config",
|
||||
};
|
||||
Object.freeze(_TVDOS);
|
||||
|
||||
@@ -162,16 +166,16 @@ class TVDOSFileDescriptor {
|
||||
|
||||
constructor(path0, driverID) {
|
||||
if (path0.startsWith("$")) {
|
||||
let path1 = path0.substring(3)
|
||||
let slashPos = path1.indexOf("/")
|
||||
let path1 = path0.replaceAll("/", "\\").substring(3)
|
||||
let slashPos = path1.indexOf("\\")
|
||||
let devName = path1.substring(0, (slashPos < 0) ? path1.length : slashPos)
|
||||
|
||||
if (!files.reservedNames.includes(devName)) {
|
||||
throw Error(`${devName} is not a valid device file`)
|
||||
}
|
||||
|
||||
this._driveLetter = undefined
|
||||
this._path = path0
|
||||
this._driveLetter = '$'
|
||||
this._path = '\\' + path1
|
||||
this._driverID = `DEV${devName}`
|
||||
this._driver = _TVDOS.DRV.FS[`DEV${devName}`] // can't just put `driverID` here
|
||||
}
|
||||
@@ -225,8 +229,9 @@ class TVDOSFileDescriptor {
|
||||
}
|
||||
|
||||
/** reads the file bytewise and puts it to the specified memory address
|
||||
* @param count optional -- how many bytes to read
|
||||
* @param offset optional -- how many bytes to skip initially
|
||||
* @param ptr -- where the bytes should be dumped
|
||||
* @param count -- how many bytes to read
|
||||
* @param offset -- how many bytes to skip initially from the file
|
||||
*/
|
||||
pread(ptr, count, offset) {
|
||||
this.driver.pread(this, ptr, count, offset)
|
||||
@@ -241,7 +246,9 @@ class TVDOSFileDescriptor {
|
||||
}
|
||||
|
||||
/** writes the bytes stored in the memory[ptr .. ptr+count-1] to file[offset .. offset+count-1]
|
||||
* - @param offset is optional
|
||||
* @param ptr -- where the bytes are
|
||||
* @param count -- how many bytes to write
|
||||
* @param offset -- position in the file
|
||||
*/
|
||||
pwrite(ptr, count, offset) {
|
||||
this.driver.pwrite(this, ptr, count, offset)
|
||||
@@ -420,11 +427,11 @@ _TVDOS.DRV.FS.SERIAL.sread = (fd) => {
|
||||
}
|
||||
_TVDOS.DRV.FS.SERIAL.bread = (fd) => {
|
||||
let str = _TVDOS.DRV.FS.SERIAL.sread(fd)
|
||||
let bytes = new Int8Array(str.length)
|
||||
let bytes = []//new Int8Array(str.length)
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
// let p = str.charCodeAt(i)
|
||||
// bytes[i] = (p > 127) ? p - 255 : p
|
||||
bytes[i] = str.charCodeAt(i)
|
||||
bytes.push(str.charCodeAt(i))
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
@@ -870,11 +877,21 @@ Object.freeze(_TVDOS.DRV.FS.DEVPT)
|
||||
_TVDOS.DRV.FS.DEVFBIPF = {}
|
||||
|
||||
_TVDOS.DRV.FS.DEVFBIPF.pwrite = (fd, infilePtr, count, _2) => {
|
||||
let decodefun = ([graphics.decodeIpf1, graphics.decodeIpf2])[sys.peek(infilePtr + 13)]
|
||||
let flags = sys.peek(infilePtr+12)
|
||||
let ipfType = sys.peek(infilePtr+13)
|
||||
let isProgressive = (flags & 0x80) != 0
|
||||
let hasAlpha = (flags & 0x01) != 0
|
||||
|
||||
// Select decode function based on type and progressive flag
|
||||
let decodefun
|
||||
if (isProgressive) {
|
||||
decodefun = ([graphics.decodeIpf1Progressive, graphics.decodeIpf2Progressive])[ipfType]
|
||||
} else {
|
||||
decodefun = ([graphics.decodeIpf1, graphics.decodeIpf2])[ipfType]
|
||||
}
|
||||
|
||||
let width = sys.peek(infilePtr+8) | (sys.peek(infilePtr+9) << 8)
|
||||
let height = sys.peek(infilePtr+10) | (sys.peek(infilePtr+11) << 8)
|
||||
let hasAlpha = (sys.peek(infilePtr+12) != 0)
|
||||
let ipfType = sys.peek(infilePtr+13)
|
||||
let imgLen = sys.peek(infilePtr+24) | (sys.peek(infilePtr+25) << 8) | (sys.peek(infilePtr+26) << 16) | (sys.peek(infilePtr+27) << 24)
|
||||
|
||||
let ipfbuf = sys.malloc(imgLen)
|
||||
@@ -924,8 +941,9 @@ _TVDOS.DRV.FS.DEVTMP.bread = (fd) => {
|
||||
_TVDOS.DRV.FS.DEVTMP.pread = (fd, ptr, count, offset) => {
|
||||
if (_TVDOS.TMPFS[fd.path] === undefined) throw Error(`No such file: ${fd.fullPath}`)
|
||||
let str = _TVDOS.TMPFS[fd.path].dataAsString()
|
||||
for (let i = 0; i < count - (offset || 0); i++) {
|
||||
sys.poke(ptr + i, String.charCodeAt(i + (offset || 0)))
|
||||
let off = offset || 0
|
||||
for (let i = 0; i < count; i++) {
|
||||
sys.poke(ptr + i, str.charCodeAt(off + i))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -973,6 +991,7 @@ _TVDOS.DRV.FS.DEVTMP.remove = (fd) => {
|
||||
return true
|
||||
}
|
||||
_TVDOS.DRV.FS.DEVTMP.exists = (fd) => (_TVDOS.TMPFS[fd.path] !== undefined)
|
||||
_TVDOS.DRV.FS.DEVTMP.getFileLen = (fd) => (_TVDOS.TMPFS[fd.path].length)
|
||||
|
||||
Object.freeze(_TVDOS.DRV.FS.DEVTMP)
|
||||
|
||||
@@ -1014,136 +1033,6 @@ _TVDOS.DRV.FS.NET.exists = (fd) => {
|
||||
return (0 == com.getStatusCode(port[0]))
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
|
||||
// Legacy Serial filesystem, !!pending for removal!!
|
||||
|
||||
|
||||
/*const filesystem = {};
|
||||
|
||||
filesystem._toPorts = (driveLetter) => {
|
||||
if (driveLetter.toUpperCase === undefined) {
|
||||
throw Error("'"+driveLetter+"' (type: "+typeof driveLetter+") is not a valid drive letter");
|
||||
}
|
||||
var port = _TVDOS.DRIVES[driveLetter.toUpperCase()];
|
||||
if (port === undefined) {
|
||||
throw Error("Drive letter '" + driveLetter.toUpperCase() + "' does not exist");
|
||||
}
|
||||
return port
|
||||
};
|
||||
filesystem._close = (portNo) => {
|
||||
com.sendMessage(portNo, "CLOSE")
|
||||
}
|
||||
filesystem._flush = (portNo) => {
|
||||
com.sendMessage(portNo, "FLUSH")
|
||||
}
|
||||
|
||||
// @return disk status code (0 for successful operation)
|
||||
// throws if:
|
||||
// - java.lang.NullPointerException if path is null
|
||||
// - Error if operation mode is not "R", "W" nor "A"
|
||||
filesystem.open = (driveLetter, path, operationMode) => {
|
||||
var port = filesystem._toPorts(driveLetter);
|
||||
|
||||
filesystem._flush(port[0]); filesystem._close(port[0]);
|
||||
|
||||
var mode = operationMode.toUpperCase();
|
||||
if (mode != "R" && mode != "W" && mode != "A") {
|
||||
throw Error("Unknown file opening mode: " + mode);
|
||||
}
|
||||
|
||||
com.sendMessage(port[0], "OPEN"+mode+'"'+path+'",'+port[1]);
|
||||
return com.getStatusCode(port[0]);
|
||||
};
|
||||
filesystem.getFileLen = (driveLetter) => {
|
||||
var port = filesystem._toPorts(driveLetter);
|
||||
com.sendMessage(port[0], "GETLEN");
|
||||
var response = com.getStatusCode(port[0]);
|
||||
if (135 == response) {
|
||||
throw Error("File not opened");
|
||||
}
|
||||
if (response < 0 || response >= 128) {
|
||||
throw Error("Reading a file failed with "+response);
|
||||
}
|
||||
return Number(com.pullMessage(port[0]));
|
||||
};
|
||||
// @return the entire contents of the file in String
|
||||
filesystem.readAll = (driveLetter) => {
|
||||
var port = filesystem._toPorts(driveLetter);
|
||||
com.sendMessage(port[0], "READ");
|
||||
var response = com.getStatusCode(port[0]);
|
||||
if (135 == response) {
|
||||
throw Error("File not opened");
|
||||
}
|
||||
if (response < 0 || response >= 128) {
|
||||
throw Error("Reading a file failed with "+response);
|
||||
}
|
||||
return com.pullMessage(port[0]);
|
||||
};
|
||||
filesystem.readAllBytes = (driveLetter) => {
|
||||
var str = filesystem.readAll(driveLetter);
|
||||
var bytes = new Uint8Array(str.length);
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
bytes[i] = str.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
filesystem.write = (driveLetter, string) => {
|
||||
var port = filesystem._toPorts(driveLetter);
|
||||
com.sendMessage(port[0], "WRITE"+string.length);
|
||||
var response = com.getStatusCode(port[0]);
|
||||
if (135 == response) {
|
||||
throw Error("File not opened");
|
||||
}
|
||||
if (response < 0 || response >= 128) {
|
||||
throw Error("Writing a file failed with "+response);
|
||||
}
|
||||
com.sendMessage(port[0], string);
|
||||
filesystem._flush(port[0]); filesystem._close(port[0]);
|
||||
};
|
||||
filesystem.writeBytes = (driveLetter, bytes) => {
|
||||
var string = btostr(bytes); // no spreading: has length limit
|
||||
filesystem.write(driveLetter, string);
|
||||
};
|
||||
filesystem.isDirectory = (driveLetter) => {
|
||||
var port = filesystem._toPorts(driveLetter);
|
||||
com.sendMessage(port[0], "LISTFILES");
|
||||
var response = com.getStatusCode(port[0]);
|
||||
return (response === 0);
|
||||
};
|
||||
filesystem.mkDir = (driveLetter) => {
|
||||
var port = filesystem._toPorts(driveLetter);
|
||||
com.sendMessage(port[0], "MKDIR");
|
||||
var response = com.getStatusCode(port[0]);
|
||||
|
||||
if (response < 0 || response >= 128) {
|
||||
var status = com.getDeviceStatus(port[0]);
|
||||
throw Error("Creating a directory failed with ("+response+"): "+status.message+"\n");
|
||||
}
|
||||
return (response === 0); // possible status codes: 0 (success), 1 (fail)
|
||||
};
|
||||
filesystem.touch = (driveLetter) => {
|
||||
var port = filesystem._toPorts(driveLetter);
|
||||
com.sendMessage(port[0], "TOUCH");
|
||||
var response = com.getStatusCode(port[0]);
|
||||
return (response === 0);
|
||||
};
|
||||
filesystem.mkFile = (driveLetter) => {
|
||||
var port = filesystem._toPorts(driveLetter);
|
||||
com.sendMessage(port[0], "MKFILE");
|
||||
var response = com.getStatusCode(port[0]);
|
||||
return (response === 0);
|
||||
};
|
||||
filesystem.remove = (driveLetter) => {
|
||||
var port = filesystem._toPorts(driveLetter);
|
||||
com.sendMessage(port[0], "DELETE");
|
||||
var response = com.getStatusCode(port[0]);
|
||||
return (response === 0);
|
||||
};
|
||||
Object.freeze(filesystem);*/
|
||||
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
const files = {}
|
||||
@@ -1225,13 +1114,18 @@ inputwork.repeatCount = 0;
|
||||
* where:
|
||||
* "key_down", <key symbol string>, <repeat count>, keycode0, keycode1 .. keycode7
|
||||
* "key_change", <key symbol string (what went up)>, 0, keycode0, keycode1 .. keycode7 (remaining keys that are held down)
|
||||
* "mouse_down", pos-x, pos-y, 1 // yes there's only one mouse button :p
|
||||
* "mouse_up", pos-x, pos-y, 0
|
||||
* "mouse_move", pos-x, pos-y, <button down?>, oldpos-x, oldpos-y
|
||||
* "mouse_down", pos-x, pos-y, <button mask: 1=left, 2=right, 4=middle>, keycode0..keycode7
|
||||
* "mouse_up", pos-x, pos-y, <button mask of the released button>, keycode0..keycode7
|
||||
* "mouse_move", pos-x, pos-y, <currently-held button mask>, oldpos-x, oldpos-y, keycode0..keycode7
|
||||
* "mouse_wheel", pos-x, pos-y, <-1 for wheel up, +1 for wheel down>, keycode0..keycode7
|
||||
*
|
||||
* Button mask values come from MMIO[36] bits 0..2 (terranmon.txt:52-58). The wheel
|
||||
* bits (6, 7) latch in hardware and clear on read, so a one-shot detent fires once.
|
||||
* Every mouse event carries the currently-held key buffer (same shape as key_down)
|
||||
* so handlers can detect modifiers like Shift+wheel via `event.includes(<keysym>)`.
|
||||
*/
|
||||
input.withEvent = function(callback) {
|
||||
|
||||
// TODO mouse event
|
||||
function arrayEq(a,b) {
|
||||
for (let i = 0; i < a.length; ++i) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
@@ -1252,7 +1146,33 @@ input.withEvent = function(callback) {
|
||||
|
||||
sys.poke(-40, 255);
|
||||
let keys = [sys.peek(-41),sys.peek(-42),sys.peek(-43),sys.peek(-44),sys.peek(-45),sys.peek(-46),sys.peek(-47),sys.peek(-48)];
|
||||
let mouse = [sys.peek(-33) | (sys.peek(-34) << 8), sys.peek(-35) | (sys.peek(-36) << 8), sys.peek(-37)];
|
||||
let mx = (sys.peek(-33) & 0xFF) | ((sys.peek(-34) & 0xFF) << 8);
|
||||
let my = (sys.peek(-35) & 0xFF) | ((sys.peek(-36) & 0xFF) << 8);
|
||||
let mb = sys.peek(-37) & 0xFF; // bits 0..2 = L/R/M held, bit 6 = wheel up, bit 7 = wheel down
|
||||
let mouse = [mx, my, mb];
|
||||
|
||||
// --- mouse dispatch ---
|
||||
let oldMouse = inputwork.oldMouse;
|
||||
let hasOld = oldMouse && oldMouse.length === 3;
|
||||
let oldBtns = hasOld ? (oldMouse[2] & 0x07) : 0;
|
||||
let curBtns = mb & 0x07;
|
||||
let wheelUp = (mb & 0x40) !== 0;
|
||||
let wheelDn = (mb & 0x80) !== 0;
|
||||
|
||||
if (wheelUp) callback(["mouse_wheel", mx, my, -1].concat(keys));
|
||||
if (wheelDn) callback(["mouse_wheel", mx, my, 1].concat(keys));
|
||||
|
||||
let pressed = curBtns & ~oldBtns;
|
||||
let released = oldBtns & ~curBtns;
|
||||
for (let b = 1; b <= 4; b <<= 1) {
|
||||
if (pressed & b) callback(["mouse_down", mx, my, b].concat(keys));
|
||||
if (released & b) callback(["mouse_up", mx, my, b].concat(keys));
|
||||
}
|
||||
if (hasOld && (mx !== oldMouse[0] || my !== oldMouse[1])) {
|
||||
callback(["mouse_move", mx, my, curBtns, oldMouse[0], oldMouse[1]].concat(keys));
|
||||
}
|
||||
// --- end mouse dispatch ---
|
||||
|
||||
let keyChanged = !arrayEq(keys, inputwork.oldKeys)
|
||||
let keyDiff = arrayDiff(keys, inputwork.oldKeys)
|
||||
|
||||
@@ -1522,9 +1442,6 @@ let requireFromMemory = (ptr) => {
|
||||
}*/
|
||||
|
||||
|
||||
var GL = require("A:/tvdos/include/gl.mjs")
|
||||
|
||||
|
||||
// @param cmdsrc JS source code
|
||||
// @param args arguments for the program, must be Array, and args[0] is always the name of the program, e.g.
|
||||
// for command line 'echo foo bar', args[0] must be 'echo'
|
||||
@@ -1537,7 +1454,7 @@ var execApp = (cmdsrc, args, appname) => {
|
||||
`var ${appname}=function(exec_args){${injectIntChk(cmdsrc, intchkFunName)}\n};` +
|
||||
`${appname}`); // making 'exec_args' a app-level global
|
||||
|
||||
execAppPrg(args);
|
||||
return execAppPrg(args);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ function print_prompt_text() {
|
||||
print(" "+CURRENT_DRIVE+":")
|
||||
con.color_pair(161,253)
|
||||
con.addch(16);con.curs_right()
|
||||
con.color_pair(0,253)
|
||||
con.color_pair(240,253)
|
||||
print(" \\"+shell_pwd.join("\\").substring(1)+" ")
|
||||
if (errorlevel != 0 && errorlevel != "undefined" && errorlevel != undefined) {
|
||||
con.color_pair(166,253)
|
||||
@@ -61,7 +61,7 @@ function greet() {
|
||||
con.clear()
|
||||
con.color_pair(253,255)
|
||||
print(' ');con.addch(17);con.curs_right()
|
||||
con.color_pair(0,253)
|
||||
con.color_pair(240,253)
|
||||
print(" ".repeat(greetLeftPad)+welcome_text+" ".repeat(greetRightPad))
|
||||
con.color_pair(253,255)
|
||||
con.addch(16);con.curs_right();print(' ')
|
||||
@@ -77,56 +77,31 @@ function printmotd() {
|
||||
let motd = motdFile.sread().trim()
|
||||
let width = con.getmaxyx()[1]
|
||||
|
||||
let ts = require("typesetter")
|
||||
|
||||
if (goFancy) {
|
||||
let margin = 4
|
||||
let internalWidth = width - 2*margin
|
||||
let textWidth = internalWidth - 2 // one space of padding inside each ribbon edge
|
||||
|
||||
con.color_pair(255,253) // white text, transparent back (initial ribbon)
|
||||
|
||||
let [cy, cx] = con.getyx()
|
||||
|
||||
con.mvaddch(cy, 4, 16);con.curs_right();print(' ')
|
||||
|
||||
const PCX_INIT = margin - 2
|
||||
let tcnt = 0
|
||||
let pcx = PCX_INIT
|
||||
con.color_pair(240,253) // black text, white back (first line of text)
|
||||
while (tcnt <= motd.length) {
|
||||
let char = motd.charAt(tcnt)
|
||||
|
||||
if (char != '\n') {
|
||||
// prevent the line starting from ' '
|
||||
if (pcx != PCX_INIT || char != ' ') {
|
||||
print(motd.charAt(tcnt))
|
||||
}
|
||||
pcx += 1
|
||||
}
|
||||
|
||||
if ('\n' == char || pcx % internalWidth == 0 && pcx != 0 || tcnt == motd.length) {
|
||||
// current line ending
|
||||
let [_, ncx] = con.getyx()
|
||||
for (let k = 0; k < width - margin - ncx + 1; k++) print(' ')
|
||||
con.color_pair(255,253) // white text, transparent back
|
||||
con.addch(17);println()
|
||||
|
||||
if (tcnt == motd.length) break
|
||||
|
||||
// next line header
|
||||
let [ncy, __] = con.getyx()
|
||||
con.color_pair(255,253) // white text, transparent back
|
||||
con.mvaddch(ncy, 4, 16);con.curs_right();print(' ');con.color_pair(240,253) // black text, white back (subsequent lines of the text)
|
||||
pcx = PCX_INIT
|
||||
}
|
||||
|
||||
tcnt += 1
|
||||
}
|
||||
|
||||
let lines = ts.typeset(motd, textWidth)
|
||||
lines.forEach(line => {
|
||||
let [cy, _cx] = con.getyx()
|
||||
con.color_pair(255,253) // ribbon edge: white text, transparent back
|
||||
con.mvaddch(cy, margin, 16); con.curs_right()
|
||||
print(' ')
|
||||
con.color_pair(240,253) // body: black text, white back
|
||||
print(line)
|
||||
con.color_pair(255,253)
|
||||
print(' ')
|
||||
con.addch(17); println()
|
||||
})
|
||||
con.reset_graphics()
|
||||
}
|
||||
else {
|
||||
println()
|
||||
println(motd)
|
||||
let lines = ts.typeset(motd, width)
|
||||
lines.forEach(line => println(line))
|
||||
}
|
||||
|
||||
println()
|
||||
@@ -203,6 +178,19 @@ shell.replaceVarCall = function(value) {
|
||||
shell.getPwd = function() { return shell_pwd; }
|
||||
shell.getPwdString = function() { return "\\" + (shell_pwd.concat([""])).join("\\"); }
|
||||
shell.getCurrentDrive = function() { return CURRENT_DRIVE; }
|
||||
shell.runningScriptPaths = []
|
||||
shell.getFilePath = function() {
|
||||
return shell.runningScriptPaths[shell.runningScriptPaths.length - 1]
|
||||
}
|
||||
shell.getFileDir = function() {
|
||||
let p = shell.runningScriptPaths[shell.runningScriptPaths.length - 1]
|
||||
if (p === undefined) return undefined
|
||||
let lastSlash = Math.max(p.lastIndexOf('\\'), p.lastIndexOf('/'))
|
||||
if (lastSlash < 0) return p
|
||||
// root of a drive (e.g. "A:\foo.js" -> "A:\")
|
||||
if (lastSlash === 2 && p[1] === ':') return p.substring(0, 3)
|
||||
return p.substring(0, lastSlash)
|
||||
}
|
||||
// example input: echo "the string" > subdir\test.txt
|
||||
shell.parse = function(input) {
|
||||
let tokens = []
|
||||
@@ -577,6 +565,59 @@ shell.coreutils = {
|
||||
ver: function(args) {
|
||||
println(welcome_text)
|
||||
},
|
||||
which: function(args) {
|
||||
if (args[1] === undefined) {
|
||||
printerrln(`Usage: ${args[0].toUpperCase()} program_name`)
|
||||
return 1
|
||||
}
|
||||
let cmd = args[1]
|
||||
|
||||
if (shell.coreutils[cmd.toLowerCase()] !== undefined) {
|
||||
println(`${cmd}: shell built-in command`)
|
||||
return 0
|
||||
}
|
||||
|
||||
var fileExists = false
|
||||
var searchFile
|
||||
var searchPath = ""
|
||||
|
||||
if (shell.isValidDriveLetter(cmd[0]) && cmd[1] == ':') {
|
||||
searchFile = files.open(cmd)
|
||||
searchPath = trimStartRevSlash(searchFile.path)
|
||||
fileExists = searchFile.exists
|
||||
}
|
||||
else {
|
||||
var searchDir = (cmd.startsWith("/")) ? [""] : ["/"+shell_pwd.join("/")].concat(_TVDOS.getPath())
|
||||
|
||||
var pathExt = []
|
||||
if (cmd.split(".")[1] === undefined)
|
||||
_TVDOS.variables.PATHEXT.split(';').forEach(function(it) { pathExt.push(it); pathExt.push(it.toUpperCase()); })
|
||||
else
|
||||
pathExt.push("")
|
||||
|
||||
searchLoop:
|
||||
for (var i = 0; i < searchDir.length; i++) {
|
||||
for (var j = 0; j < pathExt.length; j++) {
|
||||
let search = searchDir[i]; if (!search.endsWith('\\')) search += '\\'
|
||||
searchPath = trimStartRevSlash(search + cmd + pathExt[j])
|
||||
|
||||
searchFile = files.open(`${CURRENT_DRIVE}:\\${searchPath}`)
|
||||
if (searchFile.exists) {
|
||||
fileExists = true
|
||||
break searchLoop
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!fileExists) {
|
||||
printerrln(`${cmd}: not found`)
|
||||
return 1
|
||||
}
|
||||
|
||||
println(searchFile.fullPath)
|
||||
return 0
|
||||
},
|
||||
panic: function(args) {
|
||||
throw Error("Panicking command.js")
|
||||
}
|
||||
@@ -590,6 +631,7 @@ shell.coreutils.ls = shell.coreutils.dir
|
||||
shell.coreutils.time = shell.coreutils.date
|
||||
shell.coreutils.md = shell.coreutils.mkdir
|
||||
shell.coreutils.move = shell.coreutils.mv
|
||||
shell.coreutils.where = shell.coreutils.which
|
||||
// end of command aliases
|
||||
Object.freeze(shell.coreutils)
|
||||
shell.stdio = {
|
||||
@@ -614,13 +656,25 @@ require = function(path) {
|
||||
if (path[1] == ":") return shell.require(path)
|
||||
else {
|
||||
// if the path starts with ".", look for the current directory
|
||||
// if the path starts with [A-Za-z0-9], look for the DOSDIR/includes
|
||||
// if the path starts with [A-Za-z0-9], search through INCLPATH
|
||||
if (path[0] == '.') return shell.require(shell.resolvePathInput(path).full + ".mjs")
|
||||
else return shell.require(`A:${_TVDOS.variables.DOSDIR}/include/${path}.mjs`)
|
||||
else {
|
||||
let inclDirs = (_TVDOS.variables.INCLPATH || "").split(';').filter(function(it) { return it.length > 0 })
|
||||
for (let i = 0; i < inclDirs.length; i++) {
|
||||
let dir = inclDirs[i]
|
||||
if (!dir.endsWith('\\') && !dir.endsWith('/')) dir += '\\'
|
||||
let candidate = `${CURRENT_DRIVE}:${dir}${path}.mjs`
|
||||
if (files.open(candidate).exists) return shell.require(candidate)
|
||||
}
|
||||
// no match found; defer to shell.require with the first entry so the error mentions a sensible path
|
||||
let firstDir = inclDirs[0] || `${_TVDOS.variables.DOSDIR}\\include`
|
||||
if (!firstDir.endsWith('\\') && !firstDir.endsWith('/')) firstDir += '\\'
|
||||
return shell.require(`${CURRENT_DRIVE}:${firstDir}${path}.mjs`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shell.execute = function(line) {
|
||||
shell.execute = function(line, nameOverride) {
|
||||
if (0 == line.size) return
|
||||
let parsedTokens = shell.parse(line) // echo, "hai", |, less
|
||||
let statements = [] // [[echo, "hai"], [less]]
|
||||
@@ -746,6 +800,8 @@ shell.execute = function(line) {
|
||||
let programCode = searchFile.sread()
|
||||
let extension = searchFile.extension.toUpperCase()
|
||||
|
||||
shell.runningScriptPaths.push(searchFile.fullPath)
|
||||
try {
|
||||
if ("BAT" == extension) {
|
||||
// parse and run as batch file
|
||||
var lines = programCode.split('\n').filter(function(it) { return it.length > 0 }) // this return is not shell's return!
|
||||
@@ -753,6 +809,34 @@ shell.execute = function(line) {
|
||||
shell.execute(line)
|
||||
})
|
||||
}
|
||||
else if ("ALIAS" == extension) {
|
||||
// parse alias
|
||||
// $0: all arguments
|
||||
// $1..9: specific arguments
|
||||
// Tokens that contain whitespace or shell metacharacters must be re-quoted
|
||||
// before re-execution, otherwise the re-parse splits them on spaces.
|
||||
var quoteAliasArg = function(s) {
|
||||
if (s === undefined || s === null) return ""
|
||||
s = ''+s
|
||||
if (s.length === 0) return ""
|
||||
if (/[\s"|><&]/.test(s)) return '"' + s.replaceAll('"', '^"') + '"'
|
||||
return s
|
||||
}
|
||||
var lines = programCode.split('\n').filter(function(it) { return it.length > 0 }) // this return is not shell's return!
|
||||
lines.forEach(function(line) {
|
||||
var newLine = line
|
||||
|
||||
// replace $1..$9
|
||||
for (let j = 1; j <= 9; j++) {
|
||||
newLine = newLine.replaceAll('$'+j, quoteAliasArg(tokens[j]))
|
||||
}
|
||||
|
||||
// replace $0
|
||||
newLine = newLine.replaceAll('$0', tokens.slice(1).map(quoteAliasArg).join(' '))
|
||||
|
||||
shell.execute(newLine, cmd)
|
||||
})
|
||||
}
|
||||
else if ("APP" == extension) {
|
||||
let appexec = `A:${_TVDOS.variables.DOSDIR}\\sbin\\appexec.js`
|
||||
let foundFile = searchFile.fullPath
|
||||
@@ -767,6 +851,10 @@ shell.execute = function(line) {
|
||||
errorlevel = 0 // reset the number
|
||||
|
||||
if (_G.shellProgramTitles === undefined) _G.shellProgramTitles = []
|
||||
if (nameOverride !== undefined) {
|
||||
tokens[0] = (''+nameOverride)
|
||||
cmd = tokens[0]
|
||||
}
|
||||
_G.shellProgramTitles.push(cmd.toUpperCase())
|
||||
sendLcdMsg(_G.shellProgramTitles[_G.shellProgramTitles.length - 1])
|
||||
//serial.println(_G.shellProgramTitles)
|
||||
@@ -806,6 +894,9 @@ shell.execute = function(line) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
shell.runningScriptPaths.pop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -866,6 +957,18 @@ _G.shell = shell
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// ensure USERCONFIGPATH directory exists
|
||||
try {
|
||||
let userConfigPath = `${CURRENT_DRIVE}:${_TVDOS.variables.USERCONFIGPATH}`
|
||||
let userConfigDir = files.open(userConfigPath)
|
||||
if (!userConfigDir.exists) {
|
||||
debugprintln(`command.js > creating USERCONFIGPATH at ${userConfigPath}`)
|
||||
userConfigDir.mkDir()
|
||||
}
|
||||
} catch (e) {
|
||||
debugprintln("command.js > USERCONFIGPATH creation failed: " + e.message)
|
||||
}
|
||||
|
||||
if (exec_args[1] !== undefined) {
|
||||
// only meaningful switches would be either -c or -k anyway
|
||||
var firstSwitch = exec_args[1].toLowerCase()
|
||||
|
||||
1
assets/disk0/tvdos/bin/hop.alias
Normal file
1
assets/disk0/tvdos/bin/hop.alias
Normal file
@@ -0,0 +1 @@
|
||||
hopper $0
|
||||
956
assets/disk0/tvdos/bin/hopper.js
Normal file
956
assets/disk0/tvdos/bin/hopper.js
Normal file
@@ -0,0 +1,956 @@
|
||||
/**
|
||||
* Hopper is a package manager for TVDOS
|
||||
* Created by CuriousTorvald on 2026-04-16
|
||||
*/
|
||||
|
||||
const SYSTEM_PACKEAGE_DEF_DIR = "A:/tvdos/hopper"
|
||||
const USER_BASE_DIR = "A:/hopper"
|
||||
const USER_PACKAGE_DEF_DIR = `${USER_BASE_DIR}/manifests`
|
||||
const USER_PACKAGE_BIN_DIR = `${USER_BASE_DIR}/bin`
|
||||
const USER_PACKAGE_INCLUDE_DIR = `${USER_BASE_DIR}/include`
|
||||
const MANIFEST_EXT = "hop.per"
|
||||
const MIRROR_LIST_PATH = `${SYSTEM_PACKEAGE_DEF_DIR}/mirrors.list`
|
||||
|
||||
const net = require("net")
|
||||
|
||||
// SYNOPSIS
|
||||
// hopper {search,se} [--provides, --requires, --description, --author] query
|
||||
//// default searches from ProperName
|
||||
// hopper {install,in} query [-v version]
|
||||
// hopper {remove,rm} query
|
||||
|
||||
// ============================================================
|
||||
// Manifest parsing
|
||||
// ============================================================
|
||||
|
||||
function splitList(s) {
|
||||
if (!s) return []
|
||||
return s.split(";").map(it => it.trim()).filter(it => it.length > 0)
|
||||
}
|
||||
|
||||
function parseManifest(text) {
|
||||
const m = {}
|
||||
text.split("\n").forEach(rawLine => {
|
||||
const line = rawLine.replace(/\r$/, "")
|
||||
if (line.length === 0) return
|
||||
const idx = line.indexOf(":")
|
||||
if (idx < 0) return
|
||||
const key = line.substring(0, idx).trim()
|
||||
const value = line.substring(idx + 1).trim()
|
||||
m[key] = value
|
||||
})
|
||||
return m
|
||||
}
|
||||
|
||||
function readManifestFile(path) {
|
||||
const f = files.open(path)
|
||||
if (!f.exists || f.isDirectory) return undefined
|
||||
const m = parseManifest(f.sread())
|
||||
m._manifestPath = path
|
||||
return m
|
||||
}
|
||||
|
||||
function _listManifestsFrom(dirPath, origin) {
|
||||
const dir = files.open(dirPath)
|
||||
if (!dir.exists || !dir.isDirectory) return []
|
||||
const out = []
|
||||
dir.list().forEach(entry => {
|
||||
if (entry.isDirectory) return
|
||||
if (!entry.name.toLowerCase().endsWith(MANIFEST_EXT)) return
|
||||
const m = readManifestFile(entry.fullPath)
|
||||
if (m !== undefined) {
|
||||
m._origin = origin
|
||||
out.push(m)
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// System packages (shipped with TVDOS) live in SYSTEM_PACKAGE_DEF_DIR
|
||||
// and are read-only as far as hopper is concerned. User packages,
|
||||
// installed by `hopper install`, live under USER_PACKAGE_DEF_DIR. The
|
||||
// resolver treats both as "installed", but the install/remove paths
|
||||
// refuse to modify anything tagged `_origin === "system"`.
|
||||
function listInstalledManifests() {
|
||||
return _listManifestsFrom(SYSTEM_PACKEAGE_DEF_DIR, "system")
|
||||
.concat(_listManifestsFrom(USER_PACKAGE_DEF_DIR, "user"))
|
||||
}
|
||||
|
||||
function findInstalledManifest(name) {
|
||||
// Prefer user-installed copy when a system package with the same name
|
||||
// also exists -- but that combination is normally refused at install.
|
||||
const userDirect = `${USER_PACKAGE_DEF_DIR}/${name}.${MANIFEST_EXT}`
|
||||
let m = readManifestFile(userDirect)
|
||||
if (m !== undefined) { m._origin = "user"; return m }
|
||||
|
||||
const sysDirect = `${SYSTEM_PACKEAGE_DEF_DIR}/${name}.${MANIFEST_EXT}`
|
||||
m = readManifestFile(sysDirect)
|
||||
if (m !== undefined) { m._origin = "system"; return m }
|
||||
|
||||
const all = listInstalledManifests()
|
||||
for (let i = 0; i < all.length; i++) {
|
||||
if ((all[i].HopperPackageName || "") === name) return all[i]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Yes/no prompt. Empty input falls back to `defaultYes`.
|
||||
function confirm(prompt, defaultYes) {
|
||||
const hint = defaultYes ? "[Y/n]" : "[y/N]"
|
||||
print(`${prompt} ${hint} `)
|
||||
const ans = (read() || "").trim().toLowerCase()
|
||||
if (ans === "") return !!defaultYes
|
||||
return ans === "y" || ans === "yes"
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Install layout helpers
|
||||
// ============================================================
|
||||
//
|
||||
// User-installed packages live under `A:/hopper/`. Files are routed
|
||||
// by extension: `.mjs` includes go under `include/`, everything else
|
||||
// (`.js`, `.alias`, `.lfs`, data blobs, ...) lands in `bin/`. The
|
||||
// downloaded manifest is saved under `manifests/` with a
|
||||
// `SystemPackagePath` field appended that lists the resulting paths.
|
||||
|
||||
// Strip query/fragment and take the last `/`-separated component of `url`.
|
||||
function urlBasename(url) {
|
||||
let s = String(url || "")
|
||||
const qm = s.indexOf("?"); if (qm >= 0) s = s.substring(0, qm)
|
||||
const hash = s.indexOf("#"); if (hash >= 0) s = s.substring(0, hash)
|
||||
const slash = s.lastIndexOf("/")
|
||||
return (slash < 0) ? s : s.substring(slash + 1)
|
||||
}
|
||||
|
||||
function routeForBasename(name) {
|
||||
return (String(name || "").toLowerCase().endsWith(".mjs"))
|
||||
? USER_PACKAGE_INCLUDE_DIR
|
||||
: USER_PACKAGE_BIN_DIR
|
||||
}
|
||||
|
||||
// Convert a USER_BASE_DIR-relative absolute path ("A:/hopper/bin/foo.js")
|
||||
// into its declarable form ("/hopper/bin/foo.js"), matching the
|
||||
// `SystemPackagePath` convention used by the system manifests.
|
||||
function declarablePath(absPath) {
|
||||
let p = String(absPath || "").replace(/\\/g, "/")
|
||||
if (/^[A-Za-z]:/.test(p)) p = p.substring(2)
|
||||
return p
|
||||
}
|
||||
|
||||
// Parse PackageFileList (semicolon-separated full URLs) into a list of
|
||||
// download descriptors: { url, basename, localPath }.
|
||||
function parsePackageFileList(s) {
|
||||
const out = []
|
||||
splitList(s || "").forEach(url => {
|
||||
const base = urlBasename(url)
|
||||
if (base.length === 0) return
|
||||
const dir = routeForBasename(base)
|
||||
out.push({ url: url, basename: base, localPath: `${dir}/${base}` })
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
function ensureUserDirs() {
|
||||
[USER_BASE_DIR, USER_PACKAGE_BIN_DIR, USER_PACKAGE_INCLUDE_DIR, USER_PACKAGE_DEF_DIR].forEach(p => {
|
||||
const d = files.open(p)
|
||||
if (!d.exists) d.mkDir()
|
||||
})
|
||||
}
|
||||
|
||||
// Re-emit a parsed manifest, preserving insertion order, dropping
|
||||
// internal `_*` keys, and replacing any pre-existing SystemPackagePath
|
||||
// with the locally-computed one so the field always reflects what is
|
||||
// actually on disk.
|
||||
function serializeManifest(manifestObj, installedPathStr) {
|
||||
const lines = []
|
||||
Object.keys(manifestObj).forEach(k => {
|
||||
if (k.length > 0 && k[0] === "_") return
|
||||
if (k === "SystemPackagePath") return
|
||||
lines.push(`${k}:${manifestObj[k]}`)
|
||||
})
|
||||
lines.push(`SystemPackagePath:${installedPathStr}`)
|
||||
return lines.join("\n") + "\n"
|
||||
}
|
||||
|
||||
// Delete every file declared in `manifest.SystemPackagePath` plus the
|
||||
// manifest file itself. Wildcards are expanded via `expandSystemPath`.
|
||||
function deleteInstalledFiles(manifest) {
|
||||
const removed = []
|
||||
splitList(manifest.SystemPackagePath || "").forEach(p => {
|
||||
expandSystemPath(p).forEach(abs => {
|
||||
const fd = files.open(abs)
|
||||
if (!fd.exists) return
|
||||
try { fd.remove(); removed.push(abs) }
|
||||
catch (e) { printerrln(` ! failed to remove ${abs}: ${e}`) }
|
||||
})
|
||||
})
|
||||
if (manifest._manifestPath) {
|
||||
const mfd = files.open(manifest._manifestPath)
|
||||
if (mfd.exists) {
|
||||
try { mfd.remove(); removed.push(manifest._manifestPath) }
|
||||
catch (e) { printerrln(` ! failed to remove ${manifest._manifestPath}: ${e}`) }
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SemVer (strict X.Y.Z) and constraint matching
|
||||
// ============================================================
|
||||
//
|
||||
// Versions are strict Semantic Versioning: three non-negative integer
|
||||
// components MAJOR.MINOR.PATCH. No pre-release / build metadata.
|
||||
//
|
||||
// Constraint grammar (intentionally small, expandable later):
|
||||
// * any version
|
||||
// X.* major X, any minor/patch
|
||||
// X.Y.* major X, minor Y, any patch
|
||||
// X.Y.Z exact
|
||||
// ^X.Y.Z >= X.Y.Z and < (X+1).0.0 (major-compatible)
|
||||
// ~X.Y.Z >= X.Y.Z and < X.(Y+1).0 (minor-compatible)
|
||||
// >=X.Y.Z / >X.Y.Z / <=X.Y.Z / <X.Y.Z / =X.Y.Z
|
||||
//
|
||||
// Multiple comma-separated constraints are AND-ed: "^1.2.0,<1.5.0".
|
||||
|
||||
function parseVersion(v) {
|
||||
const m = String(v || "0.0.0").trim().match(/^(\d+)\.(\d+)\.(\d+)$/)
|
||||
if (!m) return [0, 0, 0]
|
||||
return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)]
|
||||
}
|
||||
|
||||
function compareVersion(a, b) {
|
||||
const A = parseVersion(a), B = parseVersion(b)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (A[i] !== B[i]) return (A[i] < B[i]) ? -1 : 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function _matchSingleConstraint(version, c) {
|
||||
c = c.trim()
|
||||
if (c === "" || c === "*") return true
|
||||
|
||||
// Operator form: ^, ~, >=, <=, >, <, =
|
||||
let opMatch = c.match(/^(\^|~|>=|<=|>|<|=)\s*(\d+\.\d+\.\d+)$/)
|
||||
if (opMatch) {
|
||||
const op = opMatch[1]
|
||||
const target = opMatch[2]
|
||||
const cmp = compareVersion(version, target)
|
||||
const [tM, tm] = parseVersion(target)
|
||||
switch (op) {
|
||||
case "=": return cmp === 0
|
||||
case ">": return cmp > 0
|
||||
case ">=": return cmp >= 0
|
||||
case "<": return cmp < 0
|
||||
case "<=": return cmp <= 0
|
||||
case "^": return cmp >= 0 && compareVersion(version, `${tM + 1}.0.0`) < 0
|
||||
case "~": return cmp >= 0 && compareVersion(version, `${tM}.${tm + 1}.0`) < 0
|
||||
}
|
||||
}
|
||||
|
||||
// Wildcard form: X.*, X.Y.*, X.x, X.Y.x, or exact X.Y.Z
|
||||
const parts = c.split(".")
|
||||
const vparts = parseVersion(version)
|
||||
for (let i = 0; i < parts.length && i < 3; i++) {
|
||||
if (parts[i] === "*" || parts[i] === "x" || parts[i] === "X") return true
|
||||
const expected = parseInt(parts[i], 10)
|
||||
if (isNaN(expected) || vparts[i] !== expected) return false
|
||||
}
|
||||
// All listed parts matched literally; remaining parts (if any) must be 0
|
||||
for (let i = parts.length; i < 3; i++) {
|
||||
if (vparts[i] !== 0) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function satisfies(version, constraint) {
|
||||
if (!constraint) return true
|
||||
return constraint.split(",").every(c => _matchSingleConstraint(version, c))
|
||||
}
|
||||
|
||||
function parseRequires(s) {
|
||||
const out = []
|
||||
splitList(s || "").forEach(entry => {
|
||||
// "<name>" or "<name> <constraint>"
|
||||
const idx = entry.search(/\s+/)
|
||||
if (idx < 0) {
|
||||
out.push({ name: entry, constraint: "*" })
|
||||
} else {
|
||||
out.push({ name: entry.substring(0, idx), constraint: entry.substring(idx + 1).trim() })
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// HopperProvides entries are "<name>" or "<name> <version>". A bare name
|
||||
// falls back to the package's own HopperPackageVersion — the same idea
|
||||
// as RPM's `Provides: aalib = 1.2.0` (where the package's real name and
|
||||
// version may differ from the virtual identity it exposes).
|
||||
function parseProvides(s, fallbackVersion) {
|
||||
const out = []
|
||||
splitList(s || "").forEach(entry => {
|
||||
const idx = entry.search(/\s+/)
|
||||
if (idx < 0) {
|
||||
out.push({ name: entry, version: fallbackVersion })
|
||||
} else {
|
||||
const v = entry.substring(idx + 1).trim()
|
||||
out.push({ name: entry.substring(0, idx), version: v || fallbackVersion })
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Look up the version a candidate exposes for `name`. If `name` matches
|
||||
// the package's own name (or isn't declared in HopperProvides at all),
|
||||
// returns the package's own version.
|
||||
function providedVersionOf(candidate, name) {
|
||||
if (candidate.provides) {
|
||||
for (let i = 0; i < candidate.provides.length; i++) {
|
||||
if (candidate.provides[i].name === name) return candidate.provides[i].version
|
||||
}
|
||||
}
|
||||
return candidate.version
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Candidate index (installed + upstream)
|
||||
// ============================================================
|
||||
|
||||
function _manifestToCandidate(m, source) {
|
||||
const name = m.HopperPackageName || ""
|
||||
const version = m.HopperPackageVersion || "0.0.0"
|
||||
const provides = parseProvides(m.HopperProvides || "", version)
|
||||
// Every package implicitly provides itself at its own version. Only
|
||||
// synthesise this when the manifest didn't declare it explicitly.
|
||||
if (name && !provides.some(p => p.name === name)) {
|
||||
provides.unshift({ name: name, version: version })
|
||||
}
|
||||
return {
|
||||
name: name,
|
||||
version: version,
|
||||
requires: parseRequires(m.HopperRequires || ""),
|
||||
provides: provides,
|
||||
source: source, // "installed" | "upstream"
|
||||
manifest: m
|
||||
}
|
||||
}
|
||||
|
||||
// Returns map: packageName -> array<Candidate>
|
||||
function buildCandidateIndex() {
|
||||
const idx = new Map()
|
||||
function add(c) {
|
||||
if (!idx.has(c.name)) idx.set(c.name, [])
|
||||
// De-dupe (name+version+source)
|
||||
const arr = idx.get(c.name)
|
||||
if (arr.some(x => x.version === c.version && x.source === c.source)) return
|
||||
arr.push(c)
|
||||
}
|
||||
|
||||
listInstalledManifests().forEach(m => add(_manifestToCandidate(m, "installed")))
|
||||
fetchRemoteCandidates().forEach(m => add(_manifestToCandidate(m, "upstream")))
|
||||
|
||||
return idx
|
||||
}
|
||||
|
||||
// Anything that satisfies a requirement on `name`: a package whose own
|
||||
// HopperPackageName matches OR whose HopperProvides declares `name`.
|
||||
// Each candidate now carries `provides` as {name, version} pairs; the
|
||||
// package's own (name, version) is always present (see
|
||||
// _manifestToCandidate), so a single pass over `provides` is enough.
|
||||
function findProviders(idx, name) {
|
||||
const out = []
|
||||
const seen = new Set()
|
||||
idx.forEach(candidates => {
|
||||
candidates.forEach(c => {
|
||||
if (seen.has(c)) return
|
||||
if (c.provides.some(p => p.name === name)) {
|
||||
out.push(c)
|
||||
seen.add(c)
|
||||
}
|
||||
})
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Sort: installed first (no churn), then highest version, then upstream order.
|
||||
function sortCandidates(cands) {
|
||||
return cands.slice().sort((a, b) => {
|
||||
if (a.source !== b.source) return (a.source === "installed") ? -1 : 1
|
||||
return -compareVersion(a.version, b.version)
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Resolver (snapshot-based backtracking; precursor to a SAT solver)
|
||||
// ============================================================
|
||||
//
|
||||
// State: chosen :: Map<packageName, Candidate>
|
||||
// At every choice point we snapshot the whole map so that backtracking
|
||||
// also undoes any transitive picks. The candidate ordering encodes the
|
||||
// preference policy:
|
||||
//
|
||||
// 1. Keep installed if it satisfies the constraint.
|
||||
// 2. Otherwise pick the newest upstream version that satisfies.
|
||||
// 3. If newer versions cause downstream conflicts, walk older versions
|
||||
// (downgrade) until either something fits or candidates are exhausted.
|
||||
//
|
||||
// The structure is intentionally close to DPLL: each "decision" is the
|
||||
// candidate we assign to a variable, and "unit propagation" is the
|
||||
// recursive resolve() call over each requirement. Replacing this with
|
||||
// clause learning / a watched-literals scheme later would be local.
|
||||
|
||||
function resolveAll(idx, requirements) {
|
||||
const chosen = new Map()
|
||||
const issues = []
|
||||
|
||||
function snapshot() { return new Map(chosen) }
|
||||
function restore(snap) { chosen.clear(); snap.forEach((v, k) => chosen.set(k, v)) }
|
||||
|
||||
function _resolve(reqName, constraint, trail) {
|
||||
const existing = chosen.get(reqName)
|
||||
if (existing !== undefined) {
|
||||
const v = providedVersionOf(existing, reqName)
|
||||
return satisfies(v, constraint)
|
||||
? { ok: true }
|
||||
: { ok: false, reason: `${reqName} pinned to ${v}, but ${trail.join(" -> ")} requires ${constraint}` }
|
||||
}
|
||||
|
||||
const providers = findProviders(idx, reqName)
|
||||
if (providers.length === 0) {
|
||||
return { ok: false, reason: `no package provides "${reqName}" (required by ${trail.join(" -> ") || "<root>"})` }
|
||||
}
|
||||
// Satisfaction checks the virtual version the candidate exposes
|
||||
// for `reqName` (HopperProvides), not necessarily the package's
|
||||
// own HopperPackageVersion.
|
||||
const matching = sortCandidates(providers.filter(c => satisfies(providedVersionOf(c, reqName), constraint)))
|
||||
if (matching.length === 0) {
|
||||
const versions = providers.map(p => `${providedVersionOf(p, reqName)}[${p.source}]`).join(", ")
|
||||
return { ok: false, reason: `no version of "${reqName}" satisfies ${constraint} (available: ${versions})` }
|
||||
}
|
||||
|
||||
let lastReason = null
|
||||
for (let i = 0; i < matching.length; i++) {
|
||||
const cand = matching[i]
|
||||
const snap = snapshot()
|
||||
chosen.set(cand.name, cand)
|
||||
|
||||
let allOk = true
|
||||
const subTrail = trail.concat([`${cand.name}@${cand.version}`])
|
||||
for (let j = 0; j < cand.requires.length; j++) {
|
||||
const req = cand.requires[j]
|
||||
const r = _resolve(req.name, req.constraint, subTrail)
|
||||
if (!r.ok) {
|
||||
allOk = false
|
||||
lastReason = r.reason
|
||||
break
|
||||
}
|
||||
}
|
||||
if (allOk) return { ok: true }
|
||||
restore(snap)
|
||||
}
|
||||
|
||||
return { ok: false, reason: lastReason || `no working candidate for "${reqName}"` }
|
||||
}
|
||||
|
||||
requirements.forEach(req => {
|
||||
const r = _resolve(req.name, req.constraint, [])
|
||||
if (!r.ok) issues.push(r.reason)
|
||||
})
|
||||
|
||||
return { chosen, issues }
|
||||
}
|
||||
|
||||
// Compare resolved assignment against currently-installed state.
|
||||
function classifyPlan(idx, chosen) {
|
||||
const installedByName = new Map()
|
||||
listInstalledManifests().forEach(m => installedByName.set(m.HopperPackageName, m))
|
||||
|
||||
const actions = []
|
||||
chosen.forEach((cand, name) => {
|
||||
const inst = installedByName.get(name)
|
||||
if (cand.source === "installed") {
|
||||
actions.push({ action: "keep", name, version: cand.version })
|
||||
}
|
||||
else if (inst === undefined) {
|
||||
actions.push({ action: "install", name, version: cand.version })
|
||||
}
|
||||
else {
|
||||
const cmp = compareVersion(cand.version, inst.HopperPackageVersion)
|
||||
if (cmp > 0) actions.push({ action: "upgrade", name, from: inst.HopperPackageVersion, to: cand.version })
|
||||
else if (cmp < 0) actions.push({ action: "downgrade", name, from: inst.HopperPackageVersion, to: cand.version })
|
||||
else actions.push({ action: "reinstall", name, version: cand.version })
|
||||
}
|
||||
})
|
||||
return actions
|
||||
}
|
||||
|
||||
function printPlan(actions, target) {
|
||||
const changing = actions.filter(a => a.action !== "keep")
|
||||
if (changing.length === 0) {
|
||||
println(`Nothing to do: ${target} is already installed and satisfied.`)
|
||||
return
|
||||
}
|
||||
println("Plan:")
|
||||
changing.forEach(a => {
|
||||
switch (a.action) {
|
||||
case "install": println(` + install ${a.name} ${a.version}`); break
|
||||
case "upgrade": println(` ^ upgrade ${a.name} ${a.from} -> ${a.to}`); break
|
||||
case "downgrade": println(` v downgrade ${a.name} ${a.from} -> ${a.to}`); break
|
||||
case "reinstall": println(` = reinstall ${a.name} ${a.version}`); break
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Remote mirrors
|
||||
// ============================================================
|
||||
//
|
||||
// `mirrors.list` lives next to the installed package manifests.
|
||||
// Each non-empty, non-`#` line is the URL prefix of a Hopper mirror.
|
||||
// The mirror MUST expose `<prefix>mirror_manifest` (key:value pairs
|
||||
// describing the mirror) and `<prefix>filelist` (CSV with rows of
|
||||
// `packagename,version,hoppermanifest-filename`).
|
||||
//
|
||||
// Trailing slash on the prefix is optional and will be added if missing.
|
||||
|
||||
function loadMirrorList() {
|
||||
const f = files.open(MIRROR_LIST_PATH)
|
||||
if (!f.exists || f.isDirectory) return []
|
||||
return f.sread().split("\n")
|
||||
.map(line => line.replace(/\r$/, "").trim())
|
||||
.filter(line => line.length > 0 && line[0] !== "#")
|
||||
.map(line => line.endsWith("/") ? line : (line + "/"))
|
||||
}
|
||||
|
||||
function parseFileList(text) {
|
||||
const out = []
|
||||
text.split("\n").forEach(raw => {
|
||||
const line = raw.replace(/\r$/, "").trim()
|
||||
if (line.length === 0 || line[0] === "#") return
|
||||
const parts = line.split(",")
|
||||
if (parts.length < 3) return
|
||||
out.push({
|
||||
name: parts[0].trim(),
|
||||
version: parts[1].trim(),
|
||||
file: parts[2].trim(),
|
||||
})
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
function fetchManifestsFromMirror(prefix) {
|
||||
const mfText = net.fetchText(prefix + "mirror_manifest")
|
||||
if (mfText === null) {
|
||||
printerrln(` ! could not reach mirror: ${prefix}`)
|
||||
return []
|
||||
}
|
||||
const mirror = parseManifest(mfText)
|
||||
const mirrorName = mirror.HopperMirrorName || prefix
|
||||
|
||||
const flText = net.fetchText(prefix + "filelist")
|
||||
if (flText === null) {
|
||||
printerrln(` ! mirror "${mirrorName}" has no filelist`)
|
||||
return []
|
||||
}
|
||||
|
||||
const out = []
|
||||
parseFileList(flText).forEach(entry => {
|
||||
const manifestText = net.fetchText(prefix + entry.file)
|
||||
if (manifestText === null) {
|
||||
printerrln(` ! mirror "${mirrorName}" missing ${entry.file}`)
|
||||
return
|
||||
}
|
||||
const m = parseManifest(manifestText)
|
||||
m._mirrorName = mirrorName
|
||||
m._mirrorPrefix = prefix
|
||||
m._manifestUrl = prefix + entry.file
|
||||
out.push(m)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Per-invocation memoisation. Search and install both pull the same
|
||||
// data; we only want to hit the network once per `hopper ...` call.
|
||||
let _remoteCache = null
|
||||
|
||||
function fetchRemoteCandidates() {
|
||||
if (_remoteCache !== null) return _remoteCache
|
||||
|
||||
const mirrors = loadMirrorList()
|
||||
if (mirrors.length === 0) {
|
||||
_remoteCache = []
|
||||
return _remoteCache
|
||||
}
|
||||
|
||||
if (!net.isAvailable()) {
|
||||
printerrln("Warning: no HTTP modem attached; remote mirrors will be skipped.")
|
||||
_remoteCache = []
|
||||
return _remoteCache
|
||||
}
|
||||
|
||||
const out = []
|
||||
mirrors.forEach(prefix => {
|
||||
fetchManifestsFromMirror(prefix).forEach(m => out.push(m))
|
||||
})
|
||||
_remoteCache = out
|
||||
return _remoteCache
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Search
|
||||
// ============================================================
|
||||
|
||||
function fieldCandidates(manifest, field) {
|
||||
switch (field) {
|
||||
case "provides": return splitList(manifest.HopperProvides || "")
|
||||
case "requires": return splitList(manifest.HopperRequires || "")
|
||||
case "description": return [manifest.ProperDescription || ""]
|
||||
case "author": return [manifest.ProperAuthor || ""]
|
||||
default: return [manifest.ProperName || "", manifest.HopperPackageName || ""]
|
||||
}
|
||||
}
|
||||
|
||||
function matchesQuery(manifest, field, query) {
|
||||
const q = query.toLowerCase()
|
||||
return fieldCandidates(manifest, field).some(c => c.toLowerCase().indexOf(q) >= 0)
|
||||
}
|
||||
|
||||
function printSearchResult(m, origin) {
|
||||
const name = m.ProperName || m.HopperPackageName || "(unnamed)"
|
||||
const ver = m.HopperPackageVersion || "?"
|
||||
println(` [${origin}] ${name} -- ${m.HopperPackageName} ${ver}`)
|
||||
if (m.ProperDescription) println(` ${m.ProperDescription}`)
|
||||
}
|
||||
|
||||
function cmdSearch(args) {
|
||||
let field = "name"
|
||||
let query = undefined
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i]
|
||||
if (a === "--provides") field = "provides"
|
||||
else if (a === "--requires") field = "requires"
|
||||
else if (a === "--description") field = "description"
|
||||
else if (a === "--author") field = "author"
|
||||
else if (a.startsWith("--")) { printerrln(`Unknown option: ${a}`); return 1 }
|
||||
else query = a
|
||||
}
|
||||
if (query === undefined) {
|
||||
printerrln("Usage: hopper search [--provides|--requires|--description|--author] <query>")
|
||||
return 1
|
||||
}
|
||||
|
||||
println(`Searching installed packages in ${SYSTEM_PACKEAGE_DEF_DIR} ...`)
|
||||
const sysHits = listInstalledManifests().filter(m => matchesQuery(m, field, query))
|
||||
if (sysHits.length === 0) println(" (no matches)")
|
||||
else sysHits.forEach(m => printSearchResult(m, "installed"))
|
||||
|
||||
println("")
|
||||
println("Searching remote mirrors ...")
|
||||
const remote = fetchRemoteCandidates()
|
||||
if (remote.length === 0) {
|
||||
println(" (no mirrors configured or reachable)")
|
||||
}
|
||||
else {
|
||||
const netHits = remote.filter(m => matchesQuery(m, field, query))
|
||||
if (netHits.length === 0) println(" (no matches)")
|
||||
else netHits.forEach(m => printSearchResult(m, m._mirrorName || "remote"))
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Install
|
||||
// ============================================================
|
||||
//
|
||||
// Each upstream manifest declares its payload via `PackageFileList`,
|
||||
// a semicolon-separated list of full URLs. Hopper fetches each URL and
|
||||
// drops the result in /hopper/bin (default) or /hopper/include (.mjs).
|
||||
// The locally-saved manifest gets a `SystemPackagePath` field appended
|
||||
// listing the resulting absolute paths, which is what `cmdRemove` later
|
||||
// walks to clean up.
|
||||
|
||||
function _installOne(action, candidate) {
|
||||
const m = candidate.manifest
|
||||
const files_ = parsePackageFileList(m.PackageFileList)
|
||||
if (files_.length === 0) {
|
||||
printerrln(` ! ${candidate.name}: upstream manifest has no PackageFileList; cannot install`)
|
||||
return false
|
||||
}
|
||||
|
||||
// Fetch first, write second: a single 404 should not leave a
|
||||
// half-installed package behind.
|
||||
const fetched = []
|
||||
for (let i = 0; i < files_.length; i++) {
|
||||
const f = files_[i]
|
||||
println(` fetch ${f.url}`)
|
||||
const body = net.fetchText(f.url)
|
||||
if (body === null || body === undefined) {
|
||||
printerrln(` ! failed to fetch ${f.url}`)
|
||||
return false
|
||||
}
|
||||
fetched.push({ entry: f, body: body })
|
||||
}
|
||||
|
||||
// If we are replacing an existing user-installed copy, remove its
|
||||
// old files first so a renamed payload doesn't leave orphans.
|
||||
if (action !== "install") {
|
||||
const oldManifestPath = `${USER_PACKAGE_DEF_DIR}/${candidate.name}.${MANIFEST_EXT}`
|
||||
const old = readManifestFile(oldManifestPath)
|
||||
if (old !== undefined) {
|
||||
splitList(old.SystemPackagePath || "").forEach(p => {
|
||||
expandSystemPath(p).forEach(abs => {
|
||||
const fd = files.open(abs)
|
||||
if (fd.exists) {
|
||||
try { fd.remove() }
|
||||
catch (e) { printerrln(` ! could not remove old ${abs}: ${e}`) }
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Write payload files.
|
||||
fetched.forEach(item => {
|
||||
const fd = files.open(item.entry.localPath)
|
||||
if (!fd.exists) fd.mkFile()
|
||||
fd.swrite(item.body)
|
||||
println(` write ${item.entry.localPath}`)
|
||||
})
|
||||
|
||||
// Save the manifest with SystemPackagePath appended.
|
||||
const sysPath = fetched.map(item => declarablePath(item.entry.localPath)).join(";")
|
||||
const manifestPath = `${USER_PACKAGE_DEF_DIR}/${candidate.name}.${MANIFEST_EXT}`
|
||||
const mfd = files.open(manifestPath)
|
||||
if (!mfd.exists) mfd.mkFile()
|
||||
mfd.swrite(serializeManifest(m, sysPath))
|
||||
println(` write ${manifestPath}`)
|
||||
return true
|
||||
}
|
||||
|
||||
function cmdInstall(args) {
|
||||
let query = undefined
|
||||
let version = undefined
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "-v") { version = args[i + 1]; i++ }
|
||||
else if (args[i].startsWith("--")) { printerrln(`Unknown option: ${args[i]}`); return 1 }
|
||||
else query = args[i]
|
||||
}
|
||||
if (query === undefined) {
|
||||
printerrln("Usage: hopper install <package> [-v <version>]")
|
||||
return 1
|
||||
}
|
||||
|
||||
const targetConstraint = version || "*"
|
||||
const verSuffix = (targetConstraint !== "*") ? ` (${targetConstraint})` : ""
|
||||
println(`Resolving ${query}${verSuffix} ...`)
|
||||
|
||||
const idx = buildCandidateIndex()
|
||||
|
||||
// Sanity check: target must exist in the index (installed or upstream).
|
||||
if (findProviders(idx, query).length === 0) {
|
||||
printerrln(`Error: package "${query}" not found (not on upstream, not installed).`)
|
||||
return 4
|
||||
}
|
||||
|
||||
// Seed order matters: the target goes FIRST so its (possibly tight)
|
||||
// constraints can drive upgrades of dependencies. The installed-set
|
||||
// requirements follow at "*" so the resolver still has to keep them
|
||||
// alive (preferring installed candidates when their version still fits,
|
||||
// otherwise upgrading or downgrading them).
|
||||
const seed = [{ name: query, constraint: targetConstraint }]
|
||||
listInstalledManifests().forEach(m => {
|
||||
if (m.HopperPackageName === query) return
|
||||
seed.push({ name: m.HopperPackageName, constraint: "*" })
|
||||
})
|
||||
|
||||
const { chosen, issues } = resolveAll(idx, seed)
|
||||
if (issues.length > 0) {
|
||||
printerrln("Resolution failed:")
|
||||
issues.forEach(reason => printerrln(` - ${reason}`))
|
||||
printerrln("")
|
||||
printerrln("No solution found -- not installable.")
|
||||
return 3
|
||||
}
|
||||
|
||||
const plan = classifyPlan(idx, chosen)
|
||||
printPlan(plan, query)
|
||||
|
||||
const changing = plan.filter(a => a.action !== "keep")
|
||||
if (changing.length === 0) return 0
|
||||
|
||||
// Pre-flight: refuse to clobber system packages, and require every
|
||||
// upstream candidate to actually carry a payload list.
|
||||
const blockers = []
|
||||
changing.forEach(a => {
|
||||
const cand = chosen.get(a.name)
|
||||
const inst = findInstalledManifest(a.name)
|
||||
if (inst && inst._origin === "system") {
|
||||
blockers.push(`${a.name}: cannot ${a.action} -- a system package with that name is already installed`)
|
||||
}
|
||||
if (cand && cand.source === "upstream" && !(cand.manifest.PackageFileList && cand.manifest.PackageFileList.length > 0)) {
|
||||
blockers.push(`${a.name}: upstream manifest declares no PackageFileList`)
|
||||
}
|
||||
})
|
||||
if (blockers.length > 0) {
|
||||
printerrln("Cannot proceed:")
|
||||
blockers.forEach(b => printerrln(` - ${b}`))
|
||||
return 5
|
||||
}
|
||||
|
||||
if (!net.isAvailable()) {
|
||||
printerrln("No HTTP modem attached; cannot fetch package files.")
|
||||
return 6
|
||||
}
|
||||
|
||||
println("")
|
||||
if (!confirm("Proceed with installation?", true)) {
|
||||
println("Aborted.")
|
||||
return 0
|
||||
}
|
||||
|
||||
ensureUserDirs()
|
||||
|
||||
let failed = 0
|
||||
for (let i = 0; i < changing.length; i++) {
|
||||
const a = changing[i]
|
||||
const cand = chosen.get(a.name)
|
||||
if (a.action === "install" || a.action === "reinstall") {
|
||||
println(`${a.action} ${a.name} ${a.version}`)
|
||||
} else {
|
||||
println(`${a.action} ${a.name} ${a.from} -> ${a.to}`)
|
||||
}
|
||||
if (!_installOne(a.action, cand)) {
|
||||
failed++
|
||||
printerrln(` ! ${a.name}: aborted`)
|
||||
break
|
||||
}
|
||||
}
|
||||
if (failed > 0) {
|
||||
printerrln(`${failed} package(s) failed to install.`)
|
||||
return 7
|
||||
}
|
||||
|
||||
println("Done.")
|
||||
return 0
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Remove
|
||||
// ============================================================
|
||||
|
||||
// Convert a SystemPackagePath entry (e.g. "/tvdos/bin/taut*") into a
|
||||
// concrete list of files on the A: drive. Supports a simple '*' wildcard
|
||||
// in the filename component.
|
||||
function expandSystemPath(pattern) {
|
||||
const sysDrive = "A:"
|
||||
|
||||
if (pattern.indexOf("*") < 0) {
|
||||
return [`${sysDrive}${pattern}`]
|
||||
}
|
||||
|
||||
const fwd = pattern.lastIndexOf("/")
|
||||
const bck = pattern.lastIndexOf("\\")
|
||||
const lastSep = Math.max(fwd, bck)
|
||||
const dirPart = (lastSep < 0) ? "" : pattern.substring(0, lastSep)
|
||||
const namePart = (lastSep < 0) ? pattern : pattern.substring(lastSep + 1)
|
||||
|
||||
const dir = files.open(`${sysDrive}${dirPart}/`)
|
||||
if (!dir.exists || !dir.isDirectory) return []
|
||||
|
||||
const escaped = namePart.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*")
|
||||
const re = new RegExp(`^${escaped}$`, "i")
|
||||
|
||||
const out = []
|
||||
dir.list().forEach(entry => {
|
||||
if (entry.isDirectory) return
|
||||
if (re.test(entry.name)) out.push(entry.fullPath)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
function cmdRemove(args) {
|
||||
const query = args[0]
|
||||
if (query === undefined) {
|
||||
printerrln("Usage: hopper remove <package>")
|
||||
return 1
|
||||
}
|
||||
|
||||
const m = findInstalledManifest(query)
|
||||
if (m === undefined) {
|
||||
printerrln(`Package not installed: ${query}`)
|
||||
return 2
|
||||
}
|
||||
if (m._origin === "system") {
|
||||
printerrln(`Cannot remove ${query}: it is a system package.`)
|
||||
return 6
|
||||
}
|
||||
|
||||
const name = m.ProperName || m.HopperPackageName || query
|
||||
const ver = m.HopperPackageVersion || "?"
|
||||
println(`Preparing removal of ${name} (${m.HopperPackageName} ${ver}) ...`)
|
||||
|
||||
const paths = splitList(m.SystemPackagePath || "")
|
||||
println("")
|
||||
println("The following files will be deleted:")
|
||||
if (paths.length === 0) {
|
||||
println(" (manifest declares no files)")
|
||||
}
|
||||
paths.forEach(p => {
|
||||
const expanded = expandSystemPath(p)
|
||||
if (expanded.length === 0) {
|
||||
println(` (no match on disk) ${p}`)
|
||||
}
|
||||
else {
|
||||
expanded.forEach(e => println(` ${e}`))
|
||||
}
|
||||
})
|
||||
println(` ${m._manifestPath}`)
|
||||
|
||||
println("")
|
||||
if (!confirm("Proceed with removal?", false)) {
|
||||
println("Aborted.")
|
||||
return 0
|
||||
}
|
||||
|
||||
const removed = deleteInstalledFiles(m)
|
||||
removed.forEach(p => println(` removed ${p}`))
|
||||
if (removed.length === 0) println(" (nothing was removed)")
|
||||
return 0
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Dispatch
|
||||
// ============================================================
|
||||
|
||||
function printUsage() {
|
||||
println("Hopper - Package manager for TVDOS")
|
||||
println("")
|
||||
println("Usage:")
|
||||
println(" hopper {search,se} [--provides|--requires|--description|--author] <query>")
|
||||
println(" hopper {install,in} <package> [-v <version>]")
|
||||
println(" hopper {remove,rm} <package>")
|
||||
}
|
||||
|
||||
const _hopperArgs = (typeof exec_args !== "undefined" && exec_args) ? exec_args.slice(1) : []
|
||||
const _hopperCmd = _hopperArgs[0]
|
||||
const _hopperRest = _hopperArgs.slice(1)
|
||||
|
||||
switch (_hopperCmd) {
|
||||
case "search":
|
||||
case "se":
|
||||
return cmdSearch(_hopperRest)
|
||||
case "install":
|
||||
case "in":
|
||||
return cmdInstall(_hopperRest)
|
||||
case "remove":
|
||||
case "rm":
|
||||
return cmdRemove(_hopperRest)
|
||||
case undefined:
|
||||
printUsage()
|
||||
return 0
|
||||
default:
|
||||
printerrln(`Unknown command: ${_hopperCmd}`)
|
||||
printUsage()
|
||||
return 1
|
||||
}
|
||||
@@ -15,7 +15,10 @@ Uint16 Encoding
|
||||
10 00 : UTF-8
|
||||
10 01 : UTF-16BE
|
||||
10 02 : UTF-16LE
|
||||
Byte[5] Padding
|
||||
Byte Flags
|
||||
0b 0000 000r
|
||||
r: path is relative
|
||||
Bytes[4] Reserved
|
||||
|
||||
# FileBlocks
|
||||
Uint8 File type (only 1 is used)
|
||||
@@ -28,27 +31,36 @@ instead of compressing individual files)
|
||||
|
||||
function printUsage() {
|
||||
println(`Collects files under a directory into a single archive.
|
||||
Usage: lfs [-c/-x/-t] dest.lfs path\\to\\source
|
||||
Usage: lfs [-c/-x/-t] [-r] dest.lfs path\\to\\source
|
||||
To collect a directory into myarchive.lfs:
|
||||
lfs -c myarchive.lfs path\\to\\directory
|
||||
To collect a directory into myarchive.lfs, using relative path:
|
||||
lfs -c -r myarchive.lfs path\\to\\directory
|
||||
To extract an archive to path\\to\\my\\files:
|
||||
lfs -x myarchive.lfs path\\to\\my\\files
|
||||
To list the collected files:
|
||||
lfs -t myarchive.lfs`)
|
||||
}
|
||||
|
||||
let option = exec_args[1]
|
||||
const lfsPath = exec_args[2]
|
||||
const dirPath = exec_args[3]
|
||||
let option = undefined
|
||||
let useRelative = false
|
||||
const positional = []
|
||||
for (let i = 1; i < exec_args.length; i++) {
|
||||
const a = exec_args[i]
|
||||
if (a === undefined) continue
|
||||
const au = a.toUpperCase()
|
||||
if (au === "-C" || au === "-X" || au === "-T") option = au
|
||||
else if (au === "-R") useRelative = true
|
||||
else positional.push(a)
|
||||
}
|
||||
const lfsPath = positional[0]
|
||||
const dirPath = positional[1]
|
||||
|
||||
|
||||
if (option === undefined || lfsPath === undefined || option.toUpperCase() != "-T" && dirPath === undefined) {
|
||||
if (option === undefined || lfsPath === undefined || (option != "-T" && dirPath === undefined)) {
|
||||
printUsage()
|
||||
return 0
|
||||
}
|
||||
|
||||
option = option.toUpperCase()
|
||||
|
||||
|
||||
function recurseDir(file, action) {
|
||||
if (!file.isDirectory) {
|
||||
@@ -76,13 +88,14 @@ if ("-C" == option) {
|
||||
return 1
|
||||
}
|
||||
|
||||
let out = "TVDOSLFS\x01\x00\x00\x00\x00\x00\x00\x00"
|
||||
const flagsByte = useRelative ? 0x01 : 0x00
|
||||
let out = "TVDOSLFS\x01\x00\x00" + String.fromCharCode(flagsByte) + "\x00\x00\x00\x00"
|
||||
const rootDirPathLen = rootDir.fullPath.length
|
||||
|
||||
recurseDir(rootDir, file=>{
|
||||
let f = files.open(file.fullPath)
|
||||
let flen = f.size
|
||||
let fname = file.fullPath.substring(rootDirPathLen + 1)
|
||||
let fname = useRelative ? file.fullPath.substring(rootDirPathLen + 1) : file.fullPath
|
||||
let plen = fname.length
|
||||
|
||||
out += "\x01" + String.fromCharCode(
|
||||
@@ -116,6 +129,8 @@ else if ("-T" == option || "-X" == option) {
|
||||
return 2
|
||||
}
|
||||
|
||||
const archiveRelative = (bytes.charCodeAt(11) & 0x01) !== 0
|
||||
|
||||
if ("-X" == option && !rootDir.exists) {
|
||||
rootDir.mkDir()
|
||||
}
|
||||
@@ -132,9 +147,12 @@ else if ("-T" == option || "-X" == option) {
|
||||
|
||||
if ("-X" == option) {
|
||||
let filebytes = bytes.substring(curs, curs + filelen)
|
||||
let outfile = files.open(`${rootDir.fullPath}\\${path}`)
|
||||
// Fully qualified paths (e.g. "A:\foo\bar.txt") get their drive prefix
|
||||
// stripped so the archive contents re-root under the destination dir.
|
||||
let subPath = archiveRelative ? path : path.replace(/^[A-Za-z]:[\\\/]?/, "")
|
||||
let outfile = files.open(`${rootDir.fullPath}\\${subPath}`)
|
||||
|
||||
mkDirs(files.open(`${rootDir.driveLetter}:${files.open(`${rootDir.fullPath}\\${path}`).parentPath}`))
|
||||
mkDirs(files.open(`${outfile.driveLetter}:${outfile.parentPath}`))
|
||||
outfile.mkFile()
|
||||
outfile.swrite(filebytes)
|
||||
}
|
||||
|
||||
1
assets/disk0/tvdos/bin/microtone.alias
Normal file
1
assets/disk0/tvdos/bin/microtone.alias
Normal file
@@ -0,0 +1 @@
|
||||
taut $0
|
||||
@@ -1,209 +1,122 @@
|
||||
const SND_BASE_ADDR = audio.getBaseAddr()
|
||||
// playmp2 — MPEG-1/2 Audio Layer II player with the shared playgui visualiser.
|
||||
// Usage: playmp2 <file.mp2> [-i]
|
||||
|
||||
const SND_BASE_ADDR = audio.getBaseAddr()
|
||||
if (!SND_BASE_ADDR) return 10
|
||||
|
||||
const MP2_BITRATES = ["???", 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384]
|
||||
const MP2_BITRATES = ["???", 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384]
|
||||
const MP2_CHANNELMODES = ["Stereo", "Joint", "Dual", "Mono"]
|
||||
|
||||
const pcm = require("pcm")
|
||||
const interactive = exec_args[2] && exec_args[2].toLowerCase() == "-i"
|
||||
const interactive = exec_args[2] && exec_args[2].toLowerCase() === "-i"
|
||||
const gui = interactive ? require("playgui") : null
|
||||
|
||||
function printdbg(s) { if (0) serial.println(s) }
|
||||
|
||||
|
||||
class SequentialFileBuffer {
|
||||
|
||||
constructor(path, offset, length) {
|
||||
if (Array.isArray(path)) throw Error("arg #1 is path(string), not array")
|
||||
|
||||
this.path = path
|
||||
this.file = files.open(path)
|
||||
|
||||
this.offset = offset || 0
|
||||
this.originalOffset = offset
|
||||
this.length = length || this.file.size
|
||||
|
||||
this.seq = require("seqread")
|
||||
this.seq.prepare(path)
|
||||
}
|
||||
|
||||
readBytes(size, ptr) {
|
||||
return this.seq.readBytes(size, ptr)
|
||||
}
|
||||
|
||||
readStr(n) {
|
||||
let ptr = this.seq.readBytes(n)
|
||||
let s = ''
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (i >= this.length) break
|
||||
s += String.fromCharCode(sys.peek(ptr + i))
|
||||
}
|
||||
sys.free(ptr)
|
||||
return s
|
||||
}
|
||||
|
||||
unread(diff) {
|
||||
let newSkipLen = this.seq.getReadCount() - diff
|
||||
this.seq.prepare(this.path)
|
||||
this.seq.skip(newSkipLen)
|
||||
}
|
||||
|
||||
rewind() {
|
||||
this.seq.prepare(this.path)
|
||||
}
|
||||
|
||||
seek(p) {
|
||||
this.seq.prepare(this.path)
|
||||
this.seq.skip(p)
|
||||
}
|
||||
|
||||
get byteLength() {
|
||||
return this.length
|
||||
}
|
||||
|
||||
get fileHeader() {
|
||||
return this.seq.fileHeader
|
||||
}
|
||||
|
||||
/*get remaining() {
|
||||
return this.length - this.getReadCount()
|
||||
}*/
|
||||
readBytes(size, ptr) { return this.seq.readBytes(size, ptr) }
|
||||
get fileHeader() { return this.seq.fileHeader }
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
let filebuf = new SequentialFileBuffer(_G.shell.resolvePathInput(exec_args[1]).full)
|
||||
const FILE_SIZE = filebuf.length// - 100
|
||||
const FRAME_SIZE = audio.mp2GetInitialFrameSize(filebuf.fileHeader)
|
||||
const filebuf = new SequentialFileBuffer(_G.shell.resolvePathInput(exec_args[1]).full)
|
||||
const FILE_SIZE = filebuf.length
|
||||
const FRAME_SIZE = audio.mp2GetInitialFrameSize(filebuf.fileHeader)
|
||||
const MEDIA_BITRATE = MP2_BITRATES[filebuf.fileHeader[2] >>> 4]
|
||||
const MEDIA_CHANNEL_MODE = MP2_CHANNELMODES[filebuf.fileHeader[3] >>> 6]
|
||||
const MEDIA_CHANNEL = MP2_CHANNELMODES[filebuf.fileHeader[3] >>> 6]
|
||||
|
||||
// mediaDecodedBin sits at MMIO offset 64 in the audio peripheral and holds
|
||||
// 2304 bytes (1152 stereo u8 samples per MP2 frame). Peripheral memory grows
|
||||
// toward 0 so the canonical pointer is SND_BASE_ADDR - 64.
|
||||
//
|
||||
// IMPORTANT: single-byte sys.peek on this address hits AudioAdapter.peek()
|
||||
// which maps the lower offsets to sampleBin, not mediaDecodedBin (the
|
||||
// MMIO/Memory-Space split — see CLAUDE.md). To get the decoded PCM into the
|
||||
// visualiser, we sys.memcpy mediaDecodedBin → a RAM scratch buffer; memcpy
|
||||
// uses VM.getDev internally which DOES route the MMIO read correctly.
|
||||
//
|
||||
// VM.getDev's range check on mediaDecodedBin (relPtrInDev) is half-open and
|
||||
// won't let us copy the full 2304 bytes — we copy 2302 (one stereo sample
|
||||
// short of the frame, invisible at visualiser resolution).
|
||||
const MP2_DECODED_ADDR = SND_BASE_ADDR - 64
|
||||
const MP2_VIS_COPY_BYTES = 2302
|
||||
const MP2_VIS_SAMPLE_COUNT = MP2_VIS_COPY_BYTES >> 1 // 1151
|
||||
const mp2VisScratch = interactive ? sys.malloc(MP2_VIS_COPY_BYTES) : 0
|
||||
|
||||
let bytes_left = FILE_SIZE
|
||||
let bytes_left = FILE_SIZE
|
||||
let decodedLength = 0
|
||||
|
||||
|
||||
//serial.println(`Frame size: ${FRAME_SIZE}`)
|
||||
|
||||
|
||||
con.curs_set(0)
|
||||
let [__, CONSOLE_WIDTH] = con.getmaxyx()
|
||||
if (interactive) {
|
||||
let [cy, cx] = con.getyx()
|
||||
// file name
|
||||
con.mvaddch(cy, 1)
|
||||
con.prnch(0xC9);con.prnch(0xCD);con.prnch(0xB5)
|
||||
print(filebuf.file.name)
|
||||
con.prnch(0xC6);con.prnch(0xCD)
|
||||
print("\x84205u".repeat(CONSOLE_WIDTH - 26 - filebuf.file.name.length))
|
||||
con.prnch(0xB5)
|
||||
print("Hold Bksp to Exit")
|
||||
con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBB)
|
||||
|
||||
// L R pillar
|
||||
con.prnch(0xBA)
|
||||
con.mvaddch(cy+1, CONSOLE_WIDTH, 0xBA)
|
||||
|
||||
// media info
|
||||
let mediaInfoStr = `MP2 ${MEDIA_CHANNEL_MODE} ${MEDIA_BITRATE}kbps`
|
||||
con.move(cy+2,1)
|
||||
con.prnch(0xC8)
|
||||
print("\x84205u".repeat(CONSOLE_WIDTH - 5 - mediaInfoStr.length))
|
||||
con.prnch(0xB5)
|
||||
print(mediaInfoStr)
|
||||
con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBC)
|
||||
|
||||
con.move(cy+1, 2)
|
||||
}
|
||||
let [cy, cx] = con.getyx()
|
||||
let paintWidth = CONSOLE_WIDTH - 20
|
||||
function bytesToSec(i) {
|
||||
// using fixed value: FRAME_SIZE(216) bytes for 36 ms on sampling rate 32000 Hz
|
||||
return i / (FRAME_SIZE * 1000 / bufRealTimeLen)
|
||||
}
|
||||
function secToReadable(n) {
|
||||
let mins = ''+((n/60)|0)
|
||||
let secs = ''+(n % 60)
|
||||
return `${mins.padStart(2,'0')}:${secs.padStart(2,'0')}`
|
||||
}
|
||||
function printPlayBar(currently) {
|
||||
if (interactive) {
|
||||
let currently = decodedLength
|
||||
let total = FILE_SIZE
|
||||
|
||||
let currentlySec = Math.round(bytesToSec(currently))
|
||||
let totalSec = Math.round(bytesToSec(total))
|
||||
|
||||
con.move(cy, 3)
|
||||
print(' '.repeat(15))
|
||||
con.move(cy, 3)
|
||||
|
||||
print(`${secToReadable(currentlySec)} / ${secToReadable(totalSec)}`)
|
||||
|
||||
con.move(cy, 17)
|
||||
print(' ')
|
||||
let progressbar = '\x84196u'.repeat(paintWidth + 1)
|
||||
print(progressbar)
|
||||
|
||||
con.mvaddch(cy, 18 + Math.round(paintWidth * (currently / total)), 0xDB)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const bufRealTimeLen = 36 // one MP2 frame at 32 kHz ≈ 36 ms
|
||||
|
||||
audio.resetParams(0)
|
||||
audio.purgeQueue(0)
|
||||
audio.setPcmMode(0)
|
||||
audio.setPcmQueueCapacityIndex(0, 2) // queue size is now 8
|
||||
audio.setPcmQueueCapacityIndex(0, 2)
|
||||
const QUEUE_MAX = audio.getPcmQueueCapacity(0)
|
||||
audio.setMasterVolume(0, 255)
|
||||
audio.play(0)
|
||||
|
||||
|
||||
//let mp2context = audio.mp2Init()
|
||||
audio.mp2Init()
|
||||
|
||||
// decode frame
|
||||
let t1 = sys.nanoTime()
|
||||
let bufRealTimeLen = 36
|
||||
function bytesToSec(i) { return i / (FRAME_SIZE * 1000 / bufRealTimeLen) }
|
||||
|
||||
if (interactive) {
|
||||
const tag = "MP2"
|
||||
const title = `${filebuf.file.name} ${MEDIA_CHANNEL} ${MEDIA_BITRATE}kbps`
|
||||
gui.audioInit({ title, tag })
|
||||
}
|
||||
|
||||
let stopPlay = false
|
||||
let errorlevel = 0
|
||||
try {
|
||||
while (bytes_left > 0 && !stopPlay) {
|
||||
|
||||
if (interactive) {
|
||||
sys.poke(-40, 1)
|
||||
if (sys.peek(-41) == 67) {
|
||||
stopPlay = true
|
||||
}
|
||||
}
|
||||
|
||||
printPlayBar()
|
||||
|
||||
if (interactive && gui.audioIsExitRequested()) { stopPlay = true; break }
|
||||
|
||||
filebuf.readBytes(FRAME_SIZE, SND_BASE_ADDR - 2368)
|
||||
audio.mp2Decode()
|
||||
|
||||
// After decode, 1152 PCMu8 stereo samples sit in mediaDecodedBin
|
||||
// (MMIO). Bounce them through RAM so single-byte peek in the
|
||||
// visualiser pipeline can reach them — see MP2_DECODED_ADDR notes.
|
||||
if (interactive) {
|
||||
sys.memcpy(MP2_DECODED_ADDR, mp2VisScratch, MP2_VIS_COPY_BYTES)
|
||||
gui.audioFeedPcm(mp2VisScratch, MP2_VIS_SAMPLE_COUNT)
|
||||
}
|
||||
|
||||
if (audio.getPosition(0) >= QUEUE_MAX) {
|
||||
while (audio.getPosition(0) >= (QUEUE_MAX >>> 1)) {
|
||||
printdbg(`Queue full, waiting until the queue has some space (${audio.getPosition(0)}/${QUEUE_MAX})`)
|
||||
if (interactive) gui.audioRender()
|
||||
sys.sleep(bufRealTimeLen)
|
||||
}
|
||||
}
|
||||
audio.mp2UploadDecoded(0)
|
||||
|
||||
if (interactive) {
|
||||
gui.audioSetProgress(decodedLength / FILE_SIZE,
|
||||
bytesToSec(decodedLength), bytesToSec(FILE_SIZE))
|
||||
gui.audioRender()
|
||||
}
|
||||
sys.sleep(10)
|
||||
|
||||
|
||||
|
||||
bytes_left -= FRAME_SIZE
|
||||
bytes_left -= FRAME_SIZE
|
||||
decodedLength += FRAME_SIZE
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
} catch (e) {
|
||||
printerrln(e)
|
||||
errorlevel = 1
|
||||
}
|
||||
finally {
|
||||
} finally {
|
||||
if (interactive) {
|
||||
if (mp2VisScratch) sys.free(mp2VisScratch)
|
||||
gui.audioClose()
|
||||
}
|
||||
}
|
||||
|
||||
return errorlevel
|
||||
return errorlevel
|
||||
|
||||
@@ -326,7 +326,7 @@ while (!stopPlay && seqread.getReadCount() < FILE_LENGTH) {
|
||||
// RAW PCM packets (decode on the fly)
|
||||
else if (packetType == 0x1000 || packetType == 0x1001) {
|
||||
let frame = seqread.readBytes(readLength)
|
||||
audio.putPcmDataByPtr(frame, readLength, 0)
|
||||
audio.putPcmDataByPtr(0, frame, readLength, 0)
|
||||
audio.setSampleUploadLength(0, readLength)
|
||||
audio.startSampleUpload(0)
|
||||
sys.free(frame)
|
||||
|
||||
@@ -1,196 +1,81 @@
|
||||
// usage: playpcm audiofile.pcm [/i]
|
||||
let fileeeee = files.open(_G.shell.resolvePathInput(exec_args[1]).full)
|
||||
let filename = fileeeee.fullPath
|
||||
function printdbg(s) { if (0) serial.println(s) }
|
||||
// playpcm — raw PCMu8 stereo player with the shared playgui visualiser.
|
||||
// Usage: playpcm <file.pcm> [-i]
|
||||
|
||||
const interactive = exec_args[2] && exec_args[2].toLowerCase() == "-i"
|
||||
const pcm = require("pcm")
|
||||
const FILE_SIZE = files.open(filename).size
|
||||
|
||||
|
||||
|
||||
function printComments() {
|
||||
for (const [key, value] of Object.entries(comments)) {
|
||||
printdbg(`${key}: ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
function GCD(a, b) {
|
||||
a = Math.abs(a)
|
||||
b = Math.abs(b)
|
||||
if (b > a) {var temp = a; a = b; b = temp}
|
||||
while (true) {
|
||||
if (b == 0) return a
|
||||
a %= b
|
||||
if (a == 0) return b
|
||||
b %= a
|
||||
}
|
||||
}
|
||||
|
||||
function LCM(a, b) {
|
||||
return (!a || !b) ? 0 : Math.abs((a * b) / GCD(a, b))
|
||||
}
|
||||
|
||||
|
||||
|
||||
//println("Reading...")
|
||||
//serial.println("!!! READING")
|
||||
const fileHandle = files.open(_G.shell.resolvePathInput(exec_args[1]).full)
|
||||
const filePath = fileHandle.fullPath
|
||||
|
||||
const interactive = exec_args[2] && exec_args[2].toLowerCase() === "-i"
|
||||
const pcm = require("pcm")
|
||||
const seqread = require("seqread")
|
||||
seqread.prepare(filename)
|
||||
|
||||
|
||||
|
||||
|
||||
const gui = interactive ? require("playgui") : null
|
||||
|
||||
const FILE_SIZE = files.open(filePath).size
|
||||
|
||||
let BLOCK_SIZE = 4096
|
||||
let INFILE_BLOCK_SIZE = BLOCK_SIZE
|
||||
const QUEUE_MAX = 8 // according to the spec
|
||||
const INFILE_BLOCK_SIZE = BLOCK_SIZE
|
||||
const QUEUE_MAX = 8
|
||||
|
||||
let nChannels = 2
|
||||
let samplingRate = pcm.HW_SAMPLING_RATE;
|
||||
let blockSize = 2;
|
||||
let bitsPerSample = 8;
|
||||
let byterate = 2*samplingRate;
|
||||
let comments = {};
|
||||
let readPtr = undefined
|
||||
let decodePtr = undefined
|
||||
const samplingRate = pcm.HW_SAMPLING_RATE
|
||||
const byterate = 2 * samplingRate
|
||||
|
||||
function bytesToSec(i) {
|
||||
return i / byterate
|
||||
}
|
||||
function secToReadable(n) {
|
||||
let mins = ''+((n/60)|0)
|
||||
let secs = ''+(n % 60)
|
||||
return `${mins.padStart(2,'0')}:${secs.padStart(2,'0')}`
|
||||
}
|
||||
|
||||
let stopPlay = false
|
||||
con.curs_set(0)
|
||||
let [__, CONSOLE_WIDTH] = con.getmaxyx()
|
||||
if (interactive) {
|
||||
let [cy, cx] = con.getyx()
|
||||
// file name
|
||||
con.mvaddch(cy, 1)
|
||||
con.prnch(0xC9);con.prnch(0xCD);con.prnch(0xB5)
|
||||
print(fileeeee.name)
|
||||
con.prnch(0xC6);con.prnch(0xCD)
|
||||
print("\x84205u".repeat(CONSOLE_WIDTH - 26 - fileeeee.name.length))
|
||||
con.prnch(0xB5)
|
||||
print("Hold Bksp to Exit")
|
||||
con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBB)
|
||||
|
||||
// L R pillar
|
||||
con.prnch(0xBA)
|
||||
con.mvaddch(cy+1, CONSOLE_WIDTH, 0xBA)
|
||||
|
||||
// media info
|
||||
let mediaInfoStr = `Raw PCM 512kbps`
|
||||
con.move(cy+2,1)
|
||||
con.prnch(0xC8)
|
||||
print("\x84205u".repeat(CONSOLE_WIDTH - 5 - mediaInfoStr.length))
|
||||
con.prnch(0xB5)
|
||||
print(mediaInfoStr)
|
||||
con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBC)
|
||||
|
||||
con.move(cy+1, 2)
|
||||
}
|
||||
let [cy, cx] = con.getyx()
|
||||
let paintWidth = CONSOLE_WIDTH - 20
|
||||
// read chunks loop
|
||||
readPtr = sys.malloc(BLOCK_SIZE * bitsPerSample / 8)
|
||||
decodePtr = sys.malloc(BLOCK_SIZE * pcm.HW_SAMPLING_RATE / samplingRate)
|
||||
function bytesToSec(i) { return i / byterate }
|
||||
|
||||
seqread.prepare(filePath)
|
||||
|
||||
const readPtr = sys.malloc(BLOCK_SIZE)
|
||||
audio.resetParams(0)
|
||||
audio.purgeQueue(0)
|
||||
audio.setPcmMode(0)
|
||||
audio.setMasterVolume(0, 255)
|
||||
|
||||
let readLength = 1
|
||||
|
||||
function printPlayBar() {
|
||||
if (interactive) {
|
||||
let currently = seqread.getReadCount()
|
||||
let total = FILE_SIZE
|
||||
|
||||
let currentlySec = Math.round(bytesToSec(currently))
|
||||
let totalSec = Math.round(bytesToSec(total))
|
||||
|
||||
con.move(cy, 3)
|
||||
print(' '.repeat(15))
|
||||
con.move(cy, 3)
|
||||
|
||||
print(`${secToReadable(currentlySec)} / ${secToReadable(totalSec)}`)
|
||||
|
||||
con.move(cy, 17)
|
||||
print(' ')
|
||||
let progressbar = '\x84196u'.repeat(paintWidth + 1)
|
||||
print(progressbar)
|
||||
|
||||
con.mvaddch(cy, 18 + Math.round(paintWidth * (currently / total)), 0xDB)
|
||||
}
|
||||
if (interactive) {
|
||||
gui.audioInit({
|
||||
title: `${fileHandle.name} Raw PCM 32kHz Stereo`,
|
||||
tag: "PCM"
|
||||
})
|
||||
}
|
||||
|
||||
let stopPlay = false
|
||||
let errorlevel = 0
|
||||
let readLength = 1
|
||||
try {
|
||||
while (!stopPlay && seqread.getReadCount() < FILE_SIZE && readLength > 0) {
|
||||
if (interactive) {
|
||||
sys.poke(-40, 1)
|
||||
if (sys.peek(-41) == 67) {
|
||||
stopPlay = true
|
||||
}
|
||||
}
|
||||
while (!stopPlay && seqread.getReadCount() < FILE_SIZE && readLength > 0) {
|
||||
if (interactive && gui.audioIsExitRequested()) { stopPlay = true; break }
|
||||
|
||||
const queueSize = audio.getPosition(0)
|
||||
if (queueSize <= 1) {
|
||||
for (let repeat = QUEUE_MAX - queueSize; repeat > 0; repeat--) {
|
||||
const remainingBytes = FILE_SIZE - seqread.getReadCount()
|
||||
readLength = (remainingBytes < INFILE_BLOCK_SIZE) ? remainingBytes : INFILE_BLOCK_SIZE
|
||||
if (readLength <= 0) break
|
||||
|
||||
let queueSize = audio.getPosition(0)
|
||||
if (queueSize <= 1) {
|
||||
seqread.readBytes(readLength, readPtr)
|
||||
|
||||
printPlayBar()
|
||||
// Raw PCMu8 stereo — sampleCount = bytes / 2.
|
||||
if (interactive) gui.audioFeedPcm(readPtr, readLength >> 1)
|
||||
|
||||
// upload four samples for lag-safely
|
||||
for (let repeat = QUEUE_MAX - queueSize; repeat > 0; repeat--) {
|
||||
let remainingBytes = FILE_SIZE - seqread.getReadCount()
|
||||
audio.putPcmDataByPtr(0, readPtr, readLength, 0)
|
||||
audio.setSampleUploadLength(0, readLength)
|
||||
audio.startSampleUpload(0)
|
||||
|
||||
readLength = (remainingBytes < INFILE_BLOCK_SIZE) ? remainingBytes : INFILE_BLOCK_SIZE
|
||||
if (readLength <= 0) {
|
||||
printdbg(`readLength = ${readLength}`)
|
||||
break
|
||||
if (repeat > 1) sys.sleep(10)
|
||||
}
|
||||
|
||||
printdbg(`offset: ${seqread.getReadCount()}/${FILE_SIZE}; readLength: ${readLength}`)
|
||||
|
||||
seqread.readBytes(readLength, readPtr)
|
||||
|
||||
audio.putPcmDataByPtr(readPtr, readLength, 0)
|
||||
audio.setSampleUploadLength(0, readLength)
|
||||
audio.startSampleUpload(0)
|
||||
|
||||
|
||||
if (repeat > 1) sys.sleep(10)
|
||||
|
||||
printPlayBar()
|
||||
audio.play(0)
|
||||
}
|
||||
|
||||
audio.play(0)
|
||||
if (interactive) {
|
||||
const cur = seqread.getReadCount()
|
||||
gui.audioSetProgress(cur / FILE_SIZE, bytesToSec(cur), bytesToSec(FILE_SIZE))
|
||||
gui.audioRender()
|
||||
}
|
||||
sys.sleep(10)
|
||||
}
|
||||
|
||||
let remainingBytes = FILE_SIZE - seqread.getReadCount()
|
||||
printdbg(`readLength = ${readLength}; remainingBytes2 = ${remainingBytes}; seqread.getReadCount() = ${seqread.getReadCount()};`)
|
||||
|
||||
|
||||
sys.sleep(10)
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
} catch (e) {
|
||||
printerrln(e)
|
||||
errorlevel = 1
|
||||
}
|
||||
finally {
|
||||
//audio.stop(0)
|
||||
} finally {
|
||||
if (readPtr !== undefined) sys.free(readPtr)
|
||||
if (decodePtr !== undefined) sys.free(decodePtr)
|
||||
if (interactive) gui.audioClose()
|
||||
}
|
||||
|
||||
return errorlevel
|
||||
|
||||
|
||||
@@ -1,112 +1,66 @@
|
||||
// playtad — TAD (TSVM Advanced Audio) player with the shared playgui visualiser.
|
||||
// Usage: playtad <file.tad> [-i | -d]
|
||||
// -i Interactive mode (visualiser + progress bar; hold Backspace to exit)
|
||||
// -d Dump mode (print the first three chunks to serial for debugging)
|
||||
|
||||
const SND_BASE_ADDR = audio.getBaseAddr()
|
||||
const SND_MEM_ADDR = audio.getMemAddr()
|
||||
const TAD_INPUT_ADDR = SND_MEM_ADDR - 262144 // TAD input buffer (matches TAV packet 0x24)
|
||||
const TAD_DECODED_ADDR = SND_MEM_ADDR - 262144 + 65536 // TAD decoded buffer
|
||||
const SND_MEM_ADDR = audio.getMemAddr()
|
||||
// tadInputBin at offset 917504, tadDecodedBin at 983040. Both addressed via
|
||||
// negative pointers — peripheral memory grows toward 0.
|
||||
const TAD_INPUT_ADDR = SND_MEM_ADDR - 917504
|
||||
const TAD_DECODED_ADDR = SND_MEM_ADDR - 983040
|
||||
|
||||
if (!SND_BASE_ADDR) return 10
|
||||
|
||||
// Check for help flag or missing arguments
|
||||
if (!exec_args[1] || exec_args[1] == "-h" || exec_args[1] == "--help") {
|
||||
serial.println("Usage: playtad <file.tad> [-i | -d] [quality]")
|
||||
serial.println(" -i Interactive mode (progress bar, press Backspace to exit)")
|
||||
serial.println(" -d Dump mode (show first 3 chunks with payload hex and decoded samples)")
|
||||
serial.println("")
|
||||
serial.println("Examples:")
|
||||
serial.println(" playtad audio.tad -i # Play with progress bar")
|
||||
serial.println(" playtad audio.tad -d # Dump first 3 chunks for debugging")
|
||||
if (!exec_args[1] || exec_args[1] === "-h" || exec_args[1] === "--help") {
|
||||
serial.println("Usage: playtad <file.tad> [-i | -d]")
|
||||
serial.println(" -i Interactive mode (visualiser + progress bar)")
|
||||
serial.println(" -d Dump first three chunks for debugging")
|
||||
return 0
|
||||
}
|
||||
|
||||
const pcm = require("pcm")
|
||||
const interactive = exec_args[2] && exec_args[2].toLowerCase() == "-i"
|
||||
const dumpCoeffs = exec_args[2] && exec_args[2].toLowerCase() == "-d"
|
||||
|
||||
function printdbg(s) { if (0) serial.println(s) }
|
||||
|
||||
const interactive = exec_args[2] && exec_args[2].toLowerCase() === "-i"
|
||||
const dumpCoeffs = exec_args[2] && exec_args[2].toLowerCase() === "-d"
|
||||
const gui = interactive ? require("playgui") : null
|
||||
|
||||
class SequentialFileBuffer {
|
||||
|
||||
constructor(path, offset, length) {
|
||||
constructor(path) {
|
||||
if (Array.isArray(path)) throw Error("arg #1 is path(string), not array")
|
||||
|
||||
this.path = path
|
||||
this.file = files.open(path)
|
||||
|
||||
this.offset = offset || 0
|
||||
this.originalOffset = offset
|
||||
this.length = length || this.file.size
|
||||
|
||||
this.length = this.file.size
|
||||
this.seq = require("seqread")
|
||||
this.seq.prepare(path)
|
||||
}
|
||||
|
||||
readBytes(size, ptr) {
|
||||
return this.seq.readBytes(size, ptr)
|
||||
}
|
||||
|
||||
readBytes(size, ptr) { return this.seq.readBytes(size, ptr) }
|
||||
readByte() {
|
||||
let ptr = this.seq.readBytes(1)
|
||||
let val = sys.peek(ptr)
|
||||
const ptr = this.seq.readBytes(1)
|
||||
const val = sys.peek(ptr)
|
||||
sys.free(ptr)
|
||||
return val
|
||||
}
|
||||
|
||||
readShort() {
|
||||
let ptr = this.seq.readBytes(2)
|
||||
let val = sys.peek(ptr) | (sys.peek(ptr + 1) << 8)
|
||||
const ptr = this.seq.readBytes(2)
|
||||
const val = sys.peek(ptr) | (sys.peek(ptr + 1) << 8)
|
||||
sys.free(ptr)
|
||||
return val
|
||||
}
|
||||
|
||||
readInt() {
|
||||
let ptr = this.seq.readBytes(4)
|
||||
let val = sys.peek(ptr) | (sys.peek(ptr + 1) << 8) | (sys.peek(ptr + 2) << 16) | (sys.peek(ptr + 3) << 24)
|
||||
const ptr = this.seq.readBytes(4)
|
||||
const val = sys.peek(ptr) | (sys.peek(ptr + 1) << 8) | (sys.peek(ptr + 2) << 16) | (sys.peek(ptr + 3) << 24)
|
||||
sys.free(ptr)
|
||||
return val
|
||||
}
|
||||
|
||||
readStr(n) {
|
||||
let ptr = this.seq.readBytes(n)
|
||||
let s = ''
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (i >= this.length) break
|
||||
s += String.fromCharCode(sys.peek(ptr + i))
|
||||
}
|
||||
sys.free(ptr)
|
||||
return s
|
||||
}
|
||||
|
||||
unread(diff) {
|
||||
let newSkipLen = this.seq.getReadCount() - diff
|
||||
const newSkipLen = this.seq.getReadCount() - diff
|
||||
this.seq.prepare(this.path)
|
||||
this.seq.skip(newSkipLen)
|
||||
}
|
||||
|
||||
rewind() {
|
||||
this.seq.prepare(this.path)
|
||||
}
|
||||
|
||||
seek(p) {
|
||||
this.seq.prepare(this.path)
|
||||
this.seq.skip(p)
|
||||
}
|
||||
|
||||
get byteLength() {
|
||||
return this.length
|
||||
}
|
||||
|
||||
get fileHeader() {
|
||||
return this.seq.fileHeader
|
||||
}
|
||||
|
||||
getReadCount() {
|
||||
return this.seq.getReadCount()
|
||||
}
|
||||
rewind() { this.seq.prepare(this.path) }
|
||||
getReadCount() { return this.seq.getReadCount() }
|
||||
}
|
||||
|
||||
|
||||
// Read TAD chunk header to determine format
|
||||
let filebuf = new SequentialFileBuffer(_G.shell.resolvePathInput(exec_args[1]).full)
|
||||
const filebuf = new SequentialFileBuffer(_G.shell.resolvePathInput(exec_args[1]).full)
|
||||
const FILE_SIZE = filebuf.length
|
||||
|
||||
if (FILE_SIZE < 7) {
|
||||
@@ -114,12 +68,12 @@ if (FILE_SIZE < 7) {
|
||||
return 1
|
||||
}
|
||||
|
||||
// Read first chunk header (standalone TAD format: no TAV wrapper)
|
||||
let firstSampleCount = filebuf.readShort()
|
||||
let firstMaxIndex = filebuf.readByte()
|
||||
let firstPayloadSize = filebuf.readInt()
|
||||
// Peek the first chunk header so we know the chunk size for the rough bytes-
|
||||
// to-seconds conversion shown in the progress bar.
|
||||
const firstSampleCount = filebuf.readShort()
|
||||
const firstMaxIndex = filebuf.readByte()
|
||||
const firstPayloadSize = filebuf.readInt()
|
||||
|
||||
// Validate first chunk
|
||||
if (firstSampleCount < 0 || firstSampleCount > 65536) {
|
||||
serial.println(`ERROR: Invalid sample count ${firstSampleCount}. File may be corrupted.`)
|
||||
return 1
|
||||
@@ -133,148 +87,68 @@ if (firstPayloadSize < 1 || firstPayloadSize > 65536) {
|
||||
return 1
|
||||
}
|
||||
|
||||
// Rewind to start
|
||||
filebuf.rewind()
|
||||
|
||||
// Calculate approximate frame info
|
||||
const AVG_CHUNK_SIZE = 7 + firstPayloadSize // TAD header (2+1+4) + payload
|
||||
const SAMPLE_RATE = 32000
|
||||
const bufRealTimeLen = Math.floor((firstSampleCount / SAMPLE_RATE) * 1000) // milliseconds per chunk
|
||||
const AVG_CHUNK_SIZE = 7 + firstPayloadSize
|
||||
const SAMPLE_RATE = 32000
|
||||
const bufRealTimeLen = Math.floor((firstSampleCount / SAMPLE_RATE) * 1000)
|
||||
|
||||
if (dumpCoeffs) {
|
||||
serial.println(`TAD Coefficient Dump Mode`)
|
||||
serial.println(`File: ${filebuf.file.name}`)
|
||||
serial.println(`First chunk header:`)
|
||||
serial.println(` Sample Count: ${firstSampleCount}`)
|
||||
serial.println(` Max Index: ${firstMaxIndex}`)
|
||||
serial.println(` Payload Size: ${firstPayloadSize} bytes`)
|
||||
serial.println(`First chunk: ${firstSampleCount} samples, Q${firstMaxIndex}, ${firstPayloadSize} bytes payload`)
|
||||
serial.println(`Chunk Duration: ${bufRealTimeLen} ms`)
|
||||
serial.println(``)
|
||||
}
|
||||
|
||||
|
||||
let bytes_left = FILE_SIZE
|
||||
let bytes_left = FILE_SIZE
|
||||
let decodedLength = 0
|
||||
let chunkNumber = 0
|
||||
|
||||
|
||||
con.curs_set(0)
|
||||
let [__, CONSOLE_WIDTH] = con.getmaxyx()
|
||||
if (interactive) {
|
||||
let [cy, cx] = con.getyx()
|
||||
// file name
|
||||
con.mvaddch(cy, 1)
|
||||
con.prnch(0xC9);con.prnch(0xCD);con.prnch(0xB5)
|
||||
print(filebuf.file.name)
|
||||
con.prnch(0xC6);con.prnch(0xCD)
|
||||
print("\x84205u".repeat(CONSOLE_WIDTH - 26 - filebuf.file.name.length))
|
||||
con.prnch(0xB5)
|
||||
print("Hold Bksp to Exit")
|
||||
con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBB)
|
||||
|
||||
// L R pillar
|
||||
con.prnch(0xBA)
|
||||
con.mvaddch(cy+1, CONSOLE_WIDTH, 0xBA)
|
||||
|
||||
// media info
|
||||
let mediaInfoStr = `TAD Q${firstMaxIndex} ${SAMPLE_RATE/1000}kHz`
|
||||
con.move(cy+2,1)
|
||||
con.prnch(0xC8)
|
||||
print("\x84205u".repeat(CONSOLE_WIDTH - 5 - mediaInfoStr.length))
|
||||
con.prnch(0xB5)
|
||||
print(mediaInfoStr)
|
||||
con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBC)
|
||||
|
||||
con.move(cy+1, 2)
|
||||
}
|
||||
let [cy, cx] = con.getyx()
|
||||
let paintWidth = CONSOLE_WIDTH - 20
|
||||
let chunkNumber = 0
|
||||
|
||||
function bytesToSec(i) {
|
||||
// Approximate: use first chunk's ratio
|
||||
return Math.round((i / FILE_SIZE) * (FILE_SIZE / AVG_CHUNK_SIZE) * (bufRealTimeLen / 1000))
|
||||
}
|
||||
|
||||
function secToReadable(n) {
|
||||
let mins = ''+((n/60)|0)
|
||||
let secs = ''+(n % 60)
|
||||
return `${mins.padStart(2,'0')}:${secs.padStart(2,'0')}`
|
||||
}
|
||||
|
||||
function printPlayBar() {
|
||||
if (interactive) {
|
||||
let currently = decodedLength
|
||||
let total = FILE_SIZE
|
||||
|
||||
let currentlySec = bytesToSec(currently)
|
||||
let totalSec = bytesToSec(total)
|
||||
|
||||
con.move(cy, 3)
|
||||
print(' '.repeat(15))
|
||||
con.move(cy, 3)
|
||||
|
||||
print(`${secToReadable(currentlySec)} / ${secToReadable(totalSec)}`)
|
||||
|
||||
con.move(cy, 17)
|
||||
print(' ')
|
||||
let progressbar = '\x84196u'.repeat(paintWidth + 1)
|
||||
print(progressbar)
|
||||
|
||||
con.mvaddch(cy, 18 + Math.round(paintWidth * (currently / total)), 0xDB)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
audio.resetParams(0)
|
||||
audio.purgeQueue(0)
|
||||
audio.setPcmMode(0)
|
||||
audio.setPcmQueueCapacityIndex(0, 2) // queue size is now 8
|
||||
audio.setPcmQueueCapacityIndex(0, 2)
|
||||
const QUEUE_MAX = audio.getPcmQueueCapacity(0)
|
||||
audio.setMasterVolume(0, 255)
|
||||
audio.play(0)
|
||||
|
||||
if (interactive) {
|
||||
gui.audioInit({
|
||||
title: `${filebuf.file.name} TAD Q${firstMaxIndex} ${SAMPLE_RATE/1000}kHz`,
|
||||
tag: "TAD"
|
||||
})
|
||||
}
|
||||
|
||||
let stopPlay = false
|
||||
let errorlevel = 0
|
||||
|
||||
try {
|
||||
while (bytes_left > 0 && !stopPlay) {
|
||||
if (interactive && gui.audioIsExitRequested()) { stopPlay = true; break }
|
||||
|
||||
if (interactive) {
|
||||
sys.poke(-40, 1)
|
||||
if (sys.peek(-41) == 67) { // Backspace key
|
||||
stopPlay = true
|
||||
}
|
||||
}
|
||||
const sampleCount = filebuf.readShort()
|
||||
const maxIndex = filebuf.readByte()
|
||||
const payloadSize = filebuf.readInt()
|
||||
|
||||
printPlayBar()
|
||||
|
||||
// Read TAD chunk header (standalone TAD format)
|
||||
// Format: [sample_count][max_index][payload_size][payload]
|
||||
let sampleCount = filebuf.readShort()
|
||||
let maxIndex = filebuf.readByte()
|
||||
let payloadSize = filebuf.readInt()
|
||||
|
||||
// Validate every chunk (not just first one)
|
||||
if (sampleCount < 0 || sampleCount > 65536) {
|
||||
serial.println(`ERROR: Chunk ${chunkNumber}: Invalid sample count ${sampleCount}. File may be corrupted.`)
|
||||
errorlevel = 1
|
||||
break
|
||||
serial.println(`ERROR: Chunk ${chunkNumber}: Invalid sample count ${sampleCount}.`)
|
||||
errorlevel = 1; break
|
||||
}
|
||||
if (maxIndex < 0 || maxIndex > 255) {
|
||||
serial.println(`ERROR: Chunk ${chunkNumber}: Invalid max index ${maxIndex}. File may be corrupted.`)
|
||||
errorlevel = 1
|
||||
break
|
||||
serial.println(`ERROR: Chunk ${chunkNumber}: Invalid max index ${maxIndex}.`)
|
||||
errorlevel = 1; break
|
||||
}
|
||||
if (payloadSize < 1 || payloadSize > 65536) {
|
||||
serial.println(`ERROR: Chunk ${chunkNumber}: Invalid payload size ${payloadSize}. File may be corrupted.`)
|
||||
errorlevel = 1
|
||||
break
|
||||
serial.println(`ERROR: Chunk ${chunkNumber}: Invalid payload size ${payloadSize}.`)
|
||||
errorlevel = 1; break
|
||||
}
|
||||
if (payloadSize + 7 > bytes_left) {
|
||||
serial.println(`ERROR: Chunk ${chunkNumber}: Chunk size ${payloadSize + 7} exceeds remaining file size ${bytes_left}`)
|
||||
errorlevel = 1
|
||||
break
|
||||
serial.println(`ERROR: Chunk ${chunkNumber}: Chunk size exceeds remaining file size.`)
|
||||
errorlevel = 1; break
|
||||
}
|
||||
|
||||
if (dumpCoeffs && chunkNumber < 3) {
|
||||
@@ -282,80 +156,59 @@ try {
|
||||
serial.println(` Sample Count: ${sampleCount}`)
|
||||
serial.println(` Max Index: ${maxIndex}`)
|
||||
serial.println(` Payload Size: ${payloadSize} bytes`)
|
||||
serial.println(` Bytes remaining in file: ${bytes_left}`)
|
||||
}
|
||||
|
||||
// Rewind 7 bytes to re-read the header along with payload
|
||||
// This allows reading the complete chunk (header + payload) in one call
|
||||
// Read entire chunk (header + payload) into TAD input buffer.
|
||||
filebuf.unread(7)
|
||||
filebuf.readBytes(7 + payloadSize, TAD_INPUT_ADDR)
|
||||
|
||||
// Read entire chunk (header + payload) to TAD input buffer
|
||||
// This matches TAV's approach for packet 0x24
|
||||
let totalChunkSize = 7 + payloadSize
|
||||
filebuf.readBytes(totalChunkSize, TAD_INPUT_ADDR)
|
||||
|
||||
if (dumpCoeffs && chunkNumber < 3) {
|
||||
// Dump first 32 bytes of compressed payload (skip 7-byte header)
|
||||
serial.print(` Compressed data (first 32 bytes): `)
|
||||
for (let i = 0; i < Math.min(32, payloadSize); i++) {
|
||||
let b = sys.peek(TAD_INPUT_ADDR + 7 + i)
|
||||
serial.print(`${(b & 0xFF).toString(16).padStart(2, '0')} `)
|
||||
}
|
||||
serial.println('')
|
||||
}
|
||||
|
||||
// Decode TAD chunk
|
||||
audio.tadDecode()
|
||||
|
||||
if (dumpCoeffs && chunkNumber < 3) {
|
||||
// After decoding, the decoded PCMu8 samples are in tadDecodedBin
|
||||
serial.println(` Decoded ${sampleCount} samples`)
|
||||
|
||||
// Dump first 16 decoded samples (PCMu8 stereo interleaved)
|
||||
serial.print(` Decoded (first 16 L samples): `)
|
||||
for (let i = 0; i < 16; i++) {
|
||||
serial.print(`${sys.peek(TAD_DECODED_ADDR + i * 2) & 0xFF} `)
|
||||
}
|
||||
serial.println('')
|
||||
serial.print(` Decoded (first 16 R samples): `)
|
||||
for (let i = 0; i < 16; i++) {
|
||||
serial.print(`${sys.peek(TAD_DECODED_ADDR + i * 2 + 1) & 0xFF} `)
|
||||
}
|
||||
serial.println('')
|
||||
serial.println('')
|
||||
}
|
||||
|
||||
// Upload decoded audio to queue
|
||||
audio.tadUploadDecoded(0, sampleCount)
|
||||
// After upload tadDecodedBin still holds the chunk until the next
|
||||
// tadDecode call, so it's safe to keep slicing samples out of it
|
||||
// during the playback wait below.
|
||||
|
||||
if (!dumpCoeffs) {
|
||||
// Sleep for the duration of the audio chunk to pace playback
|
||||
// This prevents uploading everything at once
|
||||
sys.sleep(bufRealTimeLen)
|
||||
// TAD chunks are typically 1 s long, so feeding the visualiser
|
||||
// once would freeze it for ~1 s. Walk the chunk in 2048-sample
|
||||
// slices (~64 ms each at 32 kHz) so the wavescope and XY-scope
|
||||
// stay in step with what the audio engine is actually playing.
|
||||
const chunkMs = Math.floor((sampleCount / SAMPLE_RATE) * 1000)
|
||||
const TAD_VIS_SLICE = 2048
|
||||
if (interactive) {
|
||||
gui.audioSetProgress(decodedLength / FILE_SIZE,
|
||||
bytesToSec(decodedLength), bytesToSec(FILE_SIZE))
|
||||
let sliceOff = 0
|
||||
while (sliceOff < sampleCount && !stopPlay) {
|
||||
if (gui.audioIsExitRequested()) { stopPlay = true; break }
|
||||
const sliceN = Math.min(TAD_VIS_SLICE, sampleCount - sliceOff)
|
||||
// tadDecodedBin is negative-addressed: sample i sits at
|
||||
// TAD_DECODED_ADDR - i*2. audioFeedPcm flips the read
|
||||
// direction for negative ptrs internally.
|
||||
gui.audioFeedPcm(TAD_DECODED_ADDR - sliceOff * 2, sliceN)
|
||||
gui.audioRender()
|
||||
sys.sleep(Math.floor((sliceN / SAMPLE_RATE) * 1000))
|
||||
sliceOff += sliceN
|
||||
}
|
||||
} else {
|
||||
sys.sleep(chunkMs)
|
||||
}
|
||||
}
|
||||
|
||||
// Chunk size = header (7 bytes) + payload
|
||||
let chunkSize = 7 + payloadSize
|
||||
bytes_left -= chunkSize
|
||||
const chunkSize = 7 + payloadSize
|
||||
bytes_left -= chunkSize
|
||||
decodedLength += chunkSize
|
||||
chunkNumber++
|
||||
|
||||
// Limit coefficient dump to first 3 chunks
|
||||
if (dumpCoeffs && chunkNumber >= 3) {
|
||||
serial.println(`... (remaining chunks omitted)`)
|
||||
// Keep playing but don't dump more
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
} catch (e) {
|
||||
printerrln(e)
|
||||
errorlevel = 1
|
||||
}
|
||||
finally {
|
||||
if (interactive) {
|
||||
con.move(cy + 3, 1)
|
||||
con.curs_set(1)
|
||||
}
|
||||
} finally {
|
||||
if (interactive) gui.audioClose()
|
||||
}
|
||||
|
||||
return errorlevel
|
||||
|
||||
1054
assets/disk0/tvdos/bin/playtaud.js
Normal file
1054
assets/disk0/tvdos/bin/playtaud.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ const MAXMEM = sys.maxmem()
|
||||
const WIDTH = 560
|
||||
const HEIGHT = 448
|
||||
const TAV_MAGIC = [0x1F, 0x54, 0x53, 0x56, 0x4D, 0x54, 0x41, 0x56] // "\x1FTSVM TAV"
|
||||
const TAP_MAGIC = [0x1F, 0x54, 0x53, 0x56, 0x4D, 0x54, 0x41, 0x50] // "\x1FTSVM TAP"
|
||||
const UCF_MAGIC = [0x1F, 0x54, 0x53, 0x56, 0x4D, 0x55, 0x43, 0x46] // "\x1FTSVM UCF"
|
||||
const TAV_VERSION = 1 // Initial DWT version
|
||||
const UCF_VERSION = 1
|
||||
@@ -17,6 +18,7 @@ const ADDRESSING_INTERNAL = 0x02
|
||||
const SND_BASE_ADDR = audio.getBaseAddr()
|
||||
const SND_MEM_ADDR = audio.getMemAddr()
|
||||
const pcm = require("pcm")
|
||||
const AUDIO_DEVICE = 0
|
||||
const MP2_FRAME_SIZE = [144,216,252,288,360,432,504,576,720,864,1008,1152,1440,1728]
|
||||
const TAV_TEMPORAL_LEVELS = 2
|
||||
|
||||
@@ -151,13 +153,10 @@ graphics.clearPixels4(0)
|
||||
const gpuGraphicsMode = graphics.getGraphicsMode()
|
||||
|
||||
// Initialize audio
|
||||
audio.resetParams(0)
|
||||
audio.purgeQueue(0)
|
||||
audio.setPcmMode(0)
|
||||
audio.setMasterVolume(0, 255)
|
||||
|
||||
// set colour zero as half-opaque black
|
||||
graphics.setPalette(0, 0, 0, 0, 7)
|
||||
audio.resetParams(AUDIO_DEVICE)
|
||||
audio.purgeQueue(AUDIO_DEVICE)
|
||||
audio.setPcmMode(AUDIO_DEVICE)
|
||||
audio.setMasterVolume(AUDIO_DEVICE, 255)
|
||||
|
||||
// Parse SSF-TC subtitle packet and add to event buffer (0x31)
|
||||
function parseSubtitlePacketTC(packetSize) {
|
||||
@@ -367,6 +366,8 @@ let header = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
fps: 0,
|
||||
fps_num: 0, // Fractional FPS numerator (from XFPS or derived from fps)
|
||||
fps_den: 1, // Fractional FPS denominator (from XFPS, default 1)
|
||||
totalFrames: 0,
|
||||
waveletFilter: 0, // TAV-specific: wavelet filter type
|
||||
decompLevels: 0, // TAV-specific: decomposition levels
|
||||
@@ -381,6 +382,22 @@ let header = {
|
||||
fileRole: 0
|
||||
}
|
||||
|
||||
// Helper function to parse XFPS string ("num/den" format) and update header
|
||||
function parseXFPS(xfpsStr) {
|
||||
let parts = xfpsStr.split("/")
|
||||
if (parts.length === 2) {
|
||||
let num = parseInt(parts[0], 10)
|
||||
let den = parseInt(parts[1], 10)
|
||||
if (!isNaN(num) && !isNaN(den) && den > 0) {
|
||||
header.fps_num = num
|
||||
header.fps_den = den
|
||||
header.fps = num / den
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Read and validate header
|
||||
for (let i = 0; i < 8; i++) {
|
||||
header.magic[i] = seqread.readOneByte()
|
||||
@@ -389,7 +406,7 @@ for (let i = 0; i < 8; i++) {
|
||||
// Validate magic number
|
||||
let magicValid = true
|
||||
for (let i = 0; i < 8; i++) {
|
||||
if (header.magic[i] !== TAV_MAGIC[i]) {
|
||||
if (header.magic[i] !== TAV_MAGIC[i] &&header.magic[i] !== TAP_MAGIC[i] ) {
|
||||
magicValid = false
|
||||
break
|
||||
}
|
||||
@@ -401,10 +418,16 @@ if (!magicValid) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this is a TAP still image file (magic ends with 'P' instead of 'V')
|
||||
const isTapFile = (header.magic[7] === TAP_MAGIC[7])
|
||||
|
||||
header.version = seqread.readOneByte()
|
||||
header.width = seqread.readShort()
|
||||
header.height = seqread.readShort()
|
||||
header.fps = seqread.readOneByte()
|
||||
// Set default fractional fps (will be overridden by XFPS if present)
|
||||
header.fps_num = header.fps
|
||||
header.fps_den = 1
|
||||
header.totalFrames = seqread.readInt()
|
||||
header.waveletFilter = seqread.readOneByte()
|
||||
header.decompLevels = seqread.readOneByte()
|
||||
@@ -457,7 +480,7 @@ const isLossless = (header.videoFlags & 0x04) !== 0
|
||||
|
||||
console.log(`TAV Decoder`)
|
||||
console.log(`Resolution: ${header.width}x${header.height}`)
|
||||
console.log(`FPS: ${header.fps}`)
|
||||
console.log(`FPS: ${header.fps === 255 ? "(see XFPS)" : header.fps}`)
|
||||
console.log(`Total frames: ${header.totalFrames}`)
|
||||
console.log(`Wavelet filter: ${header.waveletFilter === WAVELET_5_3_REVERSIBLE ? "5/3 reversible" : header.waveletFilter === WAVELET_9_7_IRREVERSIBLE ? "9/7 irreversible" : header.waveletFilter === WAVELET_BIORTHOGONAL_13_7 ? "Biorthogonal 13/7" : header.waveletFilter === WAVELET_DD4 ? "DD-4" : header.waveletFilter === WAVELET_HAAR ? "Haar" : "unknown"}`)
|
||||
console.log(`Decomposition levels: ${header.decompLevels}`)
|
||||
@@ -469,6 +492,135 @@ console.log(`Features: ${hasAudio ? "Audio " : ""}${hasSubtitles ? "Subtitles "
|
||||
console.log(`Video flags raw: 0x${header.videoFlags.toString(16)}`)
|
||||
console.log(`Scan type: ${isInterlaced ? "Interlaced" : "Progressive"}`)
|
||||
|
||||
// Handle TAP still image file
|
||||
if (isTapFile) {
|
||||
console.log("TAP still image detected")
|
||||
|
||||
// Allocate single frame buffer for still image
|
||||
const FRAME_PIXELS = header.width * header.height
|
||||
const FRAME_SIZE = FRAME_PIXELS * 3
|
||||
|
||||
const RGB_BUFFER = sys.malloc(FRAME_SIZE)
|
||||
const PREV_RGB_BUFFER = sys.malloc(FRAME_SIZE)
|
||||
sys.memset(RGB_BUFFER, 0, FRAME_SIZE)
|
||||
sys.memset(PREV_RGB_BUFFER, 0, FRAME_SIZE)
|
||||
|
||||
// Read the image packet (should be I-frame)
|
||||
let packetType = seqread.readOneByte()
|
||||
|
||||
// Skip non-video packets until we find the image data
|
||||
while (packetType !== TAV_PACKET_IFRAME) {
|
||||
if (packetType === TAV_PACKET_EXTENDED_HDR) {
|
||||
// Parse extended header - look for XFPS
|
||||
let numPairs = seqread.readShort()
|
||||
for (let i = 0; i < numPairs; i++) {
|
||||
// Read key (4 bytes)
|
||||
let keyBytes = seqread.readBytes(4)
|
||||
let key = ""
|
||||
for (let j = 0; j < 4; j++) {
|
||||
key += String.fromCharCode(sys.peek(keyBytes + j))
|
||||
}
|
||||
sys.free(keyBytes)
|
||||
|
||||
// Read value type and value
|
||||
let valueType = seqread.readOneByte()
|
||||
if (valueType === 0x04) { // Uint64 - 8 bytes
|
||||
seqread.skip(8)
|
||||
} else if (valueType === 0x10) { // Bytes - length-prefixed
|
||||
let length = seqread.readShort()
|
||||
let dataBytes = seqread.readBytes(length)
|
||||
// Check for XFPS key
|
||||
if (key === "XFPS") {
|
||||
let xfpsStr = ""
|
||||
for (let j = 0; j < length; j++) {
|
||||
xfpsStr += String.fromCharCode(sys.peek(dataBytes + j))
|
||||
}
|
||||
parseXFPS(xfpsStr)
|
||||
}
|
||||
sys.free(dataBytes)
|
||||
}
|
||||
}
|
||||
} else if (packetType === TAV_PACKET_SCREEN_MASK) {
|
||||
// Skip screen mask packet - single entry: frame_num(4) + top(2) + right(2) + bottom(2) + left(2)
|
||||
seqread.skip(12)
|
||||
} else if (packetType === TAV_PACKET_TIMECODE) {
|
||||
seqread.skip(8)
|
||||
} else {
|
||||
console.log(`got unknown packet type 0x${packetType.toString(16)}`)
|
||||
let size = seqread.readInt()
|
||||
seqread.skip(size)
|
||||
}
|
||||
packetType = seqread.readOneByte()
|
||||
}
|
||||
|
||||
if (packetType === TAV_PACKET_IFRAME) {
|
||||
// Read and decode I-frame
|
||||
const compressedSize = seqread.readInt()
|
||||
const compressedPtr = seqread.readBytes(compressedSize)
|
||||
|
||||
// Decode using TAV hardware decoder
|
||||
graphics.tavDecodeCompressed(
|
||||
compressedPtr, compressedSize,
|
||||
RGB_BUFFER, PREV_RGB_BUFFER,
|
||||
header.width, header.height,
|
||||
header.qualityLevel,
|
||||
QLUT[header.qualityY], QLUT[header.qualityCo], QLUT[header.qualityCg],
|
||||
header.channelLayout, 0, header.waveletFilter, header.decompLevels,
|
||||
isLossless, header.version, header.entropyCoder, 2
|
||||
)
|
||||
sys.free(compressedPtr)
|
||||
|
||||
// Upload to framebuffer
|
||||
graphics.uploadRGBToFramebuffer(RGB_BUFFER, header.width, header.height, 0, false)
|
||||
|
||||
}
|
||||
|
||||
// Free buffers
|
||||
sys.free(RGB_BUFFER)
|
||||
sys.free(PREV_RGB_BUFFER)
|
||||
|
||||
// Show "backspace to exit" message
|
||||
con.clear()
|
||||
con.curs_set(0)
|
||||
con.move(1, 1)
|
||||
println("Push and hold Backspace to exit")
|
||||
|
||||
// Wait loop for still image viewing (similar to decodeipf.js)
|
||||
let wait = true
|
||||
let t1 = sys.nanoTime()
|
||||
let tapNotifHideTimer = 0
|
||||
const TAP_NOTIF_SHOWUPTIME = 3000000000 // 3 seconds
|
||||
|
||||
while (wait) {
|
||||
sys.poke(-40, 1)
|
||||
if (sys.peek(-41) == 67) { // Backspace
|
||||
wait = false
|
||||
con.curs_set(1)
|
||||
}
|
||||
|
||||
sys.sleep(50)
|
||||
|
||||
let t2 = sys.nanoTime()
|
||||
tapNotifHideTimer += (t2 - t1)
|
||||
if (tapNotifHideTimer > TAP_NOTIF_SHOWUPTIME) {
|
||||
con.clear()
|
||||
}
|
||||
t1 = t2
|
||||
}
|
||||
|
||||
// Clean up and exit (matching normal video playback cleanup)
|
||||
con.clear()
|
||||
con.curs_set(1)
|
||||
|
||||
// Reset font ROM
|
||||
sys.poke(-1299460, 20)
|
||||
sys.poke(-1299460, 21)
|
||||
|
||||
graphics.setPalette(0, 0, 0, 0, 0)
|
||||
con.move(cy, cx) // restore cursor
|
||||
return errorlevel
|
||||
}
|
||||
|
||||
// Adjust decode height for interlaced content
|
||||
// For interlaced: header.height is display height (448)
|
||||
// Each field is half of display height (448/2 = 224)
|
||||
@@ -998,10 +1150,10 @@ try {
|
||||
else if (keyCode == 62) { // SPACE - pause/resume
|
||||
paused = !paused
|
||||
if (paused) {
|
||||
audio.stop(0)
|
||||
audio.stop(AUDIO_DEVICE)
|
||||
serial.println(`Paused at frame ${frameCount}`)
|
||||
} else {
|
||||
audio.play(0)
|
||||
audio.play(AUDIO_DEVICE)
|
||||
serial.println(`Resumed`)
|
||||
}
|
||||
}
|
||||
@@ -1022,10 +1174,10 @@ try {
|
||||
baseTimecodeFrameCount = 0
|
||||
currentTimecodeNs = 0
|
||||
nextSubtitleEventIndex = 0 // Reset subtitle event processing
|
||||
audio.purgeQueue(0)
|
||||
audio.purgeQueue(AUDIO_DEVICE)
|
||||
if (paused) {
|
||||
audio.play(0)
|
||||
audio.stop(0)
|
||||
audio.play(AUDIO_DEVICE)
|
||||
audio.stop(AUDIO_DEVICE)
|
||||
}
|
||||
skipped = true
|
||||
}
|
||||
@@ -1047,10 +1199,10 @@ try {
|
||||
baseTimecodeFrameCount = 0
|
||||
currentTimecodeNs = 0
|
||||
nextSubtitleEventIndex = 0 // Reset subtitle event processing
|
||||
audio.purgeQueue(0)
|
||||
audio.purgeQueue(AUDIO_DEVICE)
|
||||
if (paused) {
|
||||
audio.play(0)
|
||||
audio.stop(0)
|
||||
audio.play(AUDIO_DEVICE)
|
||||
audio.stop(AUDIO_DEVICE)
|
||||
}
|
||||
skipped = true
|
||||
}
|
||||
@@ -1078,10 +1230,10 @@ try {
|
||||
break
|
||||
}
|
||||
}
|
||||
audio.purgeQueue(0)
|
||||
audio.purgeQueue(AUDIO_DEVICE)
|
||||
if (paused) {
|
||||
audio.play(0)
|
||||
audio.stop(0)
|
||||
audio.play(AUDIO_DEVICE)
|
||||
audio.stop(AUDIO_DEVICE)
|
||||
}
|
||||
skipped = true
|
||||
}
|
||||
@@ -1117,10 +1269,10 @@ try {
|
||||
break
|
||||
}
|
||||
}
|
||||
audio.purgeQueue(0)
|
||||
audio.purgeQueue(AUDIO_DEVICE)
|
||||
if (paused) {
|
||||
audio.play(0)
|
||||
audio.stop(0)
|
||||
audio.play(AUDIO_DEVICE)
|
||||
audio.stop(AUDIO_DEVICE)
|
||||
}
|
||||
skipped = true
|
||||
} else if (!seekTarget) {
|
||||
@@ -1159,7 +1311,7 @@ try {
|
||||
baseTimecodeFrameCount = 0
|
||||
currentTimecodeNs = 0
|
||||
nextSubtitleEventIndex = 0 // Reset subtitle event processing
|
||||
audio.purgeQueue(0)
|
||||
audio.purgeQueue(AUDIO_DEVICE)
|
||||
currentFileIndex++
|
||||
if (skipped) {
|
||||
skipped = false
|
||||
@@ -1170,7 +1322,7 @@ try {
|
||||
|
||||
console.log(`\nStarting file ${currentFileIndex}:`)
|
||||
console.log(`Resolution: ${header.width}x${header.height}`)
|
||||
console.log(`FPS: ${header.fps}`)
|
||||
console.log(`FPS: ${header.fps === 255 ? "(see XFPS)" : header.fps}`)
|
||||
console.log(`Total frames: ${header.totalFrames}`)
|
||||
console.log(`Wavelet filter: ${header.waveletFilter === WAVELET_5_3_REVERSIBLE ? "5/3 reversible" : header.waveletFilter === WAVELET_9_7_IRREVERSIBLE ? "9/7 irreversible" : header.waveletFilter === WAVELET_BIORTHOGONAL_13_7 ? "Biorthogonal 13/7" : header.waveletFilter === WAVELET_DD4 ? "DD-4" : header.waveletFilter === WAVELET_HAAR ? "Haar" : "unknown"}`)
|
||||
console.log(`Quality: Y=${header.qualityY}, Co=${header.qualityCo}, Cg=${header.qualityCg}`)
|
||||
@@ -1583,7 +1735,7 @@ try {
|
||||
|
||||
seqread.readBytes(audioLen, SND_BASE_ADDR - 2368)
|
||||
audio.mp2Decode()
|
||||
audio.mp2UploadDecoded(0)
|
||||
audio.mp2UploadDecoded(AUDIO_DEVICE)
|
||||
|
||||
}
|
||||
else if (packetType === TAV_PACKET_AUDIO_TAD) {
|
||||
@@ -1594,9 +1746,11 @@ try {
|
||||
tadInitialised = true
|
||||
}
|
||||
|
||||
seqread.readBytes(payloadLen, SND_MEM_ADDR - 262144)
|
||||
// tadInputBin lives at audio-local offset 917504 (post-bef85f6 memory map);
|
||||
// the previous 262144 offset now points into the enlarged sampleBin.
|
||||
seqread.readBytes(payloadLen, SND_MEM_ADDR - 917504)
|
||||
audio.tadDecode()
|
||||
audio.tadUploadDecoded(0, sampleLen)
|
||||
audio.tadUploadDecoded(AUDIO_DEVICE, sampleLen)
|
||||
}
|
||||
else if (packetType === TAV_PACKET_AUDIO_NATIVE) {
|
||||
// PCM length must not exceed 65536 bytes!
|
||||
@@ -1608,10 +1762,10 @@ try {
|
||||
let pcmLen = gzip.decompFromTo(zstdPtr, zstdLen, pcmPtr) // <- segfaults!
|
||||
if (pcmLen > 65536) throw Error(`PCM data too long -- got ${pcmLen} bytes`)
|
||||
|
||||
audio.putPcmDataByPtr(pcmPtr, pcmLen, 0)
|
||||
audio.putPcmDataByPtr(AUDIO_DEVICE, pcmPtr, pcmLen, 0)
|
||||
|
||||
audio.setSampleUploadLength(0, pcmLen)
|
||||
audio.startSampleUpload(0)
|
||||
audio.setSampleUploadLength(AUDIO_DEVICE, pcmLen)
|
||||
audio.startSampleUpload(AUDIO_DEVICE)
|
||||
sys.free(zstdPtr)
|
||||
|
||||
sys.free(pcmPtr)
|
||||
@@ -1704,7 +1858,19 @@ try {
|
||||
}
|
||||
sys.free(dataBytes)
|
||||
|
||||
if (interactive) {
|
||||
// Parse XFPS if present (always try, not just when fps=255)
|
||||
if (key === "XFPS") {
|
||||
if (parseXFPS(dataStr)) {
|
||||
// Update frame timing with new fps
|
||||
frametime = 1000000000.0 / header.fps
|
||||
FRAME_TIME = 1.0 / header.fps
|
||||
if (interactive) {
|
||||
serial.println(` ${key}: ${dataStr} -> ${header.fps.toFixed(3)} fps`)
|
||||
}
|
||||
} else if (interactive) {
|
||||
serial.println(` ${key}: "${dataStr}" (parse failed)`)
|
||||
}
|
||||
} else if (interactive) {
|
||||
serial.println(` ${key}: "${dataStr}"`)
|
||||
}
|
||||
} else {
|
||||
@@ -1883,7 +2049,7 @@ try {
|
||||
|
||||
// Fire audio on first frame
|
||||
if (!audioFired) {
|
||||
audio.play(0)
|
||||
audio.play(AUDIO_DEVICE)
|
||||
audioFired = true
|
||||
}
|
||||
|
||||
@@ -1971,7 +2137,7 @@ try {
|
||||
|
||||
// Fire audio on first frame
|
||||
if (!audioFired) {
|
||||
audio.play(0)
|
||||
audio.play(AUDIO_DEVICE)
|
||||
audioFired = true
|
||||
}
|
||||
|
||||
@@ -2007,8 +2173,8 @@ try {
|
||||
sys.memcpy(predecodedPcmBuffer + predecodedPcmOffset, SND_BASE_ADDR, uploadSize)
|
||||
|
||||
// Set upload parameters and trigger upload to queue
|
||||
audio.setSampleUploadLength(0, uploadSize)
|
||||
audio.startSampleUpload(0)
|
||||
audio.setSampleUploadLength(AUDIO_DEVICE, uploadSize)
|
||||
audio.startSampleUpload(AUDIO_DEVICE)
|
||||
|
||||
predecodedPcmOffset += uploadSize
|
||||
}
|
||||
@@ -2292,10 +2458,10 @@ finally {
|
||||
sys.poke(-1299460, 20)
|
||||
sys.poke(-1299460, 21)
|
||||
|
||||
audio.stop(0)
|
||||
audio.purgeQueue(0)
|
||||
audio.stop(AUDIO_DEVICE)
|
||||
audio.purgeQueue(AUDIO_DEVICE)
|
||||
}
|
||||
|
||||
graphics.setPalette(0, 0, 0, 0, 0)
|
||||
graphics.resetPalette()
|
||||
con.move(cy, cx) // restore cursor
|
||||
return errorlevel
|
||||
@@ -307,7 +307,7 @@ for (let i = 0; i < cueElements.length; i++) {
|
||||
// Execute the player with modified environment
|
||||
exec_args[1] = targetPath
|
||||
if (playerFile) {
|
||||
let playerPath = `A:\\tvdos\\bin\\${playerFile}.js`
|
||||
let playerPath = `A:${_TVDOS.variables.DOSDIR}/bin/${playerFile}.js`
|
||||
if (files.open(playerPath).exists) {
|
||||
eval(files.readText(playerPath))
|
||||
} else {
|
||||
@@ -334,7 +334,7 @@ for (let i = 0; i < cueElements.length; i++) {
|
||||
}
|
||||
|
||||
// Execute the appropriate player
|
||||
let playerPath = `A:\\tvdos\\bin\\${playerFile}.js`
|
||||
let playerPath = `A:${_TVDOS.variables.DOSDIR}/bin/${playerFile}.js`
|
||||
if (!files.open(playerPath).exists) {
|
||||
serial.println(`Warning: Player script not found: ${playerPath}`)
|
||||
continue
|
||||
|
||||
@@ -1,329 +1,189 @@
|
||||
// usage: playwav audiofile.wav [/i]
|
||||
let fileeeee = files.open(_G.shell.resolvePathInput(exec_args[1]).full)
|
||||
let filename = fileeeee.fullPath
|
||||
// playwav — WAV (LPCM/ADPCM) player with the shared playgui visualiser.
|
||||
// Usage: playwav <file.wav> [-i]
|
||||
|
||||
const fileHandle = files.open(_G.shell.resolvePathInput(exec_args[1]).full)
|
||||
const filePath = fileHandle.fullPath
|
||||
|
||||
const WAV_FORMATS = ["LPCM", "ADPCM"]
|
||||
const WAV_CHANNELS = ["Mono", "Stereo", "3ch", "Quad", "4.1", "5.1", "6.1", "7.1"]
|
||||
const interactive = exec_args[2] && exec_args[2].toLowerCase() === "-i"
|
||||
|
||||
const seqread = require("seqread")
|
||||
const pcm = require("pcm")
|
||||
const gui = interactive ? require("playgui") : null
|
||||
|
||||
function printdbg(s) { if (0) serial.println(s) }
|
||||
|
||||
const WAV_FORMATS = ["LPCM", "ADPCM"]
|
||||
const WAV_CHANNELS = ["Mono", "Stereo", "3ch", "Quad", "4.1", "5.1", "6.1", "7.1"]
|
||||
const interactive = exec_args[2] && exec_args[2].toLowerCase() == "-i"
|
||||
const seqread = require("seqread")
|
||||
const pcm = require("pcm")
|
||||
|
||||
|
||||
|
||||
function printComments() {
|
||||
for (const [key, value] of Object.entries(comments)) {
|
||||
printdbg(`Wave Comment ${key}: ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
function GCD(a, b) {
|
||||
a = Math.abs(a)
|
||||
b = Math.abs(b)
|
||||
if (b > a) {var temp = a; a = b; b = temp}
|
||||
a = Math.abs(a); b = Math.abs(b)
|
||||
if (b > a) { const t = a; a = b; b = t }
|
||||
while (true) {
|
||||
if (b == 0) return a
|
||||
if (b === 0) return a
|
||||
a %= b
|
||||
if (a == 0) return b
|
||||
if (a === 0) return b
|
||||
b %= a
|
||||
}
|
||||
}
|
||||
function LCM(a, b) { return (!a || !b) ? 0 : Math.abs((a * b) / GCD(a, b)) }
|
||||
|
||||
function LCM(a, b) {
|
||||
return (!a || !b) ? 0 : Math.abs((a * b) / GCD(a, b))
|
||||
}
|
||||
|
||||
|
||||
|
||||
//println("Reading...")
|
||||
//serial.println("!!! READING")
|
||||
|
||||
seqread.prepare(filename)
|
||||
|
||||
|
||||
|
||||
|
||||
// decode header
|
||||
if (seqread.readFourCC() != "RIFF") {
|
||||
throw Error("File not RIFF")
|
||||
}
|
||||
|
||||
const FILE_SIZE = seqread.readInt() // size from "WAVEfmt"
|
||||
|
||||
if (seqread.readFourCC() != "WAVE") {
|
||||
throw Error("File is RIFF but not WAVE")
|
||||
}
|
||||
seqread.prepare(filePath)
|
||||
if (seqread.readFourCC() !== "RIFF") throw Error("File not RIFF")
|
||||
const FILE_SIZE = seqread.readInt()
|
||||
if (seqread.readFourCC() !== "WAVE") throw Error("File is RIFF but not WAVE")
|
||||
|
||||
let BLOCK_SIZE = 0
|
||||
let INFILE_BLOCK_SIZE = 0
|
||||
const QUEUE_MAX = 8 // according to the spec
|
||||
const QUEUE_MAX = 8
|
||||
|
||||
let pcmType;
|
||||
let nChannels;
|
||||
let samplingRate;
|
||||
let blockSize;
|
||||
let bitsPerSample;
|
||||
let byterate;
|
||||
let comments = {};
|
||||
let adpcmSamplesPerBlock;
|
||||
let readPtr = undefined
|
||||
let decodePtr = undefined
|
||||
let pcmType, nChannels, samplingRate, blockSize, bitsPerSample, byterate
|
||||
let adpcmSamplesPerBlock
|
||||
let readPtr, decodePtr
|
||||
const comments = {}
|
||||
|
||||
function bytesToSec(i) {
|
||||
if (adpcmSamplesPerBlock) {
|
||||
let newByteRate = samplingRate
|
||||
let generatedSamples = i / blockSize * adpcmSamplesPerBlock
|
||||
return generatedSamples / newByteRate
|
||||
}
|
||||
else {
|
||||
return i / byterate
|
||||
const generatedSamples = i / blockSize * adpcmSamplesPerBlock
|
||||
return generatedSamples / samplingRate
|
||||
}
|
||||
return i / byterate
|
||||
}
|
||||
function secToReadable(n) {
|
||||
let mins = ''+((n/60)|0)
|
||||
let secs = ''+(n % 60)
|
||||
return `${mins.padStart(2,'0')}:${secs.padStart(2,'0')}`
|
||||
}
|
||||
|
||||
function checkIfPlayable() {
|
||||
if (pcmType != 1 && pcmType != 2) return `PCM Type not LPCM/ADPCM (${pcmType})`
|
||||
if (pcmType !== 1 && pcmType !== 2) return `PCM Type not LPCM/ADPCM (${pcmType})`
|
||||
if (nChannels < 1 || nChannels > 2) return `Audio not mono/stereo but instead has ${nChannels} channels`
|
||||
if (pcmType != 1 && samplingRate != pcm.HW_SAMPLING_RATE) return `Format is ADPCM but sampling rate is not ${pcm.HW_SAMPLING_RATE}: ${samplingRate}`
|
||||
if (pcmType !== 1 && samplingRate !== pcm.HW_SAMPLING_RATE)
|
||||
return `Format is ADPCM but sampling rate is not ${pcm.HW_SAMPLING_RATE}: ${samplingRate}`
|
||||
return "playable!"
|
||||
}
|
||||
// @return decoded sample length (not count!)
|
||||
|
||||
function decodeInfilePcm(inPtr, outPtr, inputLen) {
|
||||
// LPCM
|
||||
if (1 == pcmType)
|
||||
if (pcmType === 1)
|
||||
return pcm.decodeLPCM(inPtr, outPtr, inputLen, { nChannels, bitsPerSample, samplingRate, blockSize })
|
||||
else if (2 == pcmType)
|
||||
if (pcmType === 2)
|
||||
return pcm.decodeMS_ADPCM(inPtr, outPtr, inputLen, { nChannels })
|
||||
else
|
||||
throw Error(`PCM Type not LPCM or ADPCM (${pcmType})`)
|
||||
throw Error(`PCM Type not LPCM or ADPCM (${pcmType})`)
|
||||
}
|
||||
|
||||
let stopPlay = false
|
||||
|
||||
|
||||
con.curs_set(0)
|
||||
let [__, CONSOLE_WIDTH] = con.getmaxyx()
|
||||
function printPlayerShell() {
|
||||
if (interactive) {
|
||||
let [cy, cx] = con.getyx()
|
||||
// file name
|
||||
con.mvaddch(cy, 1)
|
||||
con.prnch(0xC9);con.prnch(0xCD);con.prnch(0xB5)
|
||||
print(fileeeee.name)
|
||||
con.prnch(0xC6);con.prnch(0xCD)
|
||||
print("\x84205u".repeat(CONSOLE_WIDTH - 26 - fileeeee.name.length))
|
||||
con.prnch(0xB5)
|
||||
print("Hold Bksp to Exit")
|
||||
con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBB)
|
||||
|
||||
// L R pillar
|
||||
con.prnch(0xBA)
|
||||
con.mvaddch(cy+1, CONSOLE_WIDTH, 0xBA)
|
||||
|
||||
// media info
|
||||
let mediaInfoStr = `WAV ${WAV_FORMATS[pcmType-1]} ${WAV_CHANNELS[nChannels-1]} ${byterate*0.008*(pcmType == 2 ? 2 : 1)}kbps`
|
||||
con.move(cy+2,1)
|
||||
con.prnch(0xC8)
|
||||
print("\x84205u".repeat(CONSOLE_WIDTH - 5 - mediaInfoStr.length))
|
||||
con.prnch(0xB5)
|
||||
print(mediaInfoStr)
|
||||
con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBC)
|
||||
|
||||
con.move(cy+1, 2)
|
||||
}
|
||||
}
|
||||
let [cy, cx] = con.getyx(); cy++
|
||||
let paintWidth = CONSOLE_WIDTH - 20
|
||||
function printPlayBar(startOffset) {
|
||||
if (interactive) {
|
||||
let currently = seqread.getReadCount() - startOffset
|
||||
let total = FILE_SIZE - startOffset - 8
|
||||
|
||||
let currentlySec = Math.round(bytesToSec(currently))
|
||||
let totalSec = Math.round(bytesToSec(total))
|
||||
|
||||
con.move(cy, 3)
|
||||
print(' '.repeat(15))
|
||||
con.move(cy, 3)
|
||||
|
||||
print(`${secToReadable(currentlySec)} / ${secToReadable(totalSec)}`)
|
||||
|
||||
con.move(cy, 17)
|
||||
print(' ')
|
||||
let progressbar = '\x84196u'.repeat(paintWidth + 1)
|
||||
print(progressbar)
|
||||
|
||||
con.mvaddch(cy, 18 + Math.round(paintWidth * (currently / total)), 0xDB)
|
||||
}
|
||||
}
|
||||
let errorlevel = 0
|
||||
// read chunks loop
|
||||
try {
|
||||
while (!stopPlay && seqread.getReadCount() < FILE_SIZE - 8) {
|
||||
let chunkName = seqread.readFourCC()
|
||||
let chunkSize = seqread.readInt()
|
||||
printdbg(`Reading '${chunkName}' at ${seqread.getReadCount() - 8}`)
|
||||
|
||||
// here be lotsa if-else
|
||||
if ("fmt " == chunkName) {
|
||||
pcmType = seqread.readShort()
|
||||
nChannels = seqread.readShort()
|
||||
samplingRate = seqread.readInt()
|
||||
byterate = seqread.readInt()
|
||||
blockSize = seqread.readShort()
|
||||
bitsPerSample = seqread.readShort()
|
||||
if (pcmType != 2) {
|
||||
seqread.skip(chunkSize - 16)
|
||||
try {
|
||||
while (!stopPlay && seqread.getReadCount() < FILE_SIZE - 8) {
|
||||
const chunkName = seqread.readFourCC()
|
||||
const chunkSize = seqread.readInt()
|
||||
printdbg(`Reading '${chunkName}' at ${seqread.getReadCount() - 8}`)
|
||||
|
||||
if (chunkName === "fmt ") {
|
||||
pcmType = seqread.readShort()
|
||||
nChannels = seqread.readShort()
|
||||
samplingRate = seqread.readInt()
|
||||
byterate = seqread.readInt()
|
||||
blockSize = seqread.readShort()
|
||||
bitsPerSample = seqread.readShort()
|
||||
if (pcmType !== 2) {
|
||||
seqread.skip(chunkSize - 16)
|
||||
} else {
|
||||
seqread.skip(2)
|
||||
adpcmSamplesPerBlock = seqread.readShort()
|
||||
seqread.skip(chunkSize - (16 + 4))
|
||||
}
|
||||
|
||||
if (pcmType === 1) {
|
||||
const incr = LCM(blockSize, samplingRate / GCD(samplingRate, pcm.HW_SAMPLING_RATE))
|
||||
while (BLOCK_SIZE < 4096) BLOCK_SIZE += incr
|
||||
INFILE_BLOCK_SIZE = BLOCK_SIZE * bitsPerSample / 8
|
||||
} else if (pcmType === 2) {
|
||||
BLOCK_SIZE = blockSize
|
||||
INFILE_BLOCK_SIZE = BLOCK_SIZE
|
||||
}
|
||||
|
||||
if (interactive) {
|
||||
const tag = "WAV"
|
||||
const title = fileHandle.name +
|
||||
` ${WAV_FORMATS[pcmType-1]} ${WAV_CHANNELS[nChannels-1]} ${byterate*0.008*(pcmType === 2 ? 2 : 1)}kbps`
|
||||
gui.audioInit({ title, tag })
|
||||
}
|
||||
}
|
||||
else if (chunkName === "LIST") {
|
||||
const startOffset = seqread.getReadCount()
|
||||
const subChunkName = seqread.readFourCC()
|
||||
while (seqread.getReadCount() < startOffset + chunkSize) {
|
||||
if (subChunkName === "INFO") {
|
||||
let key = seqread.readFourCC()
|
||||
let valueLen = seqread.readInt()
|
||||
while (key.charCodeAt(0) === 0) {
|
||||
const kbytes = [key.charCodeAt(1), key.charCodeAt(2), key.charCodeAt(3), valueLen & 255]
|
||||
const klen = [(valueLen >>> 8) & 255, (valueLen >>> 16) & 255, (valueLen >>> 24) & 255, seqread.readOneByte()]
|
||||
key = String.fromCharCode.apply(null, kbytes)
|
||||
valueLen = klen[0] | (klen[1] << 8) | (klen[2] << 16) | (klen[3] << 24)
|
||||
}
|
||||
comments[key] = seqread.readString(valueLen)
|
||||
} else {
|
||||
seqread.skip(startOffset + chunkSize - seqread.getReadCount())
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (chunkName === "data") {
|
||||
const startOffset = seqread.getReadCount()
|
||||
const reason = checkIfPlayable()
|
||||
if (reason !== "playable!") throw Error("WAVE not playable: " + reason)
|
||||
|
||||
readPtr = sys.malloc(pcmType === 2 ? BLOCK_SIZE : BLOCK_SIZE * bitsPerSample / 8)
|
||||
decodePtr = sys.malloc(BLOCK_SIZE * pcm.HW_SAMPLING_RATE / samplingRate)
|
||||
|
||||
audio.resetParams(0)
|
||||
audio.purgeQueue(0)
|
||||
audio.setPcmMode(0)
|
||||
audio.setMasterVolume(0, 255)
|
||||
|
||||
let readLength = 1
|
||||
while (!stopPlay && seqread.getReadCount() < startOffset + chunkSize && readLength > 0) {
|
||||
if (interactive && gui.audioIsExitRequested()) { stopPlay = true; break }
|
||||
|
||||
if (audio.getPosition(0) <= 1) {
|
||||
for (let repeat = 0; repeat < QUEUE_MAX; repeat++) {
|
||||
const remainingBytes = FILE_SIZE - 8 - seqread.getReadCount()
|
||||
readLength = (remainingBytes < INFILE_BLOCK_SIZE) ? remainingBytes : INFILE_BLOCK_SIZE
|
||||
if (readLength <= 0) break
|
||||
|
||||
seqread.readBytes(readLength, readPtr)
|
||||
const decodedSampleLength = decodeInfilePcm(readPtr, decodePtr, readLength)
|
||||
|
||||
// Hand the decoded PCMu8 stereo block to the visualiser
|
||||
// before queueing — the buffer is reused next iteration.
|
||||
if (interactive) gui.audioFeedPcm(decodePtr, decodedSampleLength >> 1)
|
||||
|
||||
audio.putPcmDataByPtr(0, decodePtr, decodedSampleLength, 0)
|
||||
audio.setSampleUploadLength(0, decodedSampleLength)
|
||||
audio.startSampleUpload(0)
|
||||
|
||||
sys.spin()
|
||||
}
|
||||
audio.play(0)
|
||||
}
|
||||
|
||||
if (interactive) {
|
||||
const cur = seqread.getReadCount() - startOffset
|
||||
const tot = FILE_SIZE - startOffset - 8
|
||||
gui.audioSetProgress(cur / tot, bytesToSec(cur), bytesToSec(tot))
|
||||
gui.audioRender()
|
||||
}
|
||||
sys.sleep(10)
|
||||
}
|
||||
}
|
||||
else {
|
||||
seqread.skip(2)
|
||||
adpcmSamplesPerBlock = seqread.readShort()
|
||||
seqread.skip(chunkSize - (16 + 4))
|
||||
seqread.skip(chunkSize)
|
||||
}
|
||||
|
||||
// define BLOCK_SIZE as integer multiple of blockSize, for LPCM
|
||||
// ADPCM will be decoded per-block basis
|
||||
if (1 == pcmType) {
|
||||
// get GCD of given values; this wll make resampling headache-free
|
||||
let blockSizeIncrement = LCM(blockSize, samplingRate / GCD(samplingRate, pcm.HW_SAMPLING_RATE))
|
||||
|
||||
while (BLOCK_SIZE < 4096) {
|
||||
BLOCK_SIZE += blockSizeIncrement // for rate 44100, BLOCK_SIZE will be 4116
|
||||
}
|
||||
INFILE_BLOCK_SIZE = BLOCK_SIZE * bitsPerSample / 8 // for rate 44100, INFILE_BLOCK_SIZE will be 8232
|
||||
}
|
||||
else if (2 == pcmType) {
|
||||
BLOCK_SIZE = blockSize
|
||||
INFILE_BLOCK_SIZE = BLOCK_SIZE
|
||||
}
|
||||
|
||||
printdbg(`Format: ${pcmType}, Channels: ${nChannels}, Rate: ${samplingRate}, BitDepth: ${bitsPerSample}`)
|
||||
printdbg(`BLOCK_SIZE=${BLOCK_SIZE}, INFILE_BLOCK_SIZE=${INFILE_BLOCK_SIZE}`)
|
||||
printPlayerShell()
|
||||
sys.spin()
|
||||
}
|
||||
else if ("LIST" == chunkName) {
|
||||
let startOffset = seqread.getReadCount()
|
||||
let subChunkName = seqread.readFourCC()
|
||||
while (seqread.getReadCount() < startOffset + chunkSize) {
|
||||
if ("INFO" == subChunkName) {
|
||||
let key = seqread.readFourCC()
|
||||
let valueLen = seqread.readInt()
|
||||
|
||||
// f-you WAVE encoders with nonstandard behaviours
|
||||
// related: https://stackoverflow.com/questions/49537639/riff-icmt-tag-size-doesnt-seem-to-match-data
|
||||
while (0 == key.charCodeAt(0)) {
|
||||
printdbg(`Previous key had more zero bytes padded than its marked length, skipping one byte...`)
|
||||
|
||||
let kbytes = [key.charCodeAt(1), key.charCodeAt(2), key.charCodeAt(3), valueLen & 255]
|
||||
let klen = [(valueLen >>> 8) & 255, (valueLen >>> 16) & 255, (valueLen >>> 24) & 255, seqread.readOneByte()]
|
||||
|
||||
key = String.fromCharCode.apply(null, kbytes)
|
||||
valueLen = klen[0] | (klen[1] << 8) | (klen[2] << 16) | (klen[3] << 24)
|
||||
}
|
||||
|
||||
printdbg(`Reading LIST INFO ${key}[${[0,1,2,3].map((i)=>"0x"+key.charCodeAt(i).toString(16).padStart(2,'0'))}] (${valueLen} bytes): `)
|
||||
|
||||
|
||||
let value = seqread.readString(valueLen)
|
||||
printdbg(" |"+value)
|
||||
comments[key] = value
|
||||
}
|
||||
else {
|
||||
printdbg(`LIST skip subchunk ${subChunkName} (${startOffset + chunkSize - seqread.getReadCount()} bytes)`)
|
||||
seqread.skip(startOffset + chunkSize - seqread.getReadCount())
|
||||
}
|
||||
}
|
||||
printComments()
|
||||
}
|
||||
else if ("data" == chunkName) {
|
||||
let startOffset = seqread.getReadCount()
|
||||
|
||||
printdbg(`WAVE size: ${chunkSize}, startOffset=${startOffset}`)
|
||||
// check if the format is actually playable
|
||||
let unplayableReason = checkIfPlayable()
|
||||
if (unplayableReason != "playable!") throw Error("WAVE not playable: "+unplayableReason)
|
||||
|
||||
if (pcmType == 2)
|
||||
readPtr = sys.malloc(BLOCK_SIZE)
|
||||
else
|
||||
readPtr = sys.malloc(BLOCK_SIZE * bitsPerSample / 8)
|
||||
|
||||
decodePtr = sys.malloc(BLOCK_SIZE * pcm.HW_SAMPLING_RATE / samplingRate)
|
||||
|
||||
audio.resetParams(0)
|
||||
audio.purgeQueue(0)
|
||||
audio.setPcmMode(0)
|
||||
audio.setMasterVolume(0, 255)
|
||||
|
||||
let readLength = 1
|
||||
while (!stopPlay && seqread.getReadCount() < startOffset + chunkSize && readLength > 0) {
|
||||
if (interactive) {
|
||||
sys.poke(-40, 1)
|
||||
if (sys.peek(-41) == 67) {
|
||||
stopPlay = true
|
||||
}
|
||||
}
|
||||
|
||||
printPlayBar(startOffset)
|
||||
|
||||
let queueSize = audio.getPosition(0)
|
||||
if (queueSize <= 1) {
|
||||
|
||||
|
||||
// upload four samples for lag-safely
|
||||
for (let repeat = 0; repeat < QUEUE_MAX; repeat++) {
|
||||
let remainingBytes = FILE_SIZE - 8 - seqread.getReadCount()
|
||||
|
||||
readLength = (remainingBytes < INFILE_BLOCK_SIZE) ? remainingBytes : INFILE_BLOCK_SIZE
|
||||
if (readLength <= 0) {
|
||||
printdbg(`readLength = ${readLength}`)
|
||||
break
|
||||
}
|
||||
|
||||
printdbg(`offset: ${seqread.getReadCount()}/${FILE_SIZE + 8}; readLength: ${readLength}`)
|
||||
|
||||
seqread.readBytes(readLength, readPtr)
|
||||
|
||||
let decodedSampleLength = decodeInfilePcm(readPtr, decodePtr, readLength)
|
||||
printdbg(` decodedSampleLength: ${decodedSampleLength}`)
|
||||
|
||||
audio.putPcmDataByPtr(decodePtr, decodedSampleLength, 0)
|
||||
audio.setSampleUploadLength(0, decodedSampleLength)
|
||||
audio.startSampleUpload(0)
|
||||
|
||||
sys.spin()
|
||||
}
|
||||
|
||||
audio.play(0)
|
||||
}
|
||||
|
||||
let remainingBytes = FILE_SIZE - 8 - seqread.getReadCount()
|
||||
printdbg(`readLength = ${readLength}; remainingBytes2 = ${remainingBytes}; seqread.getReadCount() = ${seqread.getReadCount()}; startOffset + chunkSize = ${startOffset + chunkSize}`)
|
||||
|
||||
|
||||
sys.sleep(10)
|
||||
}
|
||||
}
|
||||
else {
|
||||
seqread.skip(chunkSize)
|
||||
}
|
||||
|
||||
|
||||
let remainingBytes = FILE_SIZE - 8 - seqread.getReadCount()
|
||||
printdbg(`remainingBytes2 = ${remainingBytes}`)
|
||||
sys.spin()
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
} catch (e) {
|
||||
printerrln(e)
|
||||
errorlevel = 1
|
||||
}
|
||||
finally {
|
||||
//audio.stop(0)
|
||||
if (readPtr !== undefined) sys.free(readPtr)
|
||||
} finally {
|
||||
if (readPtr !== undefined) sys.free(readPtr)
|
||||
if (decodePtr !== undefined) sys.free(decodePtr)
|
||||
if (interactive) gui.audioClose()
|
||||
}
|
||||
|
||||
return errorlevel
|
||||
|
||||
5459
assets/disk0/tvdos/bin/taut.js
Normal file
5459
assets/disk0/tvdos/bin/taut.js
Normal file
File diff suppressed because it is too large
Load Diff
77
assets/disk0/tvdos/bin/taut_fileop.js
Normal file
77
assets/disk0/tvdos/bin/taut_fileop.js
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* TAUT File Operations
|
||||
* Sub-program launched by taut.js when the File tab is active.
|
||||
* Rows 1-3 are owned by the parent; this program draws rows 4+.
|
||||
*
|
||||
* exec_args[1] = path to .taud file
|
||||
* Sets _G.TAUT.UI.NEXTPANEL before returning to request a panel switch.
|
||||
*
|
||||
* Created by minjaesong on 2026-04-27
|
||||
*/
|
||||
|
||||
const win = require("wintex")
|
||||
|
||||
const PANEL_COUNT = 7
|
||||
const MY_PANEL = 6 // VIEW_FILE
|
||||
|
||||
const [SCRH, SCRW] = con.getmaxyx()
|
||||
const PANEL_Y = 4
|
||||
const PANEL_H = SCRH - PANEL_Y
|
||||
|
||||
const colStatus = 253
|
||||
const colContent = 240
|
||||
const colHdr = 230
|
||||
|
||||
function drawFileOpContents(wo) {
|
||||
for (let y = PANEL_Y; y < SCRH; y++) {
|
||||
con.move(y, 1)
|
||||
con.color_pair(colContent, 255)
|
||||
print(' '.repeat(SCRW))
|
||||
}
|
||||
con.move(PANEL_Y + 1, 3)
|
||||
con.color_pair(colHdr, 255)
|
||||
print('[ File ]')
|
||||
con.move(PANEL_Y + 3, 3)
|
||||
con.color_pair(colStatus, 255)
|
||||
print('placeholder — not yet implemented')
|
||||
}
|
||||
|
||||
function drawHints() {
|
||||
con.move(SCRH, 1)
|
||||
con.color_pair(colStatus, 255)
|
||||
print(' '.repeat(SCRW - 1))
|
||||
con.move(SCRH, 1)
|
||||
con.color_pair(colHdr, 255); print('Tab ')
|
||||
con.color_pair(colStatus, 255); print('Panel')
|
||||
}
|
||||
|
||||
function fileOpInput(wo, event) {
|
||||
// placeholder — no interaction yet
|
||||
}
|
||||
|
||||
const panel = new win.WindowObject(1, PANEL_Y, SCRW, PANEL_H, fileOpInput, drawFileOpContents, undefined, ()=>{})
|
||||
|
||||
panel.drawContents()
|
||||
drawHints()
|
||||
|
||||
let done = false
|
||||
while (!done) {
|
||||
input.withEvent(event => {
|
||||
if (event[0] !== 'key_down') return
|
||||
const keysym = event[1]
|
||||
const keyJustHit = (1 == event[2])
|
||||
const shiftDown = (event.includes(59) || event.includes(60))
|
||||
|
||||
if (!keyJustHit) return
|
||||
|
||||
if (keysym === '<TAB>') {
|
||||
_G.TAUT.UI.NEXTPANEL = (MY_PANEL + (shiftDown ? -1 : 1) + PANEL_COUNT) % PANEL_COUNT
|
||||
done = true
|
||||
return
|
||||
}
|
||||
|
||||
panel.processInput(event)
|
||||
})
|
||||
}
|
||||
|
||||
return 0
|
||||
171
assets/disk0/tvdos/bin/taut_helpmsg.js
Normal file
171
assets/disk0/tvdos/bin/taut_helpmsg.js
Normal file
@@ -0,0 +1,171 @@
|
||||
if (!_G.TAUT) _G.TAUT = {};
|
||||
let help = {}
|
||||
|
||||
let ts = require("typesetter")
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
/*
|
||||
Tags:
|
||||
<b> - print the text in emphasis colour (colVoiceHdr aka 230)
|
||||
<c> - centre the line. If the line spans multiple lines, centre each line
|
||||
<r> - align right
|
||||
<l> - align left
|
||||
<o> - create virtual typesetting box. Left anchor: where the text cursor is. Right anchor: end of the line
|
||||
µtone; - replace with the brand string (<col 211>Micro</col><col 239>tone</col>)
|
||||
|
||||
&bul; - replace with bullet (\u00F9)
|
||||
&ddot; - replace with double-dot (\u008419u)
|
||||
&mdot; - replace with BIGDOT (\u00FA)
|
||||
&updn; - up-down arrow (\u008418u)
|
||||
&udlr; - four direction arrow (\u008428u\u008429u)
|
||||
|
||||
&keyoffsym; - pattern view key-off symbol (\u00A0\u00B1\u00B1\u00A1)
|
||||
¬ecutsym; - pattern view note-cut symbol (\u00A4\u00A4\u00A4\u00A4)
|
||||
|
||||
&demisharp;
|
||||
♯
|
||||
&sesquisharp;
|
||||
&doublesharp;
|
||||
&triplesharp;
|
||||
&quadsharp;
|
||||
&demiflat;
|
||||
♭
|
||||
&sesquiflat;
|
||||
&doubleflat;
|
||||
&tripleflat;
|
||||
&quadflat;
|
||||
&accuptick;
|
||||
&accdntick;
|
||||
&accupup;
|
||||
&accdndn;
|
||||
|
||||
- nonbreakable space (only meaningful for typesetters)
|
||||
­ - soft hyphen (only meaningful for typesetters)
|
||||
|
||||
default alignment: fully justified
|
||||
*/
|
||||
|
||||
let helpNotation = `<c>CONTROL NOTATION</c>
|
||||
<c>\u00B7${'\u00B8'.repeat(16)}\u00B9</c>
|
||||
µtone; <O>shortcuts differentiate normal and shifted shortcuts.</O>
|
||||
&bul;<b>a</b>&ddot;<b>z</b> : <O>alphabet without shift-in</O>
|
||||
&bul;<b>A</b>&ddot;<b>Z</b> : <O>alphabet with shift-in</O>
|
||||
&bul;<b>^q</b> : <O>hit 'q' with control key</O>
|
||||
&bul;<b>^Q</b> : <O>hit 'q' with control and shift key</O>
|
||||
`
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
let helpJam = `<c>NOTE JAMMING</c>
|
||||
<c>\u00B7${'\u00B8'.repeat(12)}\u00B9</c>
|
||||
Push keys to play or insert notes.
|
||||
w e t y u
|
||||
a s d f g h j k
|
||||
`
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
let helpCommon = `<c>COMMON CONTROLS</c>
|
||||
<c>\u00B7${'\u00B8'.repeat(15)}\u00B9</c>
|
||||
&bul;<b>!</b> : <O>show this help message</O>
|
||||
&bul;<b>Y</b> : <O>plays the entire song from the current cue</O>
|
||||
&bul;<b>U</b> : <O>plays the current cue then stop</O>
|
||||
&bul;<b>I</b> : <O>plays the current row</O>
|
||||
&bul;<b>O</b> : <O>stops the playback</O>
|
||||
&bul;<b>tab</b> : <O>switchs forward a tab</O>
|
||||
&bul;<b>TAB</b> : <O>switchs backward a tab</O>
|
||||
&bul;<b>q</b> : <O>closes µtone;</O>
|
||||
`
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
let helpTimeline = `<c>TIMELINE VIEW</c>
|
||||
<c>\u00B7${'\u00B8'.repeat(13)}\u00B9</c>
|
||||
Timeline has two distinct modes: view and edit mode. Two modes are toggled using the space bar.
|
||||
|
||||
<b> VIEW MODE</b>
|
||||
<b>\u00B7${'\u00B8'.repeat(9)}\u00B9</b>
|
||||
&bul;Note jamming : <O>plays the note</O>
|
||||
&bul;<b>&udlr;</b> : <O>moves the viewing cursor by voices and rows</O>
|
||||
&bul;<b>pg&updn;</b> : <O>goes to previous/next cue</O>
|
||||
&bul;<b>W</b>&mdot;<b>E</b>&mdot;<b>R</b> : <O>toggles timeline view mode. W-most detailed, R-most abridged</O>
|
||||
&bul;<b>n</b> : <O>toggles soloing of the selected voice</O>
|
||||
&bul;<b>m</b> : <O>toggles muting of the selected voice</O>
|
||||
&bul;<b>[</b>&mdot;<b>]</b> : <O>changes tick rate of playhead</O>
|
||||
|
||||
<b> EDIT MODE</b>
|
||||
<b>\u00B7${'\u00B8'.repeat(9)}\u00B9</b>
|
||||
&bul;Note jamming : <O>(note column) inserts the note</O>
|
||||
&bul;<b>{</b>&mdot;<b>}</b> : <O>(note column) lowers/raises a note by one octave (or period)</O>
|
||||
&bul;<b>[</b>&mdot;<b>]</b> : <O>(note column) lowers/raises a note by one unit</O>
|
||||
&bul;<b>z</b> : <O>(note column) inserts a key-off &keyoffsym;</O>
|
||||
&bul;<b>x</b> : <O>(note column) inserts a note-cut ¬ecutsym;</O>
|
||||
&bul;<b>.</b> : <O>clears fields</O>
|
||||
&bul;<b>bksp</b> : <O>deletes one character on the selected column</O>
|
||||
&bul;<b>0</b>&ddot;<b>9</b> <b>a</b>&ddot;<b>f</b> : <O>inserts a (hexa)decimal number</O>
|
||||
&bul;<b>0</b>&ddot;<b>9</b> <b>a</b>&ddot;<b>z</b> : <O>(fx column) inserts an effect</O>
|
||||
&bul;<b>^</b>&mdot;<b>v</b> : <O>(volume column) slide up/down</O>
|
||||
&bul;<b><</b>&mdot;<b>></b>: <O>(panning column) slide left/right</O>
|
||||
&bul;<b>-</b>&mdot;<b>=</b> : <O>(vol/pan col) fine slide down/up</O>
|
||||
&bul;<b>&udlr;</b> : <O>moves the viewing cursor by columns and rows</O>
|
||||
&bul;<b>pg&updn;</b> : <O>goes to previous/next cue</O>
|
||||
|
||||
<b> ACCIDENTALS</b>
|
||||
<b>\u00B7${'\u00B8'.repeat(11)}\u00B9</b>
|
||||
&demisharp; ♯ &doublesharp; &triplesharp; &quadsharp; &demiflat; ♭ &doubleflat; &tripleflat; &accuptick; &accupup; &accdntick; &accdndn;
|
||||
<b>C c cx x xx B b bb bbb ^ ^^ v vv</b>
|
||||
|
||||
<b> GLOBAL EDIT</b>
|
||||
<b>\u00B7${'\u00B8'.repeat(11)}\u00B9</b>
|
||||
&bul;<b>Q</b> : <O>retunes current song into different tuning and strategy. In general, nearest-note works best for macrotonals, nearest-harmonic and nearest-delta works best for highly microtonals (31+); 17- and 19-TET takes nearest-harmonic pretty well, while 22-TET seem to only benefit from the nearest-note</O>
|
||||
`
|
||||
|
||||
let helpProjectFlags = `<c>MIXER FLAGS</c>
|
||||
<c>\u00B7${'\u00B8'.repeat(11)}\u00B9</c>
|
||||
Mixer flags define how should the mixer behave.
|
||||
|
||||
<b> TONE MODE</b>
|
||||
<b>\u00B7${'\u00B8'.repeat(9)}\u00B9</b>
|
||||
&bul;Linear pitch : <O>pitch shift effects operate on linear pitch scale. The default and recommended setting for a new project</O>
|
||||
&bul;Amiga pitch : <O>pitch shift effects operate on Amiga period scale. Backwards compatible setting for MOD/S3M/XM/IT formats</O>
|
||||
&bul;Linear freq : <O>pitch shift effects operate on linear frequency scale. Backwards compatible setting for MONOTONE format</O>
|
||||
|
||||
<b> INTERPOLATION</b>
|
||||
<b>\u00B7${'\u00B8'.repeat(13)}\u00B9</b>
|
||||
&bul;Default : <O>three-tap fast sinc interpolation. The default and recommended setting for a new project</O>
|
||||
&bul;None : <O>zeroth-order hold</O>
|
||||
&bul;A500 : <O>emulates what Paula chip of Amiga 500 does. <b>S 0x00</b> effects only work with this and Amiga 1200 mode</O>
|
||||
&bul;A1200 : <O>emulates what Paula chip of Amiga 1200 does</O>
|
||||
&bul;SNES : <O>four-tap gaussian interpolation used by SNES</O>
|
||||
&bul;DPCM : <O>simulates Differential Pulse Code Modulation used by NES</O>
|
||||
`
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// assemble help text pieces to complete help message
|
||||
|
||||
const HRULE = '\u00B4\u00B5'.repeat((_G.TAUT.HELPMSG_WIDTH) >>> 1) + '\n'
|
||||
|
||||
// taut.js's popup uses (HELP_COL_TEXT on background) as the default colour pair.
|
||||
// The shared typesetter module owns the palette and the markup expander.
|
||||
function typeset(text) {
|
||||
return ts.typeset(text, _G.TAUT.HELPMSG_WIDTH)
|
||||
}
|
||||
|
||||
let helpMessages = [ // index: taut.js PANEL_NAMES
|
||||
/* Timeline */[helpJam, helpTimeline, helpCommon, helpNotation].join(HRULE),
|
||||
/* Cues */[helpCommon, helpNotation].join(HRULE), // placeholder
|
||||
/* Patterns */[helpCommon, helpNotation].join(HRULE), // placeholder
|
||||
/* Samples */[helpCommon, helpNotation].join(HRULE), // placeholder
|
||||
/* Instruments */[helpCommon, helpNotation].join(HRULE), // placeholder
|
||||
/* Project */[helpProjectFlags, helpCommon, helpNotation].join(HRULE), // placeholder
|
||||
/* File */[helpCommon, helpNotation].join(HRULE), // placeholder
|
||||
]
|
||||
|
||||
help.MSG_BY_TABS = helpMessages.map(it => typeset(it))
|
||||
help.typeset = typeset
|
||||
help.COL_TEXT = ts.COL_TEXT
|
||||
help.COL_EMPH = ts.COL_EMPH
|
||||
|
||||
if (!_G.TAUT.HELPMSG) _G.TAUT.HELPMSG=help;
|
||||
77
assets/disk0/tvdos/bin/taut_instredit.js
Normal file
77
assets/disk0/tvdos/bin/taut_instredit.js
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* TAUT Instrument Editor
|
||||
* Sub-program launched by taut.js when the Instrmnt tab is active.
|
||||
* Rows 1-3 are owned by the parent; this program draws rows 4+.
|
||||
*
|
||||
* exec_args[1] = path to .taud file
|
||||
* Sets _G.TAUT.UI.NEXTPANEL before returning to request a panel switch.
|
||||
*
|
||||
* Created by minjaesong on 2026-04-27
|
||||
*/
|
||||
|
||||
const win = require("wintex")
|
||||
|
||||
const PANEL_COUNT = 7
|
||||
const MY_PANEL = 4 // VIEW_INSTRMNT
|
||||
|
||||
const [SCRH, SCRW] = con.getmaxyx()
|
||||
const PANEL_Y = 4
|
||||
const PANEL_H = SCRH - PANEL_Y
|
||||
|
||||
const colStatus = 253
|
||||
const colContent = 240
|
||||
const colHdr = 230
|
||||
|
||||
function drawInstEditContents(wo) {
|
||||
for (let y = PANEL_Y; y < SCRH; y++) {
|
||||
con.move(y, 1)
|
||||
con.color_pair(colContent, 255)
|
||||
print(' '.repeat(SCRW))
|
||||
}
|
||||
con.move(PANEL_Y + 1, 3)
|
||||
con.color_pair(colHdr, 255)
|
||||
print('[ Instrument Editor ]')
|
||||
con.move(PANEL_Y + 3, 3)
|
||||
con.color_pair(colStatus, 255)
|
||||
print('placeholder — not yet implemented')
|
||||
}
|
||||
|
||||
function drawHints() {
|
||||
con.move(SCRH, 1)
|
||||
con.color_pair(colStatus, 255)
|
||||
print(' '.repeat(SCRW - 1))
|
||||
con.move(SCRH, 1)
|
||||
con.color_pair(colHdr, 255); print('Tab ')
|
||||
con.color_pair(colStatus, 255); print('Panel')
|
||||
}
|
||||
|
||||
function instEditInput(wo, event) {
|
||||
// placeholder — no interaction yet
|
||||
}
|
||||
|
||||
const panel = new win.WindowObject(1, PANEL_Y, SCRW, PANEL_H, instEditInput, drawInstEditContents, undefined, ()=>{})
|
||||
|
||||
panel.drawContents()
|
||||
drawHints()
|
||||
|
||||
let done = false
|
||||
while (!done) {
|
||||
input.withEvent(event => {
|
||||
if (event[0] !== 'key_down') return
|
||||
const keysym = event[1]
|
||||
const keyJustHit = (1 == event[2])
|
||||
const shiftDown = (event.includes(59) || event.includes(60))
|
||||
|
||||
if (!keyJustHit) return
|
||||
|
||||
if (keysym === '<TAB>') {
|
||||
_G.TAUT.UI.NEXTPANEL = (MY_PANEL + (shiftDown ? -1 : 1) + PANEL_COUNT) % PANEL_COUNT
|
||||
done = true
|
||||
return
|
||||
}
|
||||
|
||||
panel.processInput(event)
|
||||
})
|
||||
}
|
||||
|
||||
return 0
|
||||
0
assets/disk0/tvdos/bin/taut_notationedit.js
Normal file
0
assets/disk0/tvdos/bin/taut_notationedit.js
Normal file
181
assets/disk0/tvdos/bin/taut_sampleedit.js
Normal file
181
assets/disk0/tvdos/bin/taut_sampleedit.js
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* TAUT Sample Editor (stub)
|
||||
* Sub-program launched from taut.js's Samples viewer. Rows 1-3 are owned by
|
||||
* the parent; this program draws rows 4+.
|
||||
*
|
||||
* exec_args:
|
||||
* [1] = path to .taud file
|
||||
* [2] = parent panel index (where to return)
|
||||
* [3] = sample index to preload (-1 if none)
|
||||
*
|
||||
* Sets _G.TAUT.UI.NEXTPANEL on return to request a panel switch back.
|
||||
*
|
||||
* Created by minjaesong on 2026-04-27
|
||||
* Stub editing UI added on 2026-05-26
|
||||
*/
|
||||
|
||||
const win = require("wintex")
|
||||
|
||||
const PARENT_PANEL = (exec_args[2] !== undefined) ? (exec_args[2] | 0) : 3 // VIEW_SAMPLES
|
||||
const SAMPLE_IDX = (exec_args[3] !== undefined) ? (exec_args[3] | 0) : -1
|
||||
|
||||
const [SCRH, SCRW] = con.getmaxyx()
|
||||
const PANEL_Y = 4
|
||||
const PANEL_H = SCRH - PANEL_Y
|
||||
|
||||
const colStatus = 253
|
||||
const colContent = 240
|
||||
const colHdr = 230
|
||||
const colEmph = 211
|
||||
const colDim = 246
|
||||
const colBack = 255
|
||||
const colSel = 41
|
||||
|
||||
// Stub editor "fields": pretend toolbar. None of these write anything yet.
|
||||
const TOOLS = [
|
||||
{ key: 'L', label: 'Load .raw / .wav from disk' },
|
||||
{ key: 'S', label: 'Save current sample to disk' },
|
||||
{ key: 'D', label: 'Draw waveform freehand' },
|
||||
{ key: 'X', label: 'Crop / trim selection' },
|
||||
{ key: 'R', label: 'Resample' },
|
||||
{ key: 'V', label: 'Reverse' },
|
||||
{ key: 'N', label: 'Normalise to peak' },
|
||||
{ key: 'F', label: 'Fade in / out' },
|
||||
]
|
||||
|
||||
let toolCursor = 0
|
||||
|
||||
function drawSampleEditFrame() {
|
||||
for (let y = PANEL_Y; y < SCRH; y++) {
|
||||
con.move(y, 1)
|
||||
con.color_pair(colContent, colBack)
|
||||
print(' '.repeat(SCRW))
|
||||
}
|
||||
// Title
|
||||
con.move(PANEL_Y + 1, 3)
|
||||
con.color_pair(colHdr, colBack); print('[ Sample Editor ] ')
|
||||
con.color_pair(colEmph, colBack); print('Sample ')
|
||||
con.color_pair(colStatus, colBack)
|
||||
if (SAMPLE_IDX >= 0) print('#' + (SAMPLE_IDX + 1).toString(16).toUpperCase().padStart(2, '0'))
|
||||
else print('(none)')
|
||||
|
||||
con.move(PANEL_Y + 2, 3)
|
||||
con.color_pair(colDim, colBack)
|
||||
print('stub editor — actions below are placeholders only.')
|
||||
}
|
||||
|
||||
function drawToolList() {
|
||||
const x = 5
|
||||
const y0 = PANEL_Y + 4
|
||||
con.move(y0, x)
|
||||
con.color_pair(colHdr, colBack); print('Editing actions')
|
||||
con.move(y0 + 1, x)
|
||||
con.color_pair(colDim, colBack); print('-'.repeat(16))
|
||||
|
||||
for (let i = 0; i < TOOLS.length; i++) {
|
||||
const y = y0 + 3 + i
|
||||
const t = TOOLS[i]
|
||||
const sel = (i === toolCursor)
|
||||
const back = sel ? colSel : colBack
|
||||
con.move(y, x)
|
||||
con.color_pair(colHdr, back); print(' ' + t.key + ' ')
|
||||
con.color_pair(colStatus, back); print(' ')
|
||||
con.color_pair(sel ? colEmph : colStatus, back)
|
||||
const w = SCRW - x - 6
|
||||
const lbl = t.label.length > w ? t.label.substring(0, w) : t.label.padEnd(w)
|
||||
print(lbl)
|
||||
}
|
||||
|
||||
// Drawing-area placeholder on the right
|
||||
const dx = 38
|
||||
const dy0 = PANEL_Y + 4
|
||||
const dw = SCRW - dx - 2
|
||||
const dh = SCRH - dy0 - 2
|
||||
con.move(dy0, dx)
|
||||
con.color_pair(colHdr, colBack); print('Waveform editor')
|
||||
con.move(dy0 + 1, dx)
|
||||
con.color_pair(colDim, colBack); print('-'.repeat(16))
|
||||
|
||||
// Empty drawing rectangle made of dots
|
||||
for (let r = 0; r < dh; r++) {
|
||||
con.move(dy0 + 3 + r, dx)
|
||||
con.color_pair(colDim, colBack)
|
||||
if (r === (dh >>> 1)) print('-'.repeat(dw)) // zero line
|
||||
else print(' '.repeat(dw))
|
||||
}
|
||||
con.move(dy0 + 3 + (dh >>> 1) + 1, dx)
|
||||
con.color_pair(colDim, colBack)
|
||||
print('(drawing surface — not yet implemented)')
|
||||
}
|
||||
|
||||
function drawHints() {
|
||||
con.move(SCRH, 1)
|
||||
con.color_pair(colStatus, colBack)
|
||||
print(' '.repeat(SCRW - 1))
|
||||
con.move(SCRH, 1)
|
||||
con.color_pair(colHdr, colBack); print('28u29u ')
|
||||
con.color_pair(colStatus, colBack); print('Tool ')
|
||||
con.color_pair(colHdr, colBack); print('Enter ')
|
||||
con.color_pair(colStatus, colBack); print('Apply ')
|
||||
con.color_pair(colHdr, colBack); print('Esc/Tab ')
|
||||
con.color_pair(colStatus, colBack); print('Back to viewer')
|
||||
}
|
||||
|
||||
function flashAction(idx) {
|
||||
const t = TOOLS[idx]
|
||||
if (!t) return
|
||||
con.move(SCRH - 2, 5)
|
||||
con.color_pair(colEmph, colBack)
|
||||
print(('Action: ' + t.label + ' (stub, no-op)').padEnd(SCRW - 8))
|
||||
}
|
||||
|
||||
function sampleEditInput(wo, event) {
|
||||
// wintex panel input — wired up but the loop below handles keys directly.
|
||||
}
|
||||
|
||||
function drawAll() {
|
||||
drawSampleEditFrame()
|
||||
drawToolList()
|
||||
drawHints()
|
||||
}
|
||||
|
||||
const panel = new win.WindowObject(1, PANEL_Y, SCRW, PANEL_H, sampleEditInput, drawAll, undefined, ()=>{})
|
||||
|
||||
panel.drawContents()
|
||||
|
||||
let done = false
|
||||
while (!done) {
|
||||
input.withEvent(event => {
|
||||
if (event[0] !== 'key_down') return
|
||||
const keysym = event[1]
|
||||
const keyJustHit = (1 == event[2])
|
||||
|
||||
if (!keyJustHit) return
|
||||
|
||||
if (keysym === '<ESCAPE>' || keysym === '<TAB>') {
|
||||
_G.TAUT.UI.NEXTPANEL = PARENT_PANEL
|
||||
done = true
|
||||
return
|
||||
}
|
||||
|
||||
if (keysym === '<UP>') { if (toolCursor > 0) toolCursor--; drawToolList(); return }
|
||||
if (keysym === '<DOWN>') { if (toolCursor < TOOLS.length-1) toolCursor++; drawToolList(); return }
|
||||
|
||||
if (keysym === '\n') {
|
||||
flashAction(toolCursor)
|
||||
return
|
||||
}
|
||||
|
||||
// Direct key shortcuts
|
||||
for (let i = 0; i < TOOLS.length; i++) {
|
||||
if (keysym === TOOLS[i].key.toLowerCase() || keysym === TOOLS[i].key) {
|
||||
toolCursor = i
|
||||
drawToolList()
|
||||
flashAction(i)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return 0
|
||||
BIN
assets/disk0/tvdos/bin/tautbtn.png
Normal file
BIN
assets/disk0/tvdos/bin/tautbtn.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 518 B |
1
assets/disk0/tvdos/bin/tautbtn.r8
Normal file
1
assets/disk0/tvdos/bin/tautbtn.r8
Normal file
@@ -0,0 +1 @@
|
||||
<EFBFBD><EFBFBD>洄洄洄洄洄泚<EFBFBD>泚<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>_<EFBFBD>_
|
||||
1
assets/disk0/tvdos/bin/tautbtn0.r8
Normal file
1
assets/disk0/tvdos/bin/tautbtn0.r8
Normal file
@@ -0,0 +1 @@
|
||||
<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
|
||||
BIN
assets/disk0/tvdos/bin/tautfont.kra
LFS
Normal file
BIN
assets/disk0/tvdos/bin/tautfont.kra
LFS
Normal file
Binary file not shown.
BIN
assets/disk0/tvdos/bin/tautfont_high.chr
Normal file
BIN
assets/disk0/tvdos/bin/tautfont_high.chr
Normal file
Binary file not shown.
BIN
assets/disk0/tvdos/bin/tautfont_low.chr
Normal file
BIN
assets/disk0/tvdos/bin/tautfont_low.chr
Normal file
Binary file not shown.
BIN
assets/disk0/tvdos/bin/tauthdr.png
Normal file
BIN
assets/disk0/tvdos/bin/tauthdr.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 490 B |
BIN
assets/disk0/tvdos/bin/tauthdr.r8
Normal file
BIN
assets/disk0/tvdos/bin/tauthdr.r8
Normal file
Binary file not shown.
File diff suppressed because it is too large
Load Diff
11
assets/disk0/tvdos/hopper/getopt.hop.per
Normal file
11
assets/disk0/tvdos/hopper/getopt.hop.per
Normal file
@@ -0,0 +1,11 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:getopt
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:getopt;
|
||||
HopperRequires:
|
||||
ProperName:getopt.js
|
||||
ProperAuthor:David Pacheco
|
||||
ProperDescription:node.js implementation of POSIX getopt() (and then some)
|
||||
Licence:MIT
|
||||
SystemPackagePath:/tvdos/include/getopt.mjs
|
||||
12
assets/disk0/tvdos/hopper/libfs.hop.per
Normal file
12
assets/disk0/tvdos/hopper/libfs.hop.per
Normal file
@@ -0,0 +1,12 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:libfs
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:libfs;
|
||||
HopperRequires:tvdos 1.*;
|
||||
ProperName:LibFS
|
||||
ProperAuthor:CuriousTorvald
|
||||
ProperDescription:NodeJS-compatible Filesystem module for TVDOS
|
||||
Licence:MIT
|
||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||
SystemPackagePath:/tvdos/include/fs.mjs
|
||||
12
assets/disk0/tvdos/hopper/libgl.hop.per
Normal file
12
assets/disk0/tvdos/hopper/libgl.hop.per
Normal file
@@ -0,0 +1,12 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:libgl
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:libgl;
|
||||
HopperRequires:
|
||||
ProperName:LibGL
|
||||
ProperAuthor:CuriousTorvald
|
||||
ProperDescription:TVDOS Graphics Library
|
||||
Licence:MIT
|
||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||
SystemPackagePath:/tvdos/include/gl.mjs
|
||||
12
assets/disk0/tvdos/hopper/libpcm.hop.per
Normal file
12
assets/disk0/tvdos/hopper/libpcm.hop.per
Normal file
@@ -0,0 +1,12 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:libpcm
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:libpcm;
|
||||
HopperRequires:
|
||||
ProperName:LibPCM
|
||||
ProperAuthor:CuriousTorvald
|
||||
ProperDescription:PCM decoder for TSVM
|
||||
Licence:MIT
|
||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||
SystemPackagePath:/tvdos/include/pcm.mjs
|
||||
12
assets/disk0/tvdos/hopper/libpsg.hop.per
Normal file
12
assets/disk0/tvdos/hopper/libpsg.hop.per
Normal file
@@ -0,0 +1,12 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:libpsg
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:libpsg;
|
||||
HopperRequires:
|
||||
ProperName:LibPSG
|
||||
ProperAuthor:CuriousTorvald
|
||||
ProperDescription:Programmable sound generator library for TSVM
|
||||
Licence:MIT
|
||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||
SystemPackagePath:/tvdos/include/psg.mjs
|
||||
12
assets/disk0/tvdos/hopper/libseqread.hop.per
Normal file
12
assets/disk0/tvdos/hopper/libseqread.hop.per
Normal file
@@ -0,0 +1,12 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:libseqread
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:libseqread;
|
||||
HopperRequires:tvdos 1.*;
|
||||
ProperName:LibSeqread
|
||||
ProperAuthor:CuriousTorvald
|
||||
ProperDescription:Sequentially read files from disk drive
|
||||
Licence:MIT
|
||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||
SystemPackagePath:/tvdos/include/seqread.mjs;/tvdos/include/seqreadtape.mjs
|
||||
12
assets/disk0/tvdos/hopper/libtaud.hop.per
Normal file
12
assets/disk0/tvdos/hopper/libtaud.hop.per
Normal file
@@ -0,0 +1,12 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:libtaud
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:libtaud;
|
||||
HopperRequires:tvdos 1.*;
|
||||
ProperName:LibTaud
|
||||
ProperAuthor:CuriousTorvald
|
||||
ProperDescription:Helper functions for interaction between Taud format and TSVM Tracker
|
||||
Licence:MIT
|
||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||
SystemPackagePath:/tvdos/include/taud.mjs
|
||||
12
assets/disk0/tvdos/hopper/libterranbasic.hop.per
Normal file
12
assets/disk0/tvdos/hopper/libterranbasic.hop.per
Normal file
@@ -0,0 +1,12 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:libterranbasic
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:libterranbasic;
|
||||
HopperRequires:
|
||||
ProperName:LibTerranBasic
|
||||
ProperAuthor:CuriousTorvald
|
||||
ProperDescription:Terran BASIC runtime helper for compiled programs
|
||||
Licence:MIT
|
||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||
SystemPackagePath:/tvdos/include/tbas.mjs
|
||||
12
assets/disk0/tvdos/hopper/microtone.hop.per
Normal file
12
assets/disk0/tvdos/hopper/microtone.hop.per
Normal file
@@ -0,0 +1,12 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:microtone
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:microtone;
|
||||
HopperRequires:tvdos 1.*;wintex 1.*;libtaud 1.*;libgl 1.*
|
||||
ProperName:Microtone
|
||||
ProperAuthor:CuriousTorvald
|
||||
ProperDescription:Microtonal tracker for TSVM
|
||||
Licence:MIT
|
||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||
SystemPackagePath:/tvdos/bin/microtone.alias;/tvdos/bin/taut*
|
||||
17
assets/disk0/tvdos/hopper/mirrors.list
Normal file
17
assets/disk0/tvdos/hopper/mirrors.list
Normal file
@@ -0,0 +1,17 @@
|
||||
# Hopper Mirror List
|
||||
#
|
||||
# One mirror per non-empty, non-comment line.
|
||||
# Each entry is the remote URL prefix from which Hopper can fetch
|
||||
# <prefix>mirror_manifest
|
||||
# <prefix>filelist
|
||||
# <prefix><package>.hop.per (one per row of filelist)
|
||||
#
|
||||
# `mirror_manifest` declares HopperMirrorName, HopperMirrorMaintainer
|
||||
# and HopperMirrorRemotePrefix; `filelist` is CSV of
|
||||
# packagename,version,hoppermanifest-filename
|
||||
#
|
||||
# Lines starting with `#` and empty lines are ignored.
|
||||
# A trailing slash on the prefix is optional; Hopper will add one
|
||||
# if missing.
|
||||
|
||||
https://raw.githubusercontent.com/curioustorvald/hopper-mirror/refs/heads/master/
|
||||
12
assets/disk0/tvdos/hopper/textedit.hop.per
Normal file
12
assets/disk0/tvdos/hopper/textedit.hop.per
Normal file
@@ -0,0 +1,12 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:textedit
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:edit;
|
||||
HopperRequires:tvdos 1.*
|
||||
ProperName:edit.js
|
||||
ProperAuthor:CuriousTorvald
|
||||
ProperDescription:TVDOS default text editor
|
||||
Licence:MIT
|
||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||
SystemPackagePath:/tvdos/bin/edit.js
|
||||
12
assets/disk0/tvdos/hopper/tvdos.hop.per
Normal file
12
assets/disk0/tvdos/hopper/tvdos.hop.per
Normal file
@@ -0,0 +1,12 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:tvdos
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:tvdos;
|
||||
HopperRequires:
|
||||
ProperName:TVDOS
|
||||
ProperAuthor:CuriousTorvald
|
||||
ProperDescription:TSVM Disk Operating System
|
||||
Licence:MIT
|
||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||
SystemPackagePath:/tvdos/TVDOS.SYS;/tvdos/hyve.SYS;/tvdos/HSDPADRV.SYS;/tvdos/bin/command.js;/tvdos/sbin/sysctl.js;/tvdos/include/font.mjs;/tvdos/include/keysym.mjs;/tvdos/include/mload.mjs;/tvdos/include/playgui.mjs;/tvdos/include/typesetter.mjs
|
||||
12
assets/disk0/tvdos/hopper/wintex.hop.per
Normal file
12
assets/disk0/tvdos/hopper/wintex.hop.per
Normal file
@@ -0,0 +1,12 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:wintex
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:wintex;
|
||||
HopperRequires:
|
||||
ProperName:WinTex
|
||||
ProperAuthor:CuriousTorvald
|
||||
ProperDescription:TUI window management and renderer
|
||||
Licence:MIT
|
||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||
SystemPackagePath:/tvdos/include/wintex.mjs
|
||||
12
assets/disk0/tvdos/hopper/zfm.hop.per
Normal file
12
assets/disk0/tvdos/hopper/zfm.hop.per
Normal file
@@ -0,0 +1,12 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:zfm
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:zfm;
|
||||
HopperRequires:tvdos 1.*;wintex 1.*
|
||||
ProperName:ZFM
|
||||
ProperAuthor:CuriousTorvald
|
||||
ProperDescription:Z File Manager - Dual-panel file manager for TVDOS
|
||||
Licence:MIT
|
||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||
SystemPackagePath:/tvdos/bin/zfm*
|
||||
31
assets/disk0/tvdos/include/font.mjs
Normal file
31
assets/disk0/tvdos/include/font.mjs
Normal file
@@ -0,0 +1,31 @@
|
||||
function setHighRom(fullPath) {
|
||||
const fontFile = files.open(fullPath)
|
||||
|
||||
// upload font
|
||||
const fontData = fontFile.bread()
|
||||
for (let i = 0; i < 1920; i++) sys.poke(-133121 - i, fontData[i])
|
||||
sys.poke(-1299460, 19) // write to high rom
|
||||
|
||||
fontFile.close()
|
||||
}
|
||||
|
||||
function setLowRom(fullPath) {
|
||||
const fontFile = files.open(fullPath)
|
||||
|
||||
// upload font
|
||||
const fontData = fontFile.bread()
|
||||
for (let i = 0; i < 1920; i++) sys.poke(-133121 - i, fontData[i])
|
||||
sys.poke(-1299460, 18) // write to low rom
|
||||
|
||||
fontFile.close()
|
||||
}
|
||||
|
||||
function resetHighRom() {
|
||||
sys.poke(-1299460, 21)
|
||||
}
|
||||
|
||||
function resetLowRom() {
|
||||
sys.poke(-1299460, 20)
|
||||
}
|
||||
|
||||
exports = { setHighRom, setLowRom, resetHighRom, resetLowRom }
|
||||
1129
assets/disk0/tvdos/include/fs.mjs
Normal file
1129
assets/disk0/tvdos/include/fs.mjs
Normal file
File diff suppressed because it is too large
Load Diff
305
assets/disk0/tvdos/include/getopt.mjs
Normal file
305
assets/disk0/tvdos/include/getopt.mjs
Normal file
@@ -0,0 +1,305 @@
|
||||
/*
|
||||
* getopt.js: node.js implementation of POSIX getopt() (and then some)
|
||||
*
|
||||
* Copyright 2011 David Pacheco. All rights reserved.
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
var ASSERT = require('assert').ok;
|
||||
|
||||
function goError(msg)
|
||||
{
|
||||
return (new Error('getopt: ' + msg));
|
||||
}
|
||||
|
||||
/*
|
||||
* The BasicParser is our primary interface to the outside world. The
|
||||
* documentation for this object and its public methods is contained in
|
||||
* the included README.md.
|
||||
*/
|
||||
function goBasicParser(optstring, argv, optind)
|
||||
{
|
||||
var ii;
|
||||
|
||||
ASSERT(optstring || optstring === '', 'optstring is required');
|
||||
ASSERT(optstring.constructor === String, 'optstring must be a string');
|
||||
ASSERT(argv, 'argv is required');
|
||||
ASSERT(argv.constructor === Array, 'argv must be an array');
|
||||
|
||||
this.gop_argv = new Array(argv.length);
|
||||
this.gop_options = {};
|
||||
this.gop_aliases = {};
|
||||
this.gop_optind = optind !== undefined ? optind : 2;
|
||||
this.gop_subind = 0;
|
||||
|
||||
for (ii = 0; ii < argv.length; ii++) {
|
||||
ASSERT(argv[ii].constructor === String,
|
||||
'argv must be string array');
|
||||
this.gop_argv[ii] = argv[ii];
|
||||
}
|
||||
|
||||
this.parseOptstr(optstring);
|
||||
}
|
||||
|
||||
exports.BasicParser = goBasicParser;
|
||||
|
||||
/*
|
||||
* Parse the option string and update the following fields:
|
||||
*
|
||||
* gop_silent Whether to log errors to stderr. Silent mode is
|
||||
* indicated by a leading ':' in the option string.
|
||||
*
|
||||
* gop_options Maps valid single-letter-options to booleans indicating
|
||||
* whether each option is required.
|
||||
*
|
||||
* gop_aliases Maps valid long options to the corresponding
|
||||
* single-letter short option.
|
||||
*/
|
||||
goBasicParser.prototype.parseOptstr = function (optstr)
|
||||
{
|
||||
var chr, cp, alias, arg, ii;
|
||||
|
||||
ii = 0;
|
||||
if (optstr.length > 0 && optstr[0] == ':') {
|
||||
this.gop_silent = true;
|
||||
ii++;
|
||||
} else {
|
||||
this.gop_silent = false;
|
||||
}
|
||||
|
||||
while (ii < optstr.length) {
|
||||
chr = optstr[ii];
|
||||
arg = false;
|
||||
|
||||
if (!/^[\w\d\u1000-\u1100]$/.test(chr))
|
||||
throw (goError('invalid optstring: only alphanumeric ' +
|
||||
'characters and unicode characters between ' +
|
||||
'\\u1000-\\u1100 may be used as options: ' + chr));
|
||||
|
||||
if (ii + 1 < optstr.length && optstr[ii + 1] == ':') {
|
||||
arg = true;
|
||||
ii++;
|
||||
}
|
||||
|
||||
this.gop_options[chr] = arg;
|
||||
|
||||
while (ii + 1 < optstr.length && optstr[ii + 1] == '(') {
|
||||
ii++;
|
||||
cp = optstr.indexOf(')', ii + 1);
|
||||
if (cp == -1)
|
||||
throw (goError('invalid optstring: missing ' +
|
||||
'")" to match "(" at char ' + ii));
|
||||
|
||||
alias = optstr.substring(ii + 1, cp);
|
||||
this.gop_aliases[alias] = chr;
|
||||
ii = cp;
|
||||
}
|
||||
|
||||
ii++;
|
||||
}
|
||||
};
|
||||
|
||||
goBasicParser.prototype.optind = function ()
|
||||
{
|
||||
return (this.gop_optind);
|
||||
};
|
||||
|
||||
/*
|
||||
* For documentation on what getopt() does, see README.md. The following
|
||||
* implementation invariants are maintained by getopt() and its helper methods:
|
||||
*
|
||||
* this.gop_optind Refers to the element of gop_argv that contains
|
||||
* the next argument to be processed. This may
|
||||
* exceed gop_argv, in which case the end of input
|
||||
* has been reached.
|
||||
*
|
||||
* this.gop_subind Refers to the character inside
|
||||
* this.gop_options[this.gop_optind] which begins
|
||||
* the next option to be processed. This may never
|
||||
* exceed the length of gop_argv[gop_optind], so
|
||||
* when incrementing this value we must always
|
||||
* check if we should instead increment optind and
|
||||
* reset subind to 0.
|
||||
*
|
||||
* That is, when any of these functions is entered, the above indices' values
|
||||
* are as described above. getopt() itself and getoptArgument() may both be
|
||||
* called at the end of the input, so they check whether optind exceeds
|
||||
* argv.length. getoptShort() and getoptLong() are called only when the indices
|
||||
* already point to a valid short or long option, respectively.
|
||||
*
|
||||
* getopt() processes the next option as follows:
|
||||
*
|
||||
* o If gop_optind > gop_argv.length, then we already parsed all arguments.
|
||||
*
|
||||
* o If gop_subind == 0, then we're looking at the start of an argument:
|
||||
*
|
||||
* o Check for special cases like '-', '--', and non-option arguments.
|
||||
* If present, update the indices and return the appropriate value.
|
||||
*
|
||||
* o Check for a long-form option (beginning with '--'). If present,
|
||||
* delegate to getoptLong() and return the result.
|
||||
*
|
||||
* o Otherwise, advance subind past the argument's leading '-' and
|
||||
* continue as though gop_subind != 0 (since that's now the case).
|
||||
*
|
||||
* o Delegate to getoptShort() and return the result.
|
||||
*/
|
||||
goBasicParser.prototype.getopt = function ()
|
||||
{
|
||||
if (this.gop_optind >= this.gop_argv.length)
|
||||
/* end of input */
|
||||
return (undefined);
|
||||
|
||||
var arg = this.gop_argv[this.gop_optind];
|
||||
|
||||
if (this.gop_subind === 0) {
|
||||
if (arg == '-' || arg === '' || arg[0] != '-')
|
||||
return (undefined);
|
||||
|
||||
if (arg == '--') {
|
||||
this.gop_optind++;
|
||||
this.gop_subind = 0;
|
||||
return (undefined);
|
||||
}
|
||||
|
||||
if (arg[1] == '-')
|
||||
return (this.getoptLong());
|
||||
|
||||
this.gop_subind++;
|
||||
ASSERT(this.gop_subind < arg.length);
|
||||
}
|
||||
|
||||
return (this.getoptShort());
|
||||
};
|
||||
|
||||
/*
|
||||
* Implements getopt() for the case where optind/subind point to a short option.
|
||||
*/
|
||||
goBasicParser.prototype.getoptShort = function ()
|
||||
{
|
||||
var arg, chr;
|
||||
|
||||
ASSERT(this.gop_optind < this.gop_argv.length);
|
||||
arg = this.gop_argv[this.gop_optind];
|
||||
ASSERT(this.gop_subind < arg.length);
|
||||
chr = arg[this.gop_subind];
|
||||
|
||||
if (++this.gop_subind >= arg.length) {
|
||||
this.gop_optind++;
|
||||
this.gop_subind = 0;
|
||||
}
|
||||
|
||||
if (!(chr in this.gop_options))
|
||||
return (this.errInvalidOption(chr));
|
||||
|
||||
if (!this.gop_options[chr])
|
||||
return ({ option: chr });
|
||||
|
||||
return (this.getoptArgument(chr));
|
||||
};
|
||||
|
||||
/*
|
||||
* Implements getopt() for the case where optind/subind point to a long option.
|
||||
*/
|
||||
goBasicParser.prototype.getoptLong = function ()
|
||||
{
|
||||
var arg, alias, chr, eq;
|
||||
|
||||
ASSERT(this.gop_subind === 0);
|
||||
ASSERT(this.gop_optind < this.gop_argv.length);
|
||||
arg = this.gop_argv[this.gop_optind];
|
||||
ASSERT(arg.length > 2 && arg[0] == '-' && arg[1] == '-');
|
||||
|
||||
eq = arg.indexOf('=');
|
||||
alias = arg.substring(2, eq == -1 ? arg.length : eq);
|
||||
if (!(alias in this.gop_aliases))
|
||||
return (this.errInvalidOption(alias));
|
||||
|
||||
chr = this.gop_aliases[alias];
|
||||
ASSERT(chr in this.gop_options);
|
||||
|
||||
if (!this.gop_options[chr]) {
|
||||
if (eq != -1)
|
||||
return (this.errExtraArg(alias));
|
||||
|
||||
this.gop_optind++; /* eat this argument */
|
||||
return ({ option: chr });
|
||||
}
|
||||
|
||||
/*
|
||||
* Advance optind/subind for the argument value and retrieve it.
|
||||
*/
|
||||
if (eq == -1)
|
||||
this.gop_optind++;
|
||||
else
|
||||
this.gop_subind = eq + 1;
|
||||
|
||||
return (this.getoptArgument(chr));
|
||||
};
|
||||
|
||||
/*
|
||||
* For the given option letter 'chr' that takes an argument, assumes that
|
||||
* optind/subind point to the argument (or denote the end of input) and return
|
||||
* the appropriate getopt() return value for this option and argument (or return
|
||||
* the appropriate error).
|
||||
*/
|
||||
goBasicParser.prototype.getoptArgument = function (chr)
|
||||
{
|
||||
var arg;
|
||||
|
||||
if (this.gop_optind >= this.gop_argv.length)
|
||||
return (this.errMissingArg(chr));
|
||||
|
||||
arg = this.gop_argv[this.gop_optind].substring(this.gop_subind);
|
||||
this.gop_optind++;
|
||||
this.gop_subind = 0;
|
||||
return ({ option: chr, optarg: arg });
|
||||
};
|
||||
|
||||
goBasicParser.prototype.errMissingArg = function (chr)
|
||||
{
|
||||
if (this.gop_silent)
|
||||
return ({ option: ':', optopt: chr });
|
||||
|
||||
process.stderr.write('option requires an argument -- ' + chr + '\n');
|
||||
return ({ option: '?', optopt: chr, error: true });
|
||||
};
|
||||
|
||||
goBasicParser.prototype.errInvalidOption = function (chr)
|
||||
{
|
||||
if (!this.gop_silent)
|
||||
process.stderr.write('illegal option -- ' + chr + '\n');
|
||||
|
||||
return ({ option: '?', optopt: chr, error: true });
|
||||
};
|
||||
|
||||
/*
|
||||
* This error is not specified by POSIX, but neither is the notion of specifying
|
||||
* long option arguments using "=" in the same argv-argument, but it's common
|
||||
* practice and pretty convenient.
|
||||
*/
|
||||
goBasicParser.prototype.errExtraArg = function (chr)
|
||||
{
|
||||
if (!this.gop_silent)
|
||||
process.stderr.write('option expects no argument -- ' +
|
||||
chr + '\n');
|
||||
|
||||
return ({ option: '?', optopt: chr, error: true });
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
/*
|
||||
TVDOS Graphics Library
|
||||
|
||||
Has no affiliation with OpenGL by Khronos Group
|
||||
/**
|
||||
* LibGL — TVDOS Graphics Library
|
||||
* Has no affiliation with OpenGL by Khronos Group
|
||||
* @author CuriousTorvald
|
||||
*/
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ exports.SpriteSheet = function(tilew, tileh, tex) {
|
||||
return ty;
|
||||
};
|
||||
};
|
||||
exports.drawTexPattern = function(texture, x, y, width, height, framebuffer, fgcol, bgcol) {
|
||||
exports.drawTexPattern = function(texture, x, y, width = texture.width, height = texture.height, framebuffer = false, fgcol, bgcol) {
|
||||
if (!(texture instanceof exports.Texture) && !(texture instanceof exports.MonoTex)) throw Error("Texture is not a GL Texture types");
|
||||
|
||||
let paint = (!framebuffer) ? graphics.plotPixel : graphics.plotPixel2
|
||||
|
||||
80
assets/disk0/tvdos/include/keysym.mjs
Normal file
80
assets/disk0/tvdos/include/keysym.mjs
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* These are key symbols returned by `input.withEvent`, NOT `con.getch()`
|
||||
*/
|
||||
|
||||
exports = {
|
||||
NUM_0:7,
|
||||
NUM_1:8,
|
||||
NUM_2:9,
|
||||
NUM_3:10,
|
||||
NUM_4:11,
|
||||
NUM_5:12,
|
||||
NUM_6:13,
|
||||
NUM_7:14,
|
||||
NUM_8:15,
|
||||
NUM_9:16,
|
||||
A:29,
|
||||
ALT_LEFT:57,
|
||||
ALT_RIGHT:58,
|
||||
APOSTROPHE:75,
|
||||
AT:77,
|
||||
B:30,
|
||||
BACK:4,
|
||||
BACKSLASH:73,
|
||||
C:31,
|
||||
CAPS_LOCK:115,
|
||||
COMMA:55,
|
||||
D:32,
|
||||
DEL:67,
|
||||
BACKSPACE:67,
|
||||
FORWARD_DEL:112,
|
||||
DOWN:20,
|
||||
LEFT:21,
|
||||
RIGHT:22,
|
||||
UP:19,
|
||||
E:33,
|
||||
ENTER:66,
|
||||
EQUALS:70,
|
||||
F:34,
|
||||
G:35,
|
||||
GRAVE:68,
|
||||
H:36,
|
||||
HOME:3,
|
||||
I:37,
|
||||
J:38,
|
||||
K:39,
|
||||
L:40,
|
||||
LEFT_BRACKET:71,
|
||||
M:41,
|
||||
MINUS:69,
|
||||
N:42,
|
||||
O:43,
|
||||
P:44,
|
||||
PERIOD:56,
|
||||
PLUS:81,
|
||||
Q:45,
|
||||
R:46,
|
||||
RIGHT_BRACKET:72,
|
||||
S:47,
|
||||
SEMICOLON:74,
|
||||
SHIFT_LEFT:59,
|
||||
SHIFT_RIGHT:60,
|
||||
SLASH:76,
|
||||
SPACE:62,
|
||||
SYM:63, // on MacOS, this is Command (⌘)
|
||||
T:48,
|
||||
TAB:61,
|
||||
U:49,
|
||||
V:50,
|
||||
W:51,
|
||||
X:52,
|
||||
Y:53,
|
||||
Z:54,
|
||||
CONTROL_LEFT:129,
|
||||
CONTROL_RIGHT:130,
|
||||
ESCAPE:111,
|
||||
END:123,
|
||||
INSERT:124,
|
||||
PAGE_UP:92,
|
||||
PAGE_DOWN:93,
|
||||
}
|
||||
171
assets/disk0/tvdos/include/lfs.mjs
Normal file
171
assets/disk0/tvdos/include/lfs.mjs
Normal file
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* lfs.mjs — programmatic extractor for TVDOS Linear File Strip archives.
|
||||
*
|
||||
* let lfs = require("A:/tvdos/include/lfs.mjs")
|
||||
*
|
||||
* // Pull one entry out:
|
||||
* let fd = lfs.extractOne("A:/path/archive.lfs", "wanted.bin")
|
||||
* // → file descriptor for $:/TMP/<random>/wanted.bin
|
||||
*
|
||||
* // Unpack the whole archive:
|
||||
* let dir = lfs.extractAll("A:/path/archive.lfs")
|
||||
* // → directory descriptor for $:/TMP/<random>/
|
||||
*
|
||||
* Both functions accept an `autoDecompress` boolean (default true). When
|
||||
* a payload's first four bytes match the gzip (1F 8B 08 xx) or zstd
|
||||
* (28 B5 2F FD) magic, the payload is inflated through gzip.decomp()
|
||||
* before being written. The check is done on the payload bytes — the
|
||||
* archived filename is irrelevant.
|
||||
*
|
||||
* Both functions require a relative-path archive (one produced by
|
||||
* `lfs -c -r`); fully qualified archives carry drive letters that would
|
||||
* not make sense rerooted under $:/TMP.
|
||||
*/
|
||||
|
||||
const TMP_ROOT = "$:/TMP"
|
||||
const HASH_ALPHABET = "YBNDRFG8EJKMCPQXOTLVWIS2A345H769"
|
||||
const HASH_LEN = 32
|
||||
const LFS_HEADER = "TVDOSLFS\x01"
|
||||
const LFS_HEADER_LEN = 16
|
||||
const LFS_FLAG_RELATIVE = 0x01
|
||||
|
||||
|
||||
function _makeHash(n) {
|
||||
let s = ""
|
||||
const m = HASH_ALPHABET.length
|
||||
for (let i = 0; i < n; i++) s += HASH_ALPHABET[Math.floor(Math.random() * m)]
|
||||
return s
|
||||
}
|
||||
|
||||
function _isCompressed(s) {
|
||||
if (s.length < 4) return false
|
||||
const b0 = s.charCodeAt(0), b1 = s.charCodeAt(1)
|
||||
const b2 = s.charCodeAt(2), b3 = s.charCodeAt(3)
|
||||
if (b0 === 0x1f && b1 === 0x8b && b2 === 0x08) return true // gzip
|
||||
if (b0 === 0x28 && b1 === 0xb5 && b2 === 0x2f && b3 === 0xfd) return true // zstd
|
||||
return false
|
||||
}
|
||||
|
||||
function _decompress(payload) {
|
||||
// gzip.decomp transparently handles both gzip and zstd; returns Java byte[].
|
||||
return btostr(gzip.decomp(payload))
|
||||
}
|
||||
|
||||
function _readArchive(lfsPath) {
|
||||
const fd = files.open(lfsPath)
|
||||
if (!fd.exists) throw new Error("LFS archive not found: " + lfsPath)
|
||||
if (fd.isDirectory) throw new Error("LFS archive is a directory: " + lfsPath)
|
||||
|
||||
const bytes = fd.sread()
|
||||
try { fd.close() } catch (_) {}
|
||||
|
||||
if (bytes.substring(0, LFS_HEADER.length) !== LFS_HEADER)
|
||||
throw new Error("Not an LFS archive: " + lfsPath)
|
||||
|
||||
const flags = bytes.charCodeAt(11)
|
||||
if ((flags & LFS_FLAG_RELATIVE) === 0)
|
||||
throw new Error("LFS archive does not use relative paths: " + lfsPath)
|
||||
|
||||
return bytes
|
||||
}
|
||||
|
||||
function _allocTmpDir() {
|
||||
const path = TMP_ROOT + "/" + _makeHash(HASH_LEN)
|
||||
const dir = files.open(path)
|
||||
dir.mkDir()
|
||||
return { fd: dir, path: path }
|
||||
}
|
||||
|
||||
function _normPath(p) {
|
||||
return p.replace(/\//g, "\\")
|
||||
}
|
||||
|
||||
function _writeFile(destDirPath, archivePath, payload) {
|
||||
const parts = _normPath(archivePath).split("\\").filter(p => p.length > 0)
|
||||
if (parts.length === 0) return null
|
||||
|
||||
const leaf = parts.pop()
|
||||
let curPath = destDirPath
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
curPath = curPath + "/" + parts[i]
|
||||
const cur = files.open(curPath)
|
||||
if (!cur.exists) cur.mkDir()
|
||||
}
|
||||
const outfile = files.open(curPath + "/" + leaf)
|
||||
if (!outfile.exists) outfile.mkFile()
|
||||
outfile.swrite(payload)
|
||||
return outfile
|
||||
}
|
||||
|
||||
|
||||
function extractOne(lfsPath, filename, autoDecompress) {
|
||||
if (autoDecompress === undefined) autoDecompress = true
|
||||
if (filename === undefined || filename === null || filename === "")
|
||||
throw new Error("filename is required")
|
||||
|
||||
const bytes = _readArchive(lfsPath)
|
||||
const needle = _normPath(filename)
|
||||
|
||||
let curs = LFS_HEADER_LEN
|
||||
while (curs < bytes.length) {
|
||||
const fileType = bytes.charCodeAt(curs)
|
||||
const pathlen = (bytes.charCodeAt(curs+1) << 8) | bytes.charCodeAt(curs+2)
|
||||
curs += 3
|
||||
const path = bytes.substring(curs, curs + pathlen)
|
||||
curs += pathlen
|
||||
const filelen = (bytes.charCodeAt(curs) << 24)
|
||||
| (bytes.charCodeAt(curs+1) << 16)
|
||||
| (bytes.charCodeAt(curs+2) << 8)
|
||||
| bytes.charCodeAt(curs+3)
|
||||
curs += 4
|
||||
|
||||
if (_normPath(path) === needle) {
|
||||
let payload = bytes.substring(curs, curs + filelen)
|
||||
if (autoDecompress && _isCompressed(payload)) payload = _decompress(payload)
|
||||
|
||||
const dest = _allocTmpDir()
|
||||
const leaf = needle.split("\\").pop()
|
||||
const outfile = files.open(dest.path + "/" + leaf)
|
||||
if (!outfile.exists) outfile.mkFile()
|
||||
outfile.swrite(payload)
|
||||
return outfile
|
||||
}
|
||||
|
||||
curs += filelen
|
||||
}
|
||||
|
||||
throw new Error("File not found in archive: " + filename)
|
||||
}
|
||||
|
||||
|
||||
function extractAll(lfsPath, autoDecompress) {
|
||||
if (autoDecompress === undefined) autoDecompress = true
|
||||
|
||||
const bytes = _readArchive(lfsPath)
|
||||
const dest = _allocTmpDir()
|
||||
|
||||
let curs = LFS_HEADER_LEN
|
||||
while (curs < bytes.length) {
|
||||
const fileType = bytes.charCodeAt(curs)
|
||||
const pathlen = (bytes.charCodeAt(curs+1) << 8) | bytes.charCodeAt(curs+2)
|
||||
curs += 3
|
||||
const path = bytes.substring(curs, curs + pathlen)
|
||||
curs += pathlen
|
||||
const filelen = (bytes.charCodeAt(curs) << 24)
|
||||
| (bytes.charCodeAt(curs+1) << 16)
|
||||
| (bytes.charCodeAt(curs+2) << 8)
|
||||
| bytes.charCodeAt(curs+3)
|
||||
curs += 4
|
||||
|
||||
let payload = bytes.substring(curs, curs + filelen)
|
||||
if (autoDecompress && _isCompressed(payload)) payload = _decompress(payload)
|
||||
_writeFile(dest.path, path, payload)
|
||||
|
||||
curs += filelen
|
||||
}
|
||||
|
||||
return dest.fd
|
||||
}
|
||||
|
||||
|
||||
exports = { extractOne, extractAll }
|
||||
123
assets/disk0/tvdos/include/net.mjs
Normal file
123
assets/disk0/tvdos/include/net.mjs
Normal file
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* net.mjs — Internet text-fetch helper for TVDOS
|
||||
*
|
||||
* Wraps the HttpModem peripheral (driven by `_TVDOS.DRV.FS.NET`, see
|
||||
* TVDOS.SYS:1001-1034) behind a small, regular-URL-friendly API. The
|
||||
* helper looks up whichever drive letter the boot probe assigned to the
|
||||
* HTTP modem and translates ordinary URLs (`https://host/path`) into the
|
||||
* scheme-without-double-slash form (`https:host/path`) that the modem
|
||||
* expects on the wire.
|
||||
*
|
||||
* Usage
|
||||
* -----
|
||||
* let net = require("A:/tvdos/include/net.mjs")
|
||||
*
|
||||
* if (!net.isAvailable())
|
||||
* printerrln("No HTTP modem attached")
|
||||
*
|
||||
* let body = net.fetchText("https://example.com/index.html")
|
||||
* if (body === null) printerrln("Fetch failed")
|
||||
* else println(body)
|
||||
*/
|
||||
|
||||
|
||||
let _cachedDrive = null
|
||||
|
||||
/** Scan TVDOS drive table for an HTTP-typed device. Returns the drive
|
||||
* letter (e.g. "B") or null. */
|
||||
function _findHttpDrive() {
|
||||
if (typeof _TVDOS === 'undefined' || !_TVDOS.DRIVEINFO) return null
|
||||
if (_cachedDrive !== null && _TVDOS.DRIVEINFO[_cachedDrive] &&
|
||||
_TVDOS.DRIVEINFO[_cachedDrive].type === 'HTTP')
|
||||
return _cachedDrive
|
||||
|
||||
for (let letter in _TVDOS.DRIVEINFO) {
|
||||
let info = _TVDOS.DRIVEINFO[letter]
|
||||
if (info && info.type === 'HTTP') {
|
||||
_cachedDrive = letter
|
||||
return letter
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Convert a regular URL into the form the HTTP modem accepts:
|
||||
* - strip the `//` between scheme and authority
|
||||
* - drop any URL fragment
|
||||
* - assume `https` when no scheme is provided
|
||||
*/
|
||||
function _normaliseUrl(url) {
|
||||
if (typeof url !== 'string')
|
||||
throw new TypeError("url must be a string")
|
||||
let s = url.trim()
|
||||
if (s.length === 0) throw new Error("url is empty")
|
||||
|
||||
// Drop fragment — the modem speaks to the server, # is client-side.
|
||||
let hash = s.indexOf('#')
|
||||
if (hash >= 0) s = s.substring(0, hash)
|
||||
|
||||
// scheme://host/path → scheme:host/path
|
||||
let m = s.match(/^([a-zA-Z][a-zA-Z0-9+.\-]*):\/\/(.*)$/)
|
||||
if (m) return m[1].toLowerCase() + ':' + m[2]
|
||||
|
||||
// Already in scheme:host/path form (the modem's native shape)
|
||||
if (/^[a-zA-Z][a-zA-Z0-9+.\-]*:[^/]/.test(s)) return s
|
||||
|
||||
// No scheme — default to https
|
||||
if (!/^[a-zA-Z][a-zA-Z0-9+.\-]*:/.test(s))
|
||||
return 'https:' + s.replace(/^\/\//, '')
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
|
||||
let net = {}
|
||||
|
||||
/** Returns the drive letter currently bound to the HTTP modem, or null
|
||||
* when no such device is attached. */
|
||||
net.getHttpDrive = function () {
|
||||
return _findHttpDrive()
|
||||
}
|
||||
|
||||
/** True iff an HTTP modem is reachable through TVDOS. */
|
||||
net.isAvailable = function () {
|
||||
return _findHttpDrive() !== null
|
||||
}
|
||||
|
||||
/** Translate a URL into the `<drive>:\<modem-url>` form that
|
||||
* `files.open()` would route through `_TVDOS.DRV.FS.NET`. Useful when
|
||||
* another component wants the descriptor directly. Throws if no HTTP
|
||||
* modem is attached. */
|
||||
net.toModemPath = function (url) {
|
||||
let drive = _findHttpDrive()
|
||||
if (drive === null) throw new Error("No HTTP modem device is attached")
|
||||
return drive + ':\\' + _normaliseUrl(url)
|
||||
}
|
||||
|
||||
/** Open a TVDOS file descriptor backed by the HTTP modem for the given
|
||||
* URL. The descriptor's sread()/bread() trigger the actual fetch.
|
||||
* Throws if no HTTP modem is attached. */
|
||||
net.open = function (url) {
|
||||
return files.open(net.toModemPath(url))
|
||||
}
|
||||
|
||||
/** Fetch the body of `url` as a string. Returns the response text on
|
||||
* success, or null when the modem reports a non-zero status (bad URL,
|
||||
* I/O error, etc.). Throws if no HTTP modem is attached. */
|
||||
net.fetchText = function (url) {
|
||||
let fd = net.open(url)
|
||||
let text = fd.sread()
|
||||
try { fd.close() } catch (_) {}
|
||||
return (text === undefined) ? null : text
|
||||
}
|
||||
|
||||
/** Like fetchText, but throws an Error instead of returning null on
|
||||
* fetch failure. */
|
||||
net.fetchTextOrThrow = function (url) {
|
||||
let body = net.fetchText(url)
|
||||
if (body === null) throw new Error("Failed to fetch URL: " + url)
|
||||
return body
|
||||
}
|
||||
|
||||
|
||||
exports = net
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* LibPCM — PCM decoder for TSVM
|
||||
* @author CuriousTorvald
|
||||
*/
|
||||
|
||||
const HW_SAMPLING_RATE = 32000
|
||||
function printdbg(s) { if (0) serial.println(s) }
|
||||
function printvis(s) { if (0) println(s) }
|
||||
@@ -29,7 +34,7 @@ function s16Tou8(i) {
|
||||
}
|
||||
function u16Tos16(i) { return (i > 32767) ? i - 65536 : i }
|
||||
function randomRound(k) {
|
||||
let rnd = (Math.random() + Math.random()) / 2.0 // this produces triangular distribution
|
||||
let rnd = Math.random() // note to self: no triangular here
|
||||
return (rnd < (k - (k|0))) ? Math.ceil(k) : Math.floor(k)
|
||||
}
|
||||
function lerp(start, end, x) {
|
||||
|
||||
@@ -281,9 +281,661 @@ function printTopBar(status, moreInfo) {
|
||||
con.move(1, 1)
|
||||
}
|
||||
|
||||
// ── Audio player visualiser ─────────────────────────────────────────────────
|
||||
// Shared by playwav/playmp2/playpcm/playtad. Design follows
|
||||
// `assets/playwav_visualiser_design_2_for_tsvm.md`:
|
||||
// * 3-row ASCII wavescope (mid signal envelope) on rows 3..5
|
||||
// * 22-col progress dashes on the right side of the song-title row
|
||||
// * 24-row XY-scope + wavelet-modulated persistence visualiser on rows 7..30
|
||||
// * stereo energy bar on row 31
|
||||
//
|
||||
// The visualiser fuses two displays the design doc calls complementary:
|
||||
// * XY-scope geometry (rotated 45° so L plots along the `\` diagonal and R
|
||||
// along `/`) gives spatial motion and stereo image.
|
||||
// * Haar wavelet features (transient / noise / sustain energies) steer the
|
||||
// beam's behaviour — transients evaporate it and emit sparks, sustained
|
||||
// content lets trails breathe longer, mid noise jitters the beam.
|
||||
//
|
||||
// The wavelet is therefore a *modulator*, not a renderer. No FFT, no pitch
|
||||
// tracking, no per-frame allocation in the hot loop.
|
||||
|
||||
const AG_COLS = 80
|
||||
const AG_ROWS = 32
|
||||
const AG_COL_INSIDE_L = 2
|
||||
const AG_COL_INSIDE_R = 79
|
||||
const AG_LANE_W = 78
|
||||
|
||||
const AG_ROW_TOP_BORDER = 1
|
||||
const AG_ROW_TITLE = 2
|
||||
const AG_ROW_WAVE_TOP = 3
|
||||
const AG_ROW_WAVE_BOT = 5 // 3-row wavescope
|
||||
const AG_ROW_VIS_SEP = 6
|
||||
const AG_ROW_VIS_TOP = 7
|
||||
const AG_ROW_VIS_BOT = 30 // 24-row wavelet visualiser
|
||||
const AG_ROW_STEREO = 31
|
||||
const AG_ROW_BOT_BORDER = 32
|
||||
|
||||
const AG_VIS_H = AG_ROW_VIS_BOT - AG_ROW_VIS_TOP + 1 // 24
|
||||
const AG_VIS_W = AG_LANE_W // 78
|
||||
|
||||
// Palette (TSVM 256-colour indices)
|
||||
const AG_COL_BG = 0
|
||||
const AG_COL_BORDER = 250
|
||||
const AG_COL_LABEL = 220
|
||||
const AG_COL_DIM = 235
|
||||
const AG_COL_TITLE = 230
|
||||
const AG_COL_VALUE = 254
|
||||
const AG_COL_PROG_ON = 226 // bright yellow (matches Taud)
|
||||
|
||||
// Box-drawing constants (CP437)
|
||||
const AG_BX_TL = 0xC9, AG_BX_TR = 0xBB, AG_BX_BL = 0xC8, AG_BX_BR = 0xBC
|
||||
const AG_BX_V = 0xBA, AG_BX_H = 0xCD
|
||||
const AG_SEP_L = 0xC7, AG_SEP_R = 0xB6
|
||||
|
||||
// Half-block glyphs for wavescope
|
||||
const AG_HB_NONE = 0x20 // ' '
|
||||
const AG_HB_TOP = 0xDF // '▀'
|
||||
const AG_HB_BOT = 0xDC // '▄'
|
||||
const AG_HB_BOTH = 0xDB // '█'
|
||||
|
||||
// Density stairs for visualiser + stereo bar
|
||||
const AG_STAIRS = [0x20, 0xB0, 0xB1, 0xB2, 0xDB] // ' ', ░, ▒, ▓, █
|
||||
|
||||
// Electron-beam colour ramp. Index 0 = silent (background), last = freshly
|
||||
// drawn beam. Amber-on-black mimics analog vector-scope CRT phosphor — the
|
||||
// glyph shape carries the spatial information, the colour ramp carries age.
|
||||
const AG_BEAM_PAL = [AG_COL_BG, 94, 130, 166, 220]
|
||||
|
||||
// Five wavelet levels (Haar decomp). These are used only as modulators —
|
||||
// they never get rendered as bars. Indexing:
|
||||
// AG_WL_TRANSIENT — top-octave detail (8 kHz..16 kHz at 32 kHz Fs).
|
||||
// Spikes on percussion attacks, vocal consonants, cymbals.
|
||||
// AG_WL_NOISE — upper-mid detail (4..8 kHz). Drives beam jitter.
|
||||
// AG_WL_BODY — mid detail (2..4 kHz).
|
||||
// AG_WL_TONAL — lower-mid detail (1..2 kHz).
|
||||
// AG_WL_BASS — low detail (0.5..1 kHz). Slows the decay (sustain).
|
||||
const AG_N_BANDS = 5
|
||||
const AG_WL_TRANSIENT = 0
|
||||
const AG_WL_NOISE = 1
|
||||
const AG_WL_BODY = 2
|
||||
const AG_WL_TONAL = 3
|
||||
const AG_WL_BASS = 4
|
||||
|
||||
// Stereo bar colour ramp (5 levels) — uses the tonal blue gradient so the
|
||||
// stereo strip reads as the "ground" beneath the wavelet cloud.
|
||||
const AG_STEREO_COL = [AG_COL_DIM, 17, 33, 75, 117]
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// All state lives in module scope so a player just does:
|
||||
// const gui = require('playgui')
|
||||
// gui.audioInit({...})
|
||||
// while (...) { ...; gui.audioFeedPcm(ptr, n); gui.audioRender(); }
|
||||
// gui.audioClose()
|
||||
//
|
||||
// Multiple concurrent players in one process are not supported — but TVDOS
|
||||
// only runs one foreground command at a time, so that's fine.
|
||||
|
||||
const AG_SNAPSHOT_N = 1024 // power of 2; covers ~32 ms at 32 kHz
|
||||
const ag_snapL = new Float32Array(AG_SNAPSHOT_N)
|
||||
const ag_snapR = new Float32Array(AG_SNAPSHOT_N)
|
||||
|
||||
const AG_WORK_N = AG_SNAPSHOT_N // scratch buffers for Haar pyramid
|
||||
const ag_workMid = new Float32Array(AG_WORK_N)
|
||||
const ag_workTmp = new Float32Array(AG_WORK_N >> 1)
|
||||
const ag_bandEnergy = new Float32Array(AG_N_BANDS)
|
||||
|
||||
// Sub-500 Hz residual — drops out of the wavelet modulator set on purpose,
|
||||
// but we keep its RMS around to drive the bass mark.
|
||||
let ag_bassEnergy = 0
|
||||
|
||||
// Persistence buffer — float intensity per cell, plus the glyph last written
|
||||
// there. Decay shrinks intensity each frame; new beam samples overwrite the
|
||||
// glyph and bump intensity.
|
||||
const ag_persist = new Float32Array(AG_VIS_H * AG_VIS_W)
|
||||
const ag_persistGlyph = new Int16Array(AG_VIS_H * AG_VIS_W)
|
||||
|
||||
// Skip-redraw cache — only emit a cell when its glyph or colour changes.
|
||||
const ag_cellGlyph = new Int16Array(AG_VIS_H * AG_VIS_W).fill(-1)
|
||||
const ag_cellFg = new Int16Array(AG_VIS_H * AG_VIS_W).fill(-1)
|
||||
const ag_waveGlyph = new Int16Array(AG_LANE_W * 3).fill(-1)
|
||||
const ag_stereoGlyph = new Int16Array(AG_LANE_W).fill(-1)
|
||||
const ag_stereoFg = new Int16Array(AG_LANE_W).fill(-1)
|
||||
let ag_lastBassFg = -1
|
||||
|
||||
// Render rate-limiter — playmp2 spins ~32 Hz, playtad ~1 Hz, playwav ~100 Hz
|
||||
// at decode time. Clamp visual refresh to 20 Hz so each caller can spam
|
||||
// audioRender() without worrying about pacing.
|
||||
let ag_lastRenderNs = 0
|
||||
const AG_RENDER_INTERVAL_NS = 50 * 1000 * 1000 // 50 ms
|
||||
|
||||
// Latest progress fraction so we redraw the bar only when it changes.
|
||||
let ag_lastProgressIdx = -1
|
||||
let ag_lastTimeStr = ''
|
||||
|
||||
// Init params held for re-use during render.
|
||||
let ag_initParams = null
|
||||
|
||||
function ag_color(fg, bg) { con.color_pair(fg, bg) }
|
||||
function ag_mvprn(row, col, ch) { con.mvaddch(row, col, ch) }
|
||||
function ag_mvtext(row, col, s) { con.move(row, col); print(s) }
|
||||
|
||||
function ag_pad(n, w) {
|
||||
let s = '' + n
|
||||
while (s.length < w) s = ' ' + s
|
||||
return s
|
||||
}
|
||||
|
||||
function ag_secToReadable(n) {
|
||||
const mins = ('' + ((n / 60) | 0)).padStart(2, '0')
|
||||
const secs = ('' + (n % 60)).padStart(2, '0')
|
||||
return mins + ':' + secs
|
||||
}
|
||||
|
||||
function ag_drawSeparator(row, label) {
|
||||
ag_color(AG_COL_BORDER, AG_COL_BG)
|
||||
ag_mvprn(row, 1, AG_SEP_L)
|
||||
for (let x = 2; x < AG_COLS; x++) ag_mvprn(row, x, AG_BX_H)
|
||||
ag_mvprn(row, AG_COLS, AG_SEP_R)
|
||||
if (label) {
|
||||
ag_color(AG_COL_LABEL, AG_COL_BG)
|
||||
ag_mvtext(row, 5, ' ' + label + ' ')
|
||||
}
|
||||
}
|
||||
|
||||
function ag_drawFrame() {
|
||||
// Top border with embedded format tag.
|
||||
ag_color(AG_COL_BORDER, AG_COL_BG)
|
||||
ag_mvprn(AG_ROW_TOP_BORDER, 1, AG_BX_TL)
|
||||
for (let x = 2; x < AG_COLS; x++) ag_mvprn(AG_ROW_TOP_BORDER, x, AG_BX_H)
|
||||
ag_mvprn(AG_ROW_TOP_BORDER, AG_COLS, AG_BX_TR)
|
||||
if (ag_initParams.tag) {
|
||||
ag_color(AG_COL_LABEL, AG_COL_BG)
|
||||
ag_mvtext(AG_ROW_TOP_BORDER, 4, ' ' + ag_initParams.tag + ' ')
|
||||
}
|
||||
|
||||
// Bottom border with exit hint.
|
||||
ag_color(AG_COL_BORDER, AG_COL_BG)
|
||||
ag_mvprn(AG_ROW_BOT_BORDER, 1, AG_BX_BL)
|
||||
for (let x = 2; x < AG_COLS; x++) ag_mvprn(AG_ROW_BOT_BORDER, x, AG_BX_H)
|
||||
ag_mvprn(AG_ROW_BOT_BORDER, AG_COLS, AG_BX_BR)
|
||||
ag_color(AG_COL_DIM, AG_COL_BG)
|
||||
ag_mvtext(AG_ROW_BOT_BORDER, 4, ' Hold BkSp to exit ')
|
||||
|
||||
// Side bars.
|
||||
ag_color(AG_COL_BORDER, AG_COL_BG)
|
||||
for (let r = 2; r < AG_ROWS; r++) {
|
||||
ag_mvprn(r, 1, AG_BX_V)
|
||||
ag_mvprn(r, AG_COLS, AG_BX_V)
|
||||
}
|
||||
|
||||
// Inner separator over the visualiser canvas. The wavescope strip sits
|
||||
// flush against the title row — no separator there.
|
||||
ag_drawSeparator(AG_ROW_VIS_SEP, 'VISUALS')
|
||||
}
|
||||
|
||||
function ag_clearInside(row) {
|
||||
ag_color(AG_COL_DIM, AG_COL_BG)
|
||||
con.move(row, AG_COL_INSIDE_L)
|
||||
print(' '.repeat(AG_LANE_W))
|
||||
}
|
||||
|
||||
function ag_drawTitle() {
|
||||
ag_clearInside(AG_ROW_TITLE)
|
||||
let title = ag_initParams.title || ''
|
||||
// Reserve 24 cols on the right for time string + progress bar.
|
||||
if (title.length > AG_LANE_W - 26) title = title.substring(0, AG_LANE_W - 29) + '...'
|
||||
ag_color(AG_COL_TITLE, AG_COL_BG)
|
||||
ag_mvtext(AG_ROW_TITLE, AG_COL_INSIDE_L + 1, title)
|
||||
}
|
||||
|
||||
// Progress: time string + 22-wide dashes ramp (matches playtaud). Called by
|
||||
// the player via audioSetProgress; redraws only when something changed.
|
||||
function ag_drawProgress(progress, elapsedSec, totalSec) {
|
||||
const barW = 22
|
||||
const bx0 = AG_COL_INSIDE_R - barW
|
||||
const filled = Math.round(progress * barW)
|
||||
|
||||
const timeStr = ag_secToReadable(elapsedSec) + '/' + ag_secToReadable(totalSec)
|
||||
if (timeStr !== ag_lastTimeStr) {
|
||||
ag_lastTimeStr = timeStr
|
||||
ag_color(AG_COL_VALUE, AG_COL_BG)
|
||||
ag_mvtext(AG_ROW_TITLE, bx0 - timeStr.length - 1, timeStr)
|
||||
}
|
||||
|
||||
if (filled === ag_lastProgressIdx) return
|
||||
ag_lastProgressIdx = filled
|
||||
|
||||
for (let i = 0; i < barW; i++) {
|
||||
const lit = i < filled
|
||||
ag_color(lit ? AG_COL_PROG_ON : AG_COL_DIM, AG_COL_BG)
|
||||
ag_mvprn(AG_ROW_TITLE, bx0 + i, lit ? 0x7C /*│*/ : 0x2E /*.*/)
|
||||
}
|
||||
}
|
||||
|
||||
// ── PCM ingestion ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// feedPcm copies the most recent SNAPSHOT_N samples from a PCMu8-stereo-
|
||||
// interleaved buffer into our snapshot. `ptr` can be a positive heap address
|
||||
// (LPCM/ADPCM decoded buffer, raw PCM) or a negative peripheral address (TAD
|
||||
// decoded buffer, MP2 mediaDecodedBin) — TSVM peripheral memory grows toward
|
||||
// 0, so reads use a signed step `vec`.
|
||||
|
||||
function audioFeedPcm(ptr, sampleCount) {
|
||||
if (!sampleCount) return
|
||||
const vec = ptr >= 0 ? 1 : -1
|
||||
const inv128 = 1 / 128
|
||||
|
||||
if (sampleCount >= AG_SNAPSHOT_N) {
|
||||
// Take last AG_SNAPSHOT_N samples — discard the rest.
|
||||
const start = sampleCount - AG_SNAPSHOT_N
|
||||
for (let i = 0; i < AG_SNAPSHOT_N; i++) {
|
||||
const off = (start + i) * 2 * vec
|
||||
ag_snapL[i] = ((sys.peek(ptr + off) & 0xFF) - 128) * inv128
|
||||
ag_snapR[i] = ((sys.peek(ptr + off + vec) & 0xFF) - 128) * inv128
|
||||
}
|
||||
} else {
|
||||
// Shift snapshot left by `sampleCount` and append all new samples.
|
||||
const shift = sampleCount
|
||||
const keep = AG_SNAPSHOT_N - shift
|
||||
for (let i = 0; i < keep; i++) {
|
||||
ag_snapL[i] = ag_snapL[i + shift]
|
||||
ag_snapR[i] = ag_snapR[i + shift]
|
||||
}
|
||||
for (let i = 0; i < shift; i++) {
|
||||
const off = i * 2 * vec
|
||||
ag_snapL[keep + i] = ((sys.peek(ptr + off) & 0xFF) - 128) * inv128
|
||||
ag_snapR[keep + i] = ((sys.peek(ptr + off + vec) & 0xFF) - 128) * inv128
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wavelet analysis ───────────────────────────────────────────────────────
|
||||
//
|
||||
// In-place Haar decomposition. Five levels on 1024 samples gives band
|
||||
// passes (at 32 kHz): [8k..16k], [4k..8k], [2k..4k], [1k..2k], [500..1k].
|
||||
// Sub-500 Hz ends up in the approximation and is intentionally dropped —
|
||||
// otherwise the bass would dominate every track.
|
||||
|
||||
function ag_analyseHaar() {
|
||||
// mid = (L + R) / 2
|
||||
for (let i = 0; i < AG_SNAPSHOT_N; i++) {
|
||||
ag_workMid[i] = (ag_snapL[i] + ag_snapR[i]) * 0.5
|
||||
}
|
||||
let len = AG_SNAPSHOT_N
|
||||
const SQ_HALF = 0.70710678 // 1/sqrt(2) keeps L2 norm
|
||||
for (let lv = 0; lv < AG_N_BANDS; lv++) {
|
||||
const half = len >> 1
|
||||
let sumSq = 0
|
||||
for (let i = 0; i < half; i++) {
|
||||
const a = ag_workMid[i * 2]
|
||||
const b = ag_workMid[i * 2 + 1]
|
||||
const lo = (a + b) * SQ_HALF
|
||||
const hi = (a - b) * SQ_HALF
|
||||
ag_workMid[i] = lo
|
||||
ag_workTmp[i] = hi
|
||||
sumSq += hi * hi
|
||||
}
|
||||
// Higher-freq levels naturally have weaker energy in music; scale
|
||||
// each band by an empirical gain so all five read at comparable
|
||||
// brightness on typical material.
|
||||
const gain = 3.0 + lv * 1.5
|
||||
const rms = Math.sqrt(sumSq / half) * gain
|
||||
ag_bandEnergy[lv] = rms > 1 ? 1 : rms
|
||||
len = half
|
||||
}
|
||||
// Residual approximation in ag_workMid[0..len-1] holds the sub-500 Hz
|
||||
// energy that the modulator pipeline intentionally discards. Reuse it
|
||||
// to drive the bass mark.
|
||||
let bassSumSq = 0
|
||||
for (let i = 0; i < len; i++) {
|
||||
const v = ag_workMid[i]
|
||||
bassSumSq += v * v
|
||||
}
|
||||
const bassRms = Math.sqrt(bassSumSq / len) * 1.8
|
||||
ag_bassEnergy = bassRms > 1 ? 1 : bassRms
|
||||
}
|
||||
|
||||
// ── Wavescope (rows 3..5) ──────────────────────────────────────────────────
|
||||
//
|
||||
// Peak-detected envelope: each column shows the range [min, max] of its slice
|
||||
// of the snapshot using half-block characters for 6 vertical sub-positions.
|
||||
// Mid-signal only — for stereo information you read the bottom bar.
|
||||
|
||||
function ag_drawWavescope() {
|
||||
const N = AG_SNAPSHOT_N
|
||||
const samplesPerCol = N / AG_LANE_W
|
||||
// 6 sub-positions: 0..5 from top to bottom.
|
||||
for (let c = 0; c < AG_LANE_W; c++) {
|
||||
const s = (c * samplesPerCol) | 0
|
||||
const e = (((c + 1) * samplesPerCol) | 0)
|
||||
let mn = 1.0, mx = -1.0
|
||||
for (let i = s; i < e; i++) {
|
||||
const v = (ag_snapL[i] + ag_snapR[i]) * 0.5
|
||||
if (v < mn) mn = v
|
||||
if (v > mx) mx = v
|
||||
}
|
||||
// Map [-1, 1] → [0, 5] (top..bottom). +1 → 0, -1 → 5.
|
||||
let yMax = ((1 - mx) * 0.5 * 6) | 0
|
||||
let yMin = ((1 - mn) * 0.5 * 6) | 0
|
||||
if (yMax < 0) yMax = 0; if (yMax > 5) yMax = 5
|
||||
if (yMin < 0) yMin = 0; if (yMin > 5) yMin = 5
|
||||
// yMax is the top of the bar (smaller y = higher up), yMin is bottom.
|
||||
for (let row = 0; row < 3; row++) {
|
||||
const subTop = row * 2
|
||||
const subBot = row * 2 + 1
|
||||
const hitTop = (yMax <= subTop) && (yMin >= subTop)
|
||||
const hitBot = (yMax <= subBot) && (yMin >= subBot)
|
||||
let g = AG_HB_NONE
|
||||
if (hitTop && hitBot) g = AG_HB_BOTH
|
||||
else if (hitTop) g = AG_HB_TOP
|
||||
else if (hitBot) g = AG_HB_BOT
|
||||
const idx = row * AG_LANE_W + c
|
||||
if (ag_waveGlyph[idx] === g) continue
|
||||
ag_waveGlyph[idx] = g
|
||||
ag_color(AG_COL_LABEL, AG_COL_BG)
|
||||
ag_mvprn(AG_ROW_WAVE_TOP + row, AG_COL_INSIDE_L + c, g)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── XY-scope persistence visualiser (rows 7..30) ───────────────────────────
|
||||
//
|
||||
// 45°-rotated vectorscope, standard convention. Each PCM sample plots at
|
||||
// col = centre_col + (L − R) · SX
|
||||
// row = centre_row + (L + R) · SY
|
||||
// giving the four canonical traces:
|
||||
// in-phase mono (L = R) → vertical line ((L−R)=0, (L+R) varies)
|
||||
// out-of-phase mono (L=−R) → horizontal line ((L+R)=0, (L−R) varies)
|
||||
// pure L (R = 0) → lower-right diagonal — the `\` axis
|
||||
// pure R (L = 0) → lower-left diagonal — the `/` axis
|
||||
// (Positive mono sits below centre because screen row increases downward.)
|
||||
// The glyph per cell follows channel dominance, the cell's intensity is
|
||||
// bumped on every hit, and a global decay shrinks stale traces back to zero.
|
||||
//
|
||||
// Wavelet energies are used as *modulators* — the design's central idea:
|
||||
//
|
||||
// transient → faster decay + scattered spark emission
|
||||
// bass/tonal → slower decay (sustained content breathes longer)
|
||||
// noise → small jitter on plot position (texture fuzz)
|
||||
//
|
||||
// TSVM terminal cells are ~2:1 (taller than wide); SX is set to ~2×SY so the
|
||||
// scope reads roughly circular under steady mono content.
|
||||
|
||||
const AG_XY_CX = AG_VIS_W >> 1 // centre column inside visualiser canvas
|
||||
const AG_XY_CY = AG_VIS_H >> 1 // centre row
|
||||
const AG_XY_SX = 18 // (L−R) → horizontal extent ±36 cells
|
||||
const AG_XY_SY = 9 // (L+R) → vertical extent ±18 cells
|
||||
|
||||
// Bass mark: 2×2 cell indicator pinned to the centre of the vectorscope so
|
||||
// the bass "subwoofer" sits underneath the beam's pivot point. Half-blocks
|
||||
// form a compact 16×16-pixel "dot" centred in the 16×32-pixel 2×2 area.
|
||||
const AG_BASS_VIS_R0 = AG_XY_CY - 1
|
||||
const AG_BASS_VIS_C0 = AG_XY_CX - 1
|
||||
const AG_BASS_VIS_R1 = AG_BASS_VIS_R0 + 1
|
||||
const AG_BASS_VIS_C1 = AG_BASS_VIS_C0 + 1
|
||||
const AG_BASS_SCR_R = AG_ROW_VIS_TOP + AG_BASS_VIS_R0
|
||||
const AG_BASS_SCR_C = AG_COL_INSIDE_L + AG_BASS_VIS_C0
|
||||
|
||||
// Glyphs.
|
||||
const AG_G_DOT = 0xFA // ·
|
||||
const AG_G_BSL = 0x5C // \\
|
||||
const AG_G_FSL = 0x2F // /
|
||||
const AG_G_XCR = 0x58 // X
|
||||
const AG_G_SPK = 0x2A // *
|
||||
const AG_G_HBAR = 0xC4 // ─
|
||||
|
||||
function ag_updateXYScope() {
|
||||
// Wavelet-driven modulators, all in [0, 1].
|
||||
const transient = ag_bandEnergy[AG_WL_TRANSIENT]
|
||||
const noise = ag_bandEnergy[AG_WL_NOISE]
|
||||
const sustain = ag_bandEnergy[AG_WL_BASS] * 0.6 + ag_bandEnergy[AG_WL_TONAL] * 0.4
|
||||
|
||||
// Decay: base 0.93, longer for sustained content, much shorter for sharp
|
||||
// transients. Clamped so a screaming hi-hat never freezes the trails and
|
||||
// a deep pad never overflows.
|
||||
let decay = 0.93 + 0.05 * (sustain > 1 ? 1 : sustain)
|
||||
- 0.10 * (transient > 1 ? 1 : transient)
|
||||
if (decay < 0.72) decay = 0.72
|
||||
if (decay > 0.985) decay = 0.985
|
||||
|
||||
// Decay all cells.
|
||||
for (let i = 0; i < ag_persist.length; i++) {
|
||||
ag_persist[i] *= decay
|
||||
}
|
||||
|
||||
// Plot every sample in the snapshot. Step 1 keeps lines continuous
|
||||
// visually; with 1024 samples per ~50 ms frame, most cells get multiple
|
||||
// hits and the persistence builds the "beam" silhouette.
|
||||
const SX = AG_XY_SX
|
||||
const SY = AG_XY_SY
|
||||
const cx = AG_XY_CX
|
||||
const cy = AG_XY_CY
|
||||
const jitterAmt = noise * 0.06 // noise-driven beam fuzz
|
||||
const plotBoost = 0.05
|
||||
|
||||
for (let i = 0; i < AG_SNAPSHOT_N; i++) {
|
||||
const L = ag_snapL[i]
|
||||
const R = ag_snapR[i]
|
||||
const mono = L + R // vertical axis ∈ [-2, 2]
|
||||
const side = L - R // horizontal axis ∈ [-2, 2]
|
||||
// Wavelet-driven jitter is symmetric — substitute a deterministic
|
||||
// pseudo-random by mixing the snapshot index so we don't churn the
|
||||
// shared Math.random() PRNG 1024× per frame.
|
||||
const jx = (((i * 1103515245 + 12345) & 0xFFFF) / 65536 - 0.5) * jitterAmt
|
||||
const jy = (((i * 1664525 + 1013904223) & 0xFFFF) / 65536 - 0.5) * jitterAmt
|
||||
let col = cx + ((side + jx) * SX) | 0
|
||||
let row = cy + ((mono + jy) * SY) | 0
|
||||
if (col < 0 || col >= AG_VIS_W || row < 0 || row >= AG_VIS_H) continue
|
||||
|
||||
const absL = L < 0 ? -L : L
|
||||
const absR = R < 0 ? -R : R
|
||||
let glyph
|
||||
if (absL + absR < 0.04) {
|
||||
glyph = AG_G_DOT
|
||||
} else if (absL > absR * 1.25) {
|
||||
glyph = AG_G_BSL // L-dominant → \
|
||||
} else if (absR > absL * 1.25) {
|
||||
glyph = AG_G_FSL // R-dominant → /
|
||||
} else {
|
||||
glyph = AG_G_XCR // mixed → X
|
||||
}
|
||||
|
||||
const idx = row * AG_VIS_W + col
|
||||
let nv = ag_persist[idx] + plotBoost
|
||||
if (nv > 1.0) nv = 1.0
|
||||
ag_persist[idx] = nv
|
||||
ag_persistGlyph[idx] = glyph
|
||||
}
|
||||
|
||||
// Transient spark emission — when high-freq energy peaks, scatter a few
|
||||
// bright `*` glyphs across the canvas. Cap at ~32 sparks to stay cheap.
|
||||
if (transient > 0.32) {
|
||||
const nSparks = ((transient - 0.32) * 60) | 0
|
||||
for (let s = 0; s < nSparks && s < 32; s++) {
|
||||
const c = (Math.random() * AG_VIS_W) | 0
|
||||
const r = (Math.random() * AG_VIS_H) | 0
|
||||
const idx = r * AG_VIS_W + c
|
||||
if (ag_persist[idx] < 0.85) ag_persist[idx] = 0.85
|
||||
ag_persistGlyph[idx] = AG_G_SPK
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ag_drawVisualiser() {
|
||||
for (let r = 0; r < AG_VIS_H; r++) {
|
||||
const rowOff = r * AG_VIS_W
|
||||
const screenY = AG_ROW_VIS_TOP + r
|
||||
const inBassRow = (r === AG_BASS_VIS_R0 || r === AG_BASS_VIS_R1)
|
||||
for (let c = 0; c < AG_VIS_W; c++) {
|
||||
// Bass mark owns its 2×2 cells — let ag_drawBassMark() paint them.
|
||||
if (inBassRow && (c === AG_BASS_VIS_C0 || c === AG_BASS_VIS_C1)) continue
|
||||
const idx = rowOff + c
|
||||
const e = ag_persist[idx]
|
||||
let levelIdx = (e * 5) | 0
|
||||
if (levelIdx > 4) levelIdx = 4
|
||||
if (levelIdx < 0) levelIdx = 0
|
||||
const glyph = (levelIdx === 0) ? 0x20 : ag_persistGlyph[idx]
|
||||
const fg = AG_BEAM_PAL[levelIdx]
|
||||
if (ag_cellGlyph[idx] === glyph && ag_cellFg[idx] === fg) continue
|
||||
ag_cellGlyph[idx] = glyph
|
||||
ag_cellFg[idx] = fg
|
||||
ag_color(fg, AG_COL_BG)
|
||||
ag_mvprn(screenY, AG_COL_INSIDE_L + c, glyph)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bass mark (rows 29-30, cols 2-3) ───────────────────────────────────────
|
||||
// Brightness-only indicator driven by the sub-500 Hz residual of the Haar
|
||||
// pyramid. Uses indices 1..4 of the beam palette so the dot never falls all
|
||||
// the way to background — a quiet track still shows a faint amber ember.
|
||||
|
||||
function ag_drawBassMark() {
|
||||
let idx = (ag_bassEnergy * 4) | 0
|
||||
if (idx > 3) idx = 3
|
||||
if (idx < 0) idx = 0
|
||||
const fg = AG_BEAM_PAL[idx + 1]
|
||||
if (fg === ag_lastBassFg) return
|
||||
ag_lastBassFg = fg
|
||||
ag_color(fg, AG_COL_BG)
|
||||
ag_mvprn(AG_BASS_SCR_R, AG_BASS_SCR_C, 0xDC)
|
||||
ag_mvprn(AG_BASS_SCR_R, AG_BASS_SCR_C + 1, 0xDC)
|
||||
ag_mvprn(AG_BASS_SCR_R + 1, AG_BASS_SCR_C, 0xDF)
|
||||
ag_mvprn(AG_BASS_SCR_R + 1, AG_BASS_SCR_C + 1, 0xDF)
|
||||
}
|
||||
|
||||
// ── Stereo energy bar (row 31) ─────────────────────────────────────────────
|
||||
//
|
||||
// Same idea as playtaud.drawStereo() but driven by raw PCM: for each sample,
|
||||
// pan = side/|mid| → bin index, energy = sqrt(|mid|+|side|). Gaussian-ish
|
||||
// 7-cell spread so individual sample clusters read as bars, not single spikes.
|
||||
|
||||
function ag_drawStereo() {
|
||||
const W = AG_LANE_W
|
||||
const bins = new Float32Array(W)
|
||||
const N = AG_SNAPSHOT_N
|
||||
|
||||
for (let i = 0; i < N; i++) {
|
||||
const L = ag_snapL[i]
|
||||
const R = ag_snapR[i]
|
||||
const mid = (L + R) * 0.5
|
||||
const side = (L - R) * 0.5
|
||||
const absM = mid < 0 ? -mid : mid
|
||||
const absS = side < 0 ? -side : side
|
||||
// Pan estimate, clamped — `side/|mid|` blows up near silence so we
|
||||
// floor the denominator. This is a coarse stereo image, not a
|
||||
// calibrated readout.
|
||||
let pan = side / (absM + 0.02)
|
||||
if (pan < -1) pan = -1; else if (pan > 1) pan = 1
|
||||
const energy = Math.pow(absM + absS, 0.5)
|
||||
if (energy <= 0) continue
|
||||
|
||||
let col = ((pan + 1) * 0.5 * (W - 1)) | 0
|
||||
if (col < 0) col = 0; else if (col >= W) col = W - 1
|
||||
bins[col] += energy
|
||||
if (col >= 3) bins[col - 3] += energy * 0.05
|
||||
if (col >= 2) bins[col - 2] += energy * 0.3
|
||||
if (col >= 1) bins[col - 1] += energy * 0.75
|
||||
if (col < W - 1) bins[col + 1] += energy * 0.75
|
||||
if (col < W - 2) bins[col + 2] += energy * 0.3
|
||||
if (col < W - 3) bins[col + 3] += energy * 0.05
|
||||
}
|
||||
// Calibrated for "typical" 32 kHz × 1024-sample snapshot at modest level.
|
||||
const norm = 8.0 / N
|
||||
for (let i = 0; i < W; i++) {
|
||||
const v = bins[i] * norm
|
||||
let idx = (v * 1.6) | 0
|
||||
if (idx > 4) idx = 4
|
||||
if (idx < 0) idx = 0
|
||||
const glyph = AG_STAIRS[idx]
|
||||
const fg = AG_STEREO_COL[idx]
|
||||
if (ag_stereoGlyph[i] === glyph && ag_stereoFg[i] === fg) continue
|
||||
ag_stereoGlyph[i] = glyph
|
||||
ag_stereoFg[i] = fg
|
||||
ag_color(fg, AG_COL_BG)
|
||||
ag_mvprn(AG_ROW_STEREO, AG_COL_INSIDE_L + i, glyph)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ─────────────────────────────────────────────────────────────
|
||||
//
|
||||
// audioInit({ title, tag }): paint the static frame.
|
||||
// title : song title shown on row 2 (left)
|
||||
// tag : 3-5 char format label embedded in the top border (e.g. "WAV", "MP2")
|
||||
//
|
||||
// audioFeedPcm(ptr, sampleCount): hand the visualiser a fresh slice of
|
||||
// PCMu8-stereo-interleaved samples (typically the freshly decoded chunk).
|
||||
//
|
||||
// audioSetProgress(progress, elapsedSec, totalSec): update the title-row
|
||||
// progress bar. Cheap — only redraws on change.
|
||||
//
|
||||
// audioRender(): repaint wavescope + visualiser + stereo bar from the latest
|
||||
// snapshot. Internally rate-limited to ~20 Hz so callers can invoke
|
||||
// liberally without juggling frame timing.
|
||||
//
|
||||
// audioClose(): restore cursor + move out of the panel for a clean exit.
|
||||
|
||||
function audioInit(params) {
|
||||
ag_initParams = params || {}
|
||||
ag_lastRenderNs = 0
|
||||
ag_lastProgressIdx = -1
|
||||
ag_lastTimeStr = ''
|
||||
for (let i = 0; i < ag_snapL.length; i++) { ag_snapL[i] = 0; ag_snapR[i] = 0 }
|
||||
for (let i = 0; i < ag_persist.length; i++) ag_persist[i] = 0
|
||||
ag_persistGlyph.fill(0x20)
|
||||
ag_cellGlyph.fill(-1); ag_cellFg.fill(-1)
|
||||
ag_waveGlyph.fill(-1)
|
||||
ag_stereoGlyph.fill(-1); ag_stereoFg.fill(-1)
|
||||
ag_bassEnergy = 0
|
||||
ag_lastBassFg = -1
|
||||
|
||||
con.curs_set(0)
|
||||
con.clear()
|
||||
ag_drawFrame()
|
||||
ag_drawTitle()
|
||||
}
|
||||
|
||||
function audioSetProgress(progress, elapsedSec, totalSec) {
|
||||
if (progress < 0) progress = 0; else if (progress > 1) progress = 1
|
||||
ag_drawProgress(progress, elapsedSec | 0, totalSec | 0)
|
||||
}
|
||||
|
||||
function audioRender() {
|
||||
const now = sys.nanoTime()
|
||||
if (now - ag_lastRenderNs < AG_RENDER_INTERVAL_NS) return
|
||||
ag_lastRenderNs = now
|
||||
|
||||
ag_analyseHaar()
|
||||
ag_updateXYScope()
|
||||
ag_drawWavescope()
|
||||
ag_drawVisualiser()
|
||||
ag_drawBassMark()
|
||||
ag_drawStereo()
|
||||
}
|
||||
|
||||
function audioClose() {
|
||||
con.move(AG_ROW_BOT_BORDER + 1, 1)
|
||||
con.curs_set(1)
|
||||
}
|
||||
|
||||
// ── Exit polling ───────────────────────────────────────────────────────────
|
||||
// Mirror the Backspace-to-quit convention already in playtaud.
|
||||
|
||||
function audioIsExitRequested() {
|
||||
sys.poke(-40, 1)
|
||||
return sys.peek(-41) === 67
|
||||
}
|
||||
|
||||
exports = {
|
||||
clearSubtitleArea,
|
||||
displaySubtitle,
|
||||
printTopBar,
|
||||
printBottomBar
|
||||
printBottomBar,
|
||||
audioInit,
|
||||
audioFeedPcm,
|
||||
audioSetProgress,
|
||||
audioRender,
|
||||
audioClose,
|
||||
audioIsExitRequested
|
||||
}
|
||||
414
assets/disk0/tvdos/include/psg.mjs
Normal file
414
assets/disk0/tvdos/include/psg.mjs
Normal file
@@ -0,0 +1,414 @@
|
||||
/**
|
||||
* LibPSG — PSG emulator and mixer for TSVM
|
||||
* Software-mixes various PSG channels and sends them to sound device as PCM
|
||||
* @author CuriousTorvald
|
||||
*/
|
||||
|
||||
const HW_SAMPLING_RATE = 32000
|
||||
|
||||
function clamp(val, low, hi) { return (val < low) ? low : (val > hi) ? hi : val }
|
||||
function clampS16(i) { return clamp(i, -32768, 32767) }
|
||||
const uNybToSnyb = [0,1,2,3,4,5,6,7,-8,-7,-6,-5,-4,-3,-2,-1]
|
||||
// returns: [unsigned high, unsigned low, signed high, signed low]
|
||||
function getNybbles(b) { return [b >> 4, b & 15, uNybToSnyb[b >> 4], uNybToSnyb[b & 15]] }
|
||||
function s8Tou8(i) { return i + 128 }
|
||||
function s16Tou8(i) {
|
||||
// return s8Tou8((i >> 8) & 255)
|
||||
// apply dithering
|
||||
let ufval = (i / 65536.0) + 0.5
|
||||
let ival = randomRound(ufval * 255.0)
|
||||
return ival|0
|
||||
}
|
||||
function u16Tos16(i) { return (i > 32767) ? i - 65536 : i }
|
||||
function randomRound(k) {
|
||||
let rnd = Math.random() // note to self: no triangular here
|
||||
return (rnd < (k - (k|0))) ? Math.ceil(k) : Math.floor(k)
|
||||
}
|
||||
function lerp(start, end, x) {
|
||||
return (1 - x) * start + x * end
|
||||
}
|
||||
function lerpAndRound(start, end, x) {
|
||||
return Math.round(lerp(start, end, x))
|
||||
}
|
||||
|
||||
|
||||
|
||||
// output format: immediately uploadable into TSVM audio adapter
|
||||
|
||||
// ── Internal helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function secToSamples(sec) { return Math.round(HW_SAMPLING_RATE * sec) }
|
||||
|
||||
function isNative(buf) { return buf.native }
|
||||
|
||||
function readU8(buf, ch, i) {
|
||||
return isNative(buf) ? (sys.peek(buf[ch] + i) & 255) : buf[ch][i]
|
||||
}
|
||||
function writeU8(buf, ch, i, v) {
|
||||
if (isNative(buf)) sys.poke(buf[ch] + i, v)
|
||||
else buf[ch][i] = v
|
||||
}
|
||||
|
||||
// ── Buffer management ───────────────────────────────────────────────────────
|
||||
|
||||
function makeBuffer(length) {
|
||||
// returns [Uint8Array, Uint8Array] (stereo) that will be used to collect samples made by LibPSG.
|
||||
// Length: seconds. Number of elements: round(HW_SAMPLING_RATE * length)
|
||||
const n = secToSamples(length)
|
||||
const L = new Uint8Array(n)
|
||||
const R = new Uint8Array(n)
|
||||
L.fill(128)
|
||||
R.fill(128)
|
||||
return { 0: L, 1: R, samples: n, native: false }
|
||||
}
|
||||
|
||||
function makeBufferNative(length) {
|
||||
// returns native buffer object (stereo) that will be used to collect samples made by LibPSG.
|
||||
// Length: seconds. Number of elements: round(HW_SAMPLING_RATE * length)
|
||||
// Free with freeBufferNative() when done.
|
||||
const n = secToSamples(length)
|
||||
const L = sys.malloc(n); sys.memset(L, 128, n)
|
||||
const R = sys.malloc(n); sys.memset(R, 128, n)
|
||||
return { 0: L, 1: R, samples: n, native: true }
|
||||
}
|
||||
|
||||
function freeBufferNative(buf) {
|
||||
sys.free(buf[0])
|
||||
sys.free(buf[1])
|
||||
}
|
||||
|
||||
function clearBuffer(buf, offsetSec, lengthSec) {
|
||||
// Re-silence a buffer region (fill with 128) for re-use across frames.
|
||||
const start = (offsetSec != null) ? secToSamples(offsetSec) : 0
|
||||
const total = (lengthSec != null) ? secToSamples(lengthSec) : (buf.samples - start)
|
||||
if (!buf.native) {
|
||||
buf[0].fill(128, start, start + total)
|
||||
buf[1].fill(128, start, start + total)
|
||||
} else {
|
||||
sys.memset(buf[0] + start, 128, total)
|
||||
sys.memset(buf[1] + start, 128, total)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shared mix core ─────────────────────────────────────────────────────────
|
||||
|
||||
// sampleFn(i) must return a float in [-1, 1].
|
||||
// Mixing maths: decode u8 → s16, apply op, clamp, dither back to u8.
|
||||
function mixInto(buf, lengthSec, offsetSec, op, amp, pan, sampleFn) {
|
||||
const startIdx = secToSamples(offsetSec)
|
||||
const n = secToSamples(lengthSec)
|
||||
// Linear pan law: centre (pan=0) → both channels at full amp
|
||||
const gainL = Math.max(0, Math.min(1, 1.0 - pan))
|
||||
const gainR = Math.max(0, Math.min(1, 1.0 + pan))
|
||||
const opCode = (op === 'sub') ? 1 : (op === 'mul') ? 2 : 0 // default: add
|
||||
for (let i = 0; i < n; i++) {
|
||||
const v = sampleFn(i) // oscillator value in [-1, 1]
|
||||
const oscBase = v * amp * 32767
|
||||
const oscL = Math.round(oscBase * gainL) | 0
|
||||
const oscR = Math.round(oscBase * gainR) | 0
|
||||
for (let ch = 0; ch < 2; ch++) {
|
||||
const osc = (ch === 0) ? oscL : oscR
|
||||
const cur = (readU8(buf, ch, startIdx + i) - 128) << 8
|
||||
let out
|
||||
switch (opCode) {
|
||||
case 0: out = cur + osc; break
|
||||
case 1: out = cur - osc; break
|
||||
case 2: out = (cur * osc) >> 15; break
|
||||
}
|
||||
writeU8(buf, ch, startIdx + i, s16Tou8(clampS16(out)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Waveform generators ─────────────────────────────────────────────────────
|
||||
|
||||
function makeSquare(buf, length, offset, freq, duty, op, amp, pan, phaseOffset) {
|
||||
// buffer: [Uint8Array, Uint8Array] or native buffer
|
||||
// length: in seconds
|
||||
// offset: in seconds
|
||||
// duty: 0.0 to 1.0. default 0.5 (fraction of period where output is +1)
|
||||
// freq: Hz
|
||||
// op: add / mul / sub; default: add
|
||||
// amp: 0.0 to 1.0; default: 0.5
|
||||
// pan: -1.0 to 1.0; default: 0.0
|
||||
// phaseOffset: optional absolute-time base (seconds) added to phase calc only,
|
||||
// not to the buffer write position — use to ensure phase continuity
|
||||
// across successive calls (e.g. frame boundaries).
|
||||
if (duty == null) duty = 0.5
|
||||
if (op == null) op = 'add'
|
||||
if (amp == null) amp = 0.5
|
||||
if (pan == null) pan = 0.0
|
||||
const tBase = (phaseOffset || 0) + offset
|
||||
mixInto(buf, length, offset, op, amp, pan, function(i) {
|
||||
const phase = ((tBase + i / HW_SAMPLING_RATE) * freq) % 1
|
||||
return (phase < duty) ? 1.0 : -1.0
|
||||
})
|
||||
}
|
||||
|
||||
function makeTriangle(buf, length, offset, freq, duty, op, amp, pan, phaseOffset) {
|
||||
// buffer: [Uint8Array, Uint8Array] or native buffer
|
||||
// length: in seconds
|
||||
// offset: in seconds
|
||||
// duty: skew. -1.0 = falling sawtooth, 0.0 = symmetric triangle, 1.0 = rising sawtooth
|
||||
// freq: Hz
|
||||
// op: add / mul / sub; default: add
|
||||
// amp: 0.0 to 1.0; default: 0.5
|
||||
// pan: -1.0 to 1.0; default: 0.0
|
||||
// phaseOffset: optional absolute-time base (seconds) added to phase calc only —
|
||||
// see makeSquare for details.
|
||||
if (duty == null) duty = 0.0
|
||||
if (op == null) op = 'add'
|
||||
if (amp == null) amp = 0.5
|
||||
if (pan == null) pan = 0.0
|
||||
// riseFrac: fraction of period spent rising from -1 to +1
|
||||
// 0.0 → falling saw, 0.5 → symmetric triangle, 1.0 → rising saw
|
||||
const riseFrac = (duty + 1.0) * 0.5
|
||||
const tBase = (phaseOffset || 0) + offset
|
||||
mixInto(buf, length, offset, op, amp, pan, function(i) {
|
||||
const phase = ((tBase + i / HW_SAMPLING_RATE) * freq) % 1
|
||||
if (riseFrac <= 0) {
|
||||
return 1.0 - 2.0 * phase // falling saw
|
||||
} else if (riseFrac >= 1) {
|
||||
return -1.0 + 2.0 * phase // rising saw
|
||||
} else if (phase < riseFrac) {
|
||||
return -1.0 + 2.0 * (phase / riseFrac) // rising slope
|
||||
} else {
|
||||
return 1.0 - 2.0 * ((phase - riseFrac) / (1.0 - riseFrac)) // falling slope
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function makeAliasedTriangle(buf, length, offset, freq, duty, op, amp, pan, phaseOffset) {
|
||||
// buffer: [Uint8Array, Uint8Array] or native buffer
|
||||
// Famicom-style triangle — output is quantised to 16 DAC levels (4-bit, NES APU style).
|
||||
// The staircase quantisation introduces harmonics that mimic NES character.
|
||||
// length: in seconds
|
||||
// offset: in seconds
|
||||
// duty: skew. -1.0 = falling sawtooth, 0.0 = symmetric triangle, 1.0 = rising sawtooth
|
||||
// freq: Hz
|
||||
// op: add / mul / sub; default: add
|
||||
// amp: 0.0 to 1.0; default: 0.5
|
||||
// pan: -1.0 to 1.0; default: 0.0
|
||||
// phaseOffset: optional absolute-time base (seconds) added to phase calc only —
|
||||
// see makeSquare for details.
|
||||
if (duty == null) duty = 0.0
|
||||
if (op == null) op = 'add'
|
||||
if (amp == null) amp = 0.5
|
||||
if (pan == null) pan = 0.0
|
||||
const riseFrac = (duty + 1.0) * 0.5
|
||||
const tBase = (phaseOffset || 0) + offset
|
||||
mixInto(buf, length, offset, op, amp, pan, function(i) {
|
||||
const phase = ((tBase + i / HW_SAMPLING_RATE) * freq) % 1
|
||||
let v
|
||||
if (riseFrac <= 0) {
|
||||
v = 1.0 - 2.0 * phase
|
||||
} else if (riseFrac >= 1) {
|
||||
v = -1.0 + 2.0 * phase
|
||||
} else if (phase < riseFrac) {
|
||||
v = -1.0 + 2.0 * (phase / riseFrac)
|
||||
} else {
|
||||
v = 1.0 - 2.0 * ((phase - riseFrac) / (1.0 - riseFrac))
|
||||
}
|
||||
// Quantise to 16 levels (NES triangle 4-bit DAC: 0..15 → -1..+1)
|
||||
const level = Math.max(0, Math.min(15, Math.round((v + 1.0) * 7.5)))
|
||||
return level / 7.5 - 1.0
|
||||
})
|
||||
}
|
||||
|
||||
// ── LFSR helpers (for noise types 1 and 2) ─────────────────────────────────
|
||||
|
||||
function lfsrStep(state, mode) {
|
||||
// mode 0 (long/NES mode 0): feedback tap at bit 1; period 32767
|
||||
// mode 1 (short/NES mode 1): feedback tap at bit 6; period 93 (metallic/tonal)
|
||||
const bit0 = state & 1
|
||||
const bitTap = (mode === 0) ? (state >> 1) & 1 : (state >> 6) & 1
|
||||
const feed = bit0 ^ bitTap
|
||||
return ((feed << 14) | (state >> 1)) & 0x7FFF
|
||||
}
|
||||
|
||||
function lfsrAdvance(state, steps, mode) {
|
||||
for (let k = 0; k < steps; k++) state = lfsrStep(state, mode)
|
||||
return state
|
||||
}
|
||||
|
||||
// NES APU documented LFSR periods
|
||||
const LFSR_PERIOD_LONG = 32767 // mode 0
|
||||
const LFSR_PERIOD_SHORT = 93 // mode 1
|
||||
|
||||
function makeNoise(buf, length, offset, freq, type, op, amp, pan, phaseOffset) {
|
||||
// buffer: [Uint8Array, Uint8Array] or native buffer
|
||||
// length: in seconds
|
||||
// offset: in seconds
|
||||
// type:
|
||||
// -1: 8-bit white noise (random float per period, sample-and-hold)
|
||||
// 0: 1-bit white noise (random ±1 per period, sample-and-hold)
|
||||
// 1: 1-bit LFSR long mode — NES mode 0, tap=bit0^bit1, period 32767 (full-spectrum)
|
||||
// 2: 1-bit LFSR short mode — NES mode 1, tap=bit0^bit6, period 93 (metallic/tonal)
|
||||
// freq: Hz (clock rate of the noise generator)
|
||||
// op: add / mul / sub; default: add
|
||||
// amp: 0.0 to 1.0; default: 0.5
|
||||
// pan: -1.0 to 1.0; default: 0.0
|
||||
// phaseOffset: optional absolute-time base (seconds) added to phase/LFSR calc only —
|
||||
// see makeSquare for details.
|
||||
//
|
||||
// LFSR types (1 and 2) are deterministic given (phaseOffset+offset, freq): calling
|
||||
// with monotonically advancing phaseOffset+offset produces a seamless noise stream
|
||||
// across frames. White noise types (-1, 0) are random per call.
|
||||
if (op == null) op = 'add'
|
||||
if (amp == null) amp = 0.5
|
||||
if (pan == null) pan = 0.0
|
||||
const tBase = (phaseOffset || 0) + offset
|
||||
|
||||
if (type === -1) {
|
||||
// 8-bit white: new random float in [-1, 1] each clock period
|
||||
let prevClock = -1
|
||||
let noiseVal = 0.0
|
||||
mixInto(buf, length, offset, op, amp, pan, function(i) {
|
||||
const currentClock = Math.floor((tBase + i / HW_SAMPLING_RATE) * freq) | 0
|
||||
if (currentClock !== prevClock) {
|
||||
prevClock = currentClock
|
||||
noiseVal = Math.random() * 2.0 - 1.0
|
||||
}
|
||||
return noiseVal
|
||||
})
|
||||
} else if (type === 0) {
|
||||
// 1-bit white: random ±1 each clock period
|
||||
let prevClock = -1
|
||||
let noiseVal = 1.0
|
||||
mixInto(buf, length, offset, op, amp, pan, function(i) {
|
||||
const currentClock = Math.floor((tBase + i / HW_SAMPLING_RATE) * freq) | 0
|
||||
if (currentClock !== prevClock) {
|
||||
prevClock = currentClock
|
||||
noiseVal = (Math.random() >= 0.5) ? 1.0 : -1.0
|
||||
}
|
||||
return noiseVal
|
||||
})
|
||||
} else {
|
||||
// LFSR-based noise (types 1 and 2)
|
||||
const mode = (type === 2) ? 1 : 0
|
||||
const period = (mode === 0) ? LFSR_PERIOD_LONG : LFSR_PERIOD_SHORT
|
||||
// Advance to deterministic position for this tBase so consecutive frame
|
||||
// calls with monotonically advancing phaseOffset produce a seamless noise stream.
|
||||
const startClock = Math.floor(tBase * freq) | 0
|
||||
let lfsr = lfsrAdvance(1, startClock % period, mode)
|
||||
let prevClock = startClock
|
||||
mixInto(buf, length, offset, op, amp, pan, function(i) {
|
||||
const currentClock = Math.floor((tBase + i / HW_SAMPLING_RATE) * freq) | 0
|
||||
const delta = currentClock - prevClock
|
||||
if (delta > 0) {
|
||||
const steps = delta % period
|
||||
if (steps > 0) lfsr = lfsrAdvance(lfsr, steps, mode)
|
||||
prevClock = currentClock
|
||||
}
|
||||
return (lfsr & 1) ? 1.0 : -1.0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function makeAliasedTriangleNES(buf, length, offset, freq, duty, op, amp, pan, phaseOffset) {
|
||||
// NES APU triangle — quantised to the authentic 32-step, 4-bit (0..15) staircase.
|
||||
// The 32-step sequence is: 15,14,...,1,0, 0,1,...,14,15 (descending then ascending).
|
||||
// This mirrors the real NES triangle DAC which has 32 equal-height steps per period.
|
||||
// duty parameter is accepted for API symmetry but ignored (NES triangle is always symmetric).
|
||||
// phaseOffset: optional absolute-time base (seconds) — see makeSquare for details.
|
||||
if (op == null) op = 'add'
|
||||
if (amp == null) amp = 0.5
|
||||
if (pan == null) pan = 0.0
|
||||
const tBase = (phaseOffset || 0) + offset
|
||||
mixInto(buf, length, offset, op, amp, pan, function(i) {
|
||||
const phase = ((tBase + i / HW_SAMPLING_RATE) * freq) % 1
|
||||
const step32 = Math.floor(phase * 32) | 0 // 0..31
|
||||
// step 0..15: descend from 15 to 0; step 16..31: ascend from 0 to 15
|
||||
const level = (step32 < 16) ? (15 - step32) : (step32 - 16)
|
||||
return level / 7.5 - 1.0 // map 0..15 → -1..+1
|
||||
})
|
||||
}
|
||||
|
||||
// ── Send to audio hardware ──────────────────────────────────────────────────
|
||||
|
||||
function sendBuffer(buf, playhead, offsetSec, lengthSec, stagingPtr) {
|
||||
// Interleaves the L and R channels into a staging region (LRLRLR…) and uploads
|
||||
// to the audio adapter pcmBin via the standard putPcmDataByPtr pipeline.
|
||||
//
|
||||
// offsetSec: start of region to send (default: 0)
|
||||
// lengthSec: duration to send (default: entire buffer from offsetSec)
|
||||
// stagingPtr: optional caller-owned native buffer (≥ min(chunk, 32768) * 2 bytes).
|
||||
// Pass a pre-allocated pointer to avoid malloc/free per call —
|
||||
// useful for the per-frame tvnes pattern.
|
||||
//
|
||||
// The function auto-chunks at 32768 stereo samples (pcmBin capacity).
|
||||
// Blocks briefly if the audio queue is saturated (queue depth > 2).
|
||||
const start = (offsetSec != null) ? secToSamples(offsetSec) : 0
|
||||
const total = (lengthSec != null) ? secToSamples(lengthSec) : (buf.samples - start)
|
||||
const MAX_CHUNK = 32768 // pcmBin = 65536 bytes; stereo → max 32768 samples per upload
|
||||
const ownsStaging = (stagingPtr == null)
|
||||
if (ownsStaging) stagingPtr = sys.malloc(Math.min(total, MAX_CHUNK) * 2)
|
||||
|
||||
let remaining = total
|
||||
let cursor = start
|
||||
while (remaining > 0) {
|
||||
const take = Math.min(remaining, MAX_CHUNK)
|
||||
// Interleave L, R into staging buffer
|
||||
for (let i = 0; i < take; i++) {
|
||||
sys.poke(stagingPtr + 2 * i, readU8(buf, 0, cursor + i))
|
||||
sys.poke(stagingPtr + 2 * i + 1, readU8(buf, 1, cursor + i))
|
||||
}
|
||||
// Wait for room in the playback queue (mirrors playwav.js idiom)
|
||||
// while (audio.getPosition(playhead) > 2) sys.sleep(2)
|
||||
audio.putPcmDataByPtr(playhead, stagingPtr, take * 2, 0)
|
||||
audio.setSampleUploadLength(playhead, take * 2)
|
||||
audio.startSampleUpload(playhead)
|
||||
remaining -= take
|
||||
cursor += take
|
||||
}
|
||||
|
||||
if (ownsStaging) sys.free(stagingPtr)
|
||||
}
|
||||
|
||||
// Lazily-allocated JS-side interleave scratch; shared across sendBufferFast calls.
|
||||
let _sendFastScratch = null
|
||||
|
||||
function sendBufferFast(buf, playhead, offsetSec, lengthSec, stagingPtr) {
|
||||
// Like sendBuffer but interleaves L/R via a JS Uint8Array + one sys.pokeBytes per chunk,
|
||||
// instead of ~2n sys.poke calls. Requires a non-native (JS-backed) buffer.
|
||||
// Falls back to sendBuffer for native buffers.
|
||||
if (isNative(buf)) { sendBuffer(buf, playhead, offsetSec, lengthSec, stagingPtr); return }
|
||||
|
||||
const start = (offsetSec != null) ? secToSamples(offsetSec) : 0
|
||||
const total = (lengthSec != null) ? secToSamples(lengthSec) : (buf.samples - start)
|
||||
const MAX_CHUNK = 32768
|
||||
const ownsStaging = (stagingPtr == null)
|
||||
if (ownsStaging) stagingPtr = sys.malloc(Math.min(total, MAX_CHUNK) * 2)
|
||||
|
||||
const scratchNeeded = Math.min(total, MAX_CHUNK) * 2
|
||||
if (_sendFastScratch == null || _sendFastScratch.length < scratchNeeded) {
|
||||
_sendFastScratch = new Uint8Array(scratchNeeded)
|
||||
}
|
||||
|
||||
let remaining = total
|
||||
let cursor = start
|
||||
while (remaining > 0) {
|
||||
const take = Math.min(remaining, MAX_CHUNK)
|
||||
const L = buf[0], R = buf[1], sc = _sendFastScratch
|
||||
for (let i = 0; i < take; i++) {
|
||||
sc[2 * i] = L[cursor + i]
|
||||
sc[2 * i + 1] = R[cursor + i]
|
||||
}
|
||||
sys.pokeBytes(stagingPtr, sc.subarray(0, take * 2), take * 2)
|
||||
// while (audio.getPosition(playhead) > 2) sys.sleep(2)
|
||||
audio.putPcmDataByPtr(playhead, stagingPtr, take * 2, 0)
|
||||
audio.setSampleUploadLength(playhead, take * 2)
|
||||
audio.startSampleUpload(playhead)
|
||||
remaining -= take
|
||||
cursor += take
|
||||
}
|
||||
|
||||
if (ownsStaging) sys.free(stagingPtr)
|
||||
}
|
||||
|
||||
exports = {
|
||||
HW_SAMPLING_RATE,
|
||||
makeBuffer, makeBufferNative, freeBufferNative, clearBuffer,
|
||||
makeSquare, makeTriangle, makeAliasedTriangle, makeAliasedTriangleNES, makeNoise,
|
||||
sendBuffer, sendBufferFast
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
|
||||
/**
|
||||
* LibSeqread — sequentially read files from disk drive
|
||||
* @author CuriousTorvald
|
||||
*/
|
||||
let readCount = 0
|
||||
let port = undefined
|
||||
let fileHeader = new Uint8Array(4096)
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* LibSeqread extension for Tape Drive — sequentially read tape
|
||||
* @author CuriousTorvald
|
||||
*/
|
||||
|
||||
// Sequential reader for HSDPA TAPE devices
|
||||
// Unlike seqread.mjs which is limited to 4096 bytes per read due to serial communication,
|
||||
// this module can read larger chunks efficiently from HSDPA devices.
|
||||
|
||||
321
assets/disk0/tvdos/include/taud.mjs
Normal file
321
assets/disk0/tvdos/include/taud.mjs
Normal file
@@ -0,0 +1,321 @@
|
||||
/*
|
||||
* LibTaud — Helper functions for interaction between Taud format and TSVM Tracker
|
||||
* Requires TVDOS to function.
|
||||
* @author CuriousTorvald
|
||||
*/
|
||||
|
||||
// ── Format constants ────────────────────────────────────────────────────────
|
||||
|
||||
const TAUD_MAGIC = [0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64] // \x1F TSVMaud
|
||||
const TAUD_VERSION = 1
|
||||
const TAUD_HEADER_SIZE = 32 // magic(8) + version(1) + numSongs(1) + compSize(4) + projOff(4) + sig(14)
|
||||
const TAUD_SONG_ENTRY = 32 // see encodeSongEntry / decodeSongEntry below
|
||||
// Sample+instrument image: 8 MB sample pool (banked, 16 × 512 K) + 64 K instrument bin = 8256 kB total.
|
||||
// (terranmon.txt:1985-1997, 2533-2564 — bank-switched via MMIO 46.)
|
||||
const SAMPLE_BANK_SIZE = 524288 // 512 K — size of the sample-bin window
|
||||
const SAMPLE_BANK_COUNT = 16 // 16 banks × 512 K = 8 MB
|
||||
const SAMPLEBIN_SIZE = SAMPLE_BANK_SIZE * SAMPLE_BANK_COUNT // 8 MB
|
||||
const INSTBIN_SIZE = 65536 // 256 inst × 256 bytes
|
||||
const SAMPLEINST_SIZE = SAMPLEBIN_SIZE + INSTBIN_SIZE // 8454144 = 8256 kB
|
||||
const SAMPLEBIN_WINDOW_OFFSET = 0 // peripheral memory window for the active sample bank
|
||||
const INSTBIN_WINDOW_OFFSET = 720896 // peripheral memory offset of instrument bin
|
||||
const PATTERN_SIZE = 512 // bytes per pattern (64 rows × 8 bytes)
|
||||
const NUM_PATTERNS_MAX = 256
|
||||
const NUM_CUES = 1024
|
||||
const CUE_SIZE = 32 // bytes per cue entry (packed 12-bit×20 voices + instruction + pad)
|
||||
|
||||
// Signature written into the file (14 bytes, space-padded)
|
||||
const CAPTURE_SIGNATURE = "LibTaud/TSVM "
|
||||
|
||||
// ── Internal helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function _peekU32LE(ptr, off) {
|
||||
return ((sys.peek(ptr+off) & 0xFF) ) |
|
||||
((sys.peek(ptr+off+1) & 0xFF) << 8 ) |
|
||||
((sys.peek(ptr+off+2) & 0xFF) << 16 ) |
|
||||
((sys.peek(ptr+off+3) & 0xFF) * 0x1000000) // avoid sign-extend
|
||||
}
|
||||
|
||||
function _pokeU32LE(ptr, off, v) {
|
||||
sys.poke(ptr+off, (v ) & 0xFF)
|
||||
sys.poke(ptr+off+1, (v >>> 8) & 0xFF)
|
||||
sys.poke(ptr+off+2, (v >>> 16) & 0xFF)
|
||||
sys.poke(ptr+off+3, (v >>> 24) & 0xFF)
|
||||
}
|
||||
|
||||
// ── uploadTaudFile ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load one song from a Taud file into the tracker hardware and configure the
|
||||
* given playhead ready to play.
|
||||
*
|
||||
* @param inFile Full path with drive letter, e.g. "A:/music/song.taud"
|
||||
* @param songIndex 0-based index of the song in the SONG TABLE
|
||||
* @param playhead Playhead number (0-3) to configure
|
||||
*/
|
||||
function uploadTaudFile(inFile, songIndex, playhead) {
|
||||
const drive = inFile[0].toUpperCase()
|
||||
const diskPath = inFile.substring(2)
|
||||
|
||||
const memBase = audio.getMemAddr()
|
||||
|
||||
// -- 1. Read whole file into VM memory ------------------------------------
|
||||
const fileHandle = files.open(inFile)
|
||||
|
||||
if (!fileHandle.exists) {
|
||||
throw Error("taud: file not exists")
|
||||
}
|
||||
|
||||
const fileSize = fileHandle.size
|
||||
const filePtr = sys.malloc(fileSize)
|
||||
fileHandle.pread(filePtr, fileSize, 0)
|
||||
|
||||
let pos = 0
|
||||
|
||||
// -- 2. Verify magic ------------------------------------------------------
|
||||
for (let i = 0; i < 8; i++) {
|
||||
let magicc = sys.peek(filePtr + i)
|
||||
if (magicc !== TAUD_MAGIC[i]) {
|
||||
sys.free(filePtr)
|
||||
throw Error("taud: bad magic byte " + magicc.toString(16) + " at index " + i)
|
||||
}
|
||||
}
|
||||
pos = 8
|
||||
|
||||
// -- 3. Parse header ------------------------------------------------------
|
||||
// version(1) + numSongs(1) + compressedSize(4) + rsvd(2) + signature(16) = 24 bytes
|
||||
let version = sys.peek(filePtr + pos) & 0xFF; pos++
|
||||
let numSongs = sys.peek(filePtr + pos) & 0xFF; pos++
|
||||
let compressedSize = _peekU32LE(filePtr, pos); pos += 4
|
||||
pos += 18 // skip reserved(2) + signature(16)
|
||||
// pos == 32 == TAUD_HEADER_SIZE
|
||||
|
||||
if (songIndex < 0 || songIndex >= numSongs) {
|
||||
sys.free(filePtr)
|
||||
throw Error("taud: songIndex " + songIndex + " out of range (numSongs=" + numSongs + ")")
|
||||
}
|
||||
|
||||
// -- 4. Decompress and upload sample+instrument bin -----------------------
|
||||
// The decompressed image is 8256 kB (8 MB samples bank-major + 64 K instruments)
|
||||
// which exceeds the 8 MB user-space cap, so we route through a hardware helper
|
||||
// that decompresses straight into the adapter's native sample/instrument
|
||||
// storage instead of staging a buffer in user memory.
|
||||
audio.uploadSampleInstBlob(filePtr + pos, compressedSize)
|
||||
audio.setSampleBank(0)
|
||||
pos += compressedSize
|
||||
|
||||
// -- 5. Parse song-table entry for the requested song --------------------
|
||||
let entryOff = pos + songIndex * TAUD_SONG_ENTRY
|
||||
let songOffset = _peekU32LE(filePtr, entryOff)
|
||||
let numVoices = sys.peek(filePtr + entryOff + 4) & 0xFF
|
||||
let numPatsLo = sys.peek(filePtr + entryOff + 5) & 0xFF
|
||||
let numPatsHi = sys.peek(filePtr + entryOff + 6) & 0xFF
|
||||
let bpmStored = sys.peek(filePtr + entryOff + 7) & 0xFF
|
||||
let tickRate = sys.peek(filePtr + entryOff + 8) & 0xFF
|
||||
let mixerflags = sys.peek(filePtr + entryOff + 15) & 0xFF
|
||||
let songGlobalVolume = sys.peek(filePtr + entryOff + 16) & 0xFF
|
||||
let songMixingVolume = sys.peek(filePtr + entryOff + 17) & 0xFF
|
||||
let patBinCompSize = _peekU32LE(filePtr, entryOff + 18)
|
||||
let cueSheetCompSize = _peekU32LE(filePtr, entryOff + 22)
|
||||
|
||||
let bpm = bpmStored + 25
|
||||
let patsToLoad = numPatsLo | (numPatsHi << 8)
|
||||
|
||||
// -- 6. Decompress + upload patterns --------------------------------------
|
||||
let patBinSize = patsToLoad * PATTERN_SIZE
|
||||
let patBinPtr = sys.malloc(patBinSize)
|
||||
gzip.decompFromTo(filePtr + songOffset, patBinCompSize, patBinPtr)
|
||||
|
||||
let patBytes = new Array(PATTERN_SIZE)
|
||||
for (let p = 0; p < patsToLoad; p++) {
|
||||
for (let k = 0; k < PATTERN_SIZE; k++)
|
||||
patBytes[k] = sys.peek(patBinPtr + p * PATTERN_SIZE + k) & 0xFF
|
||||
audio.uploadPattern(p, patBytes)
|
||||
}
|
||||
sys.free(patBinPtr)
|
||||
|
||||
// -- 7. Decompress + upload cue sheet -------------------------------------
|
||||
let cueSheetSize = NUM_CUES * CUE_SIZE
|
||||
let cueSheetPtr = sys.malloc(cueSheetSize)
|
||||
gzip.decompFromTo(filePtr + songOffset + patBinCompSize, cueSheetCompSize, cueSheetPtr)
|
||||
|
||||
let cueBytes = new Array(CUE_SIZE)
|
||||
for (let c = 0; c < NUM_CUES; c++) {
|
||||
for (let k = 0; k < CUE_SIZE; k++)
|
||||
cueBytes[k] = sys.peek(cueSheetPtr + c * CUE_SIZE + k) & 0xFF
|
||||
audio.uploadCue(c, cueBytes)
|
||||
}
|
||||
sys.free(cueSheetPtr)
|
||||
|
||||
// -- 8. Configure playhead ------------------------------------------------
|
||||
audio.setTrackerMode(playhead)
|
||||
audio.setBPM(playhead, bpm)
|
||||
audio.setTickRate(playhead, tickRate > 0 ? tickRate : 6)
|
||||
audio.setTrackerMixerFlags(playhead, mixerflags)
|
||||
audio.setSongGlobalVolume(playhead, songGlobalVolume)
|
||||
audio.setSongMixingVolume(playhead, songMixingVolume)
|
||||
|
||||
|
||||
fileHandle.close()
|
||||
sys.free(filePtr)
|
||||
}
|
||||
|
||||
// ── captureTrackerDataToFile ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Dump the current tracker hardware state (sample bin, instruments, patterns
|
||||
* in bank 0, cue sheet) to a single-song Taud file. BPM and tick-rate are
|
||||
* taken from playhead 0.
|
||||
*
|
||||
* @param outFile Full path with drive letter, e.g. "A:/music/out.taud"
|
||||
*/
|
||||
function captureTrackerDataToFile(outFile) {
|
||||
const drive = outFile[0].toUpperCase()
|
||||
const diskPath = outFile.substring(2)
|
||||
|
||||
const memBase = audio.getMemAddr()
|
||||
const baseAddr = audio.getBaseAddr()
|
||||
|
||||
// -- 1. Compress sample+instrument bin ------------------------------------
|
||||
// The 8256 kB raw image (8 MB samples + 64 K instruments) cannot fit in the
|
||||
// 8 MB user space, so we hand the entire compress step to a hardware helper
|
||||
// that reads directly out of the adapter's native sample/instrument storage.
|
||||
// Realistic sample data compresses well under both gzip and zstd; we cap the
|
||||
// destination at "uncompressed size + 8 K" headroom which suffices for any
|
||||
// sane musical content.
|
||||
const COMP_BUF_CAP = 1024 * 1024 * 4 // 4 MiB cap for compressed sample+inst blob
|
||||
let compBuf = sys.malloc(COMP_BUF_CAP)
|
||||
let compressedSize = audio.captureSampleInstBlob(compBuf, COMP_BUF_CAP)
|
||||
if (compressedSize > COMP_BUF_CAP) {
|
||||
sys.free(compBuf)
|
||||
throw Error("taud: compressed sample+inst blob exceeded " + COMP_BUF_CAP + " bytes (got " + compressedSize + ")")
|
||||
}
|
||||
|
||||
// -- 2. Find last non-empty pattern in bank 0 (all-zero = uninitialized) --
|
||||
let numPatsActual = 0
|
||||
outer: for (let p = NUM_PATTERNS_MAX - 1; p >= 0; p--) {
|
||||
let patBase = 131072 + p * PATTERN_SIZE // offset within peripheral memory space
|
||||
for (let k = 0; k < PATTERN_SIZE; k++) {
|
||||
if ((sys.peek(memBase - (patBase + k)) & 0xFF) !== 0) {
|
||||
numPatsActual = p + 1
|
||||
break outer
|
||||
}
|
||||
}
|
||||
}
|
||||
if (numPatsActual === 0) numPatsActual = 1 // always emit at least one pattern slot
|
||||
|
||||
let numPats = numPatsActual // Uint16, 1-65535
|
||||
let patsToSave = numPatsActual
|
||||
|
||||
// -- 3. BPM / tick-rate / volumes from playhead 0 -------------------------
|
||||
let bpm = audio.getBPM(0) || 125
|
||||
let tickRate = audio.getTickRate(0) || 6
|
||||
let bpmStored = (bpm - 25) & 0xFF
|
||||
let songGlobalVolume = audio.getSongGlobalVolume(0)
|
||||
let songMixingVolume = audio.getSongMixingVolume(0)
|
||||
if (songGlobalVolume === undefined || songGlobalVolume === null) songGlobalVolume = 0x80
|
||||
if (songMixingVolume === undefined || songMixingVolume === null) songMixingVolume = 0x80
|
||||
|
||||
// -- 4. Compress pattern bin ----------------------------------------------
|
||||
let patBinSize = patsToSave * PATTERN_SIZE
|
||||
let patBuf = sys.malloc(patBinSize)
|
||||
sys.memcpy(memBase - 131072, patBuf, patBinSize)
|
||||
|
||||
let patCompBuf = sys.malloc(patBinSize + 4096)
|
||||
let patCompSize = gzip.compFromTo(patBuf, patBinSize, patCompBuf)
|
||||
sys.free(patBuf)
|
||||
|
||||
// -- 5. Compress cue sheet ------------------------------------------------
|
||||
// Cue entry c, byte k is at MMIO address 32768 + c*32 + k,
|
||||
// accessed as sys.peek(baseAddr − (32768 + c*32 + k)).
|
||||
let cueSheetSize = NUM_CUES * CUE_SIZE
|
||||
let cueBuf = sys.malloc(cueSheetSize)
|
||||
for (let c = 0; c < NUM_CUES; c++) {
|
||||
let cueOff = 32768 + c * CUE_SIZE
|
||||
for (let k = 0; k < CUE_SIZE; k++)
|
||||
sys.poke(cueBuf + c * CUE_SIZE + k,
|
||||
sys.peek(baseAddr - (cueOff + k)) & 0xFF)
|
||||
}
|
||||
|
||||
let cueCompBuf = sys.malloc(cueSheetSize + 4096)
|
||||
let cueCompSize = gzip.compFromTo(cueBuf, cueSheetSize, cueCompBuf)
|
||||
sys.free(cueBuf)
|
||||
|
||||
// -- 6. Compute song offset (absolute from file start) --------------------
|
||||
// Layout: header(32) + compressed(compressedSize) + songTable(1 × TAUD_SONG_ENTRY)
|
||||
let songOffset = TAUD_HEADER_SIZE + compressedSize + 1 * TAUD_SONG_ENTRY
|
||||
|
||||
// -- 7. Build header byte array (32 bytes) --------------------------------
|
||||
let sigBytes = new Array(14)
|
||||
for (let i = 0; i < 14; i++)
|
||||
sigBytes[i] = i < CAPTURE_SIGNATURE.length ? CAPTURE_SIGNATURE.charCodeAt(i) : 0
|
||||
|
||||
let header = [
|
||||
// Magic (8)
|
||||
0x1F, 0x54, 0x53, 0x56, 0x4D, 0x61, 0x75, 0x64,
|
||||
// version, numSongs
|
||||
TAUD_VERSION, 1,
|
||||
// compressedSize uint32 LE (4) -- sample+inst bin
|
||||
(compressedSize ) & 0xFF,
|
||||
(compressedSize >>> 8) & 0xFF,
|
||||
(compressedSize >>> 16) & 0xFF,
|
||||
(compressedSize >>> 24) & 0xFF,
|
||||
// project data offset (4) -- not emitted
|
||||
0x00, 0x00, 0x00, 0x00,
|
||||
].concat(sigBytes) // 8 + 2 + 4 + 4 + 14 = 32 bytes
|
||||
|
||||
// -- 8. Build song-table row (32 bytes) -----------------------------------
|
||||
let songTable = [
|
||||
(songOffset ) & 0xFF,
|
||||
(songOffset >>> 8) & 0xFF,
|
||||
(songOffset >>> 16) & 0xFF,
|
||||
(songOffset >>> 24) & 0xFF,
|
||||
20, // numVoices
|
||||
numPats & 0xFF, (numPats >>> 8) & 0xFF, // numPatterns Uint16 LE
|
||||
bpmStored, // BPM with −25 bias
|
||||
tickRate, // initial tick-rate
|
||||
0x00,0xA0, // basenote (0xA000 -- C9)
|
||||
0x00,0xAC,0x02,0x46, // basefreq (8363 Hz)
|
||||
sys.peek(baseAddr - 7), // mixer flags
|
||||
songGlobalVolume & 0xFF, // global volume
|
||||
songMixingVolume & 0xFF, // mixing volume
|
||||
// pattern bin compressed size (4)
|
||||
(patCompSize ) & 0xFF,
|
||||
(patCompSize >>> 8) & 0xFF,
|
||||
(patCompSize >>> 16) & 0xFF,
|
||||
(patCompSize >>> 24) & 0xFF,
|
||||
// cue sheet compressed size (4)
|
||||
(cueCompSize ) & 0xFF,
|
||||
(cueCompSize >>> 8) & 0xFF,
|
||||
(cueCompSize >>> 16) & 0xFF,
|
||||
(cueCompSize >>> 24) & 0xFF,
|
||||
// reserved (6)
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
]
|
||||
|
||||
// -- 9. Write header (creates / truncates file) ---------------------------
|
||||
const fileHandle = files.open(outFile)
|
||||
fileHandle.bwrite(header)
|
||||
|
||||
// -- 10. Append compressed sample+inst bin --------------------------------
|
||||
fileHandle.pwrite(compBuf, compressedSize, TAUD_HEADER_SIZE)
|
||||
sys.free(compBuf)
|
||||
|
||||
// -- 11. Write song table -------------------------------------------------
|
||||
fileHandle.bwrite(songTable)
|
||||
|
||||
// -- 12. Append compressed pattern bin ------------------------------------
|
||||
fileHandle.pwrite(patCompBuf, patCompSize,
|
||||
TAUD_HEADER_SIZE + compressedSize + songTable.length)
|
||||
sys.free(patCompBuf)
|
||||
|
||||
// -- 13. Append compressed cue sheet --------------------------------------
|
||||
fileHandle.pwrite(cueCompBuf, cueCompSize,
|
||||
TAUD_HEADER_SIZE + compressedSize + songTable.length + patCompSize)
|
||||
sys.free(cueCompBuf)
|
||||
|
||||
|
||||
fileHandle.flush(); fileHandle.close()
|
||||
}
|
||||
|
||||
exports = { uploadTaudFile, captureTrackerDataToFile }
|
||||
621
assets/disk0/tvdos/include/tbas.mjs
Normal file
621
assets/disk0/tvdos/include/tbas.mjs
Normal file
@@ -0,0 +1,621 @@
|
||||
// Terran BASIC runtime helper for compiled programs
|
||||
// Compiled-by: assets/disk0/tbas/compile.js
|
||||
// Loaded at runtime by `let bS = require("tbas")`
|
||||
//
|
||||
// Contract with compiler:
|
||||
// - The compiler has lowered every BASIC expression to a JS expression
|
||||
// that produces the *raw* JS value (number, string, array, ForGen,
|
||||
// function, BasicMemoMonad, …). Builtins take such raw values, NOT
|
||||
// SyntaxTreeReturnObj wrappers.
|
||||
// - Variable reads: bS.__state.vars.X (key always uppercased)
|
||||
// - Variable writes: bS.__state.vars.X = v
|
||||
// - Control flow (GOTO/GOSUB/RETURN/FOR/NEXT/IF/ON/END/READ/RESTORE/LABEL/DATA)
|
||||
// is *not* exposed here — the compiler emits inline JS that updates the
|
||||
// `pc` and `gosubStack` directly.
|
||||
//
|
||||
// Naming: BASIC builtins exposed under their UPPERCASE name (bS.PRINT,
|
||||
// bS.PLOT, bS.SIN). Compiler-only helpers prefixed with __.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types & helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isNumable(s) {
|
||||
if (Array.isArray(s)) return false
|
||||
if (s === undefined) return false
|
||||
if (typeof s.trim == "function" && s.trim().length == 0) return false
|
||||
return !isNaN(s)
|
||||
}
|
||||
const tonum = (t) => t * 1.0
|
||||
|
||||
function ForGen(s, e, t) {
|
||||
this.start = s
|
||||
this.end = e
|
||||
this.step = t || 1
|
||||
this.current = this.start
|
||||
this.stepsgn = (this.step > 0) ? 1 : -1
|
||||
}
|
||||
const isGenerator = (o) =>
|
||||
o !== undefined && o !== null &&
|
||||
o.start !== undefined && o.end !== undefined &&
|
||||
o.step !== undefined && o.stepsgn !== undefined
|
||||
const genToArray = (gen) => {
|
||||
let a = []
|
||||
let cur = gen.start
|
||||
while (cur * gen.stepsgn + gen.step * gen.stepsgn <= (gen.end + gen.step) * gen.stepsgn) {
|
||||
a.push(cur)
|
||||
cur += gen.step
|
||||
}
|
||||
return a
|
||||
}
|
||||
const genHasNext = (o) => o.current * o.stepsgn + o.step * o.stepsgn <= (o.end + o.step) * o.stepsgn
|
||||
const genGetNext = (gen, mutated) => {
|
||||
if (mutated !== undefined) gen.current = tonum(mutated)
|
||||
gen.current += gen.step
|
||||
return genHasNext(gen) ? gen.current : undefined
|
||||
}
|
||||
|
||||
function BasicMemoMonad(m) { this.mType = "value"; this.mVal = m }
|
||||
function BasicListMonad(m) { this.mType = "list"; this.mVal = [m] }
|
||||
function BasicFunSeq(f) { this.mType = "funseq"; this.mVal = f }
|
||||
const isMonad = (o) => o !== undefined && o !== null && o.mType !== undefined
|
||||
|
||||
function arrayToString(a) {
|
||||
let acc = ""
|
||||
for (let k = 0; k < a.length; k++) {
|
||||
if (k > 0) acc += ","
|
||||
acc += (Array.isArray(a[k])) ? arrayToString(a[k]) : a[k]
|
||||
}
|
||||
return "{" + acc + "}"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State container
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const _initialConsts = () => ({
|
||||
NIL: [],
|
||||
PI: Math.PI,
|
||||
TAU: Math.PI * 2,
|
||||
EULER: Math.E,
|
||||
UNDEFINED: undefined,
|
||||
TRUE: true,
|
||||
FALSE: false,
|
||||
// ID is identity-function: emitted as JS arrow when needed
|
||||
ID: (x) => x,
|
||||
})
|
||||
|
||||
const state = {
|
||||
vars: _initialConsts(),
|
||||
indexBase: 0,
|
||||
dataConsts: [],
|
||||
dataCursor: 0,
|
||||
gotoLabels: {}, // labelName -> [lnum, stmt]
|
||||
lineList: [], // sorted ascending list of existing source lines (for GOTO snap)
|
||||
rnd: Math.random(),
|
||||
forVar: {}, // varname -> generator|array (the iterable we still owe to FOR/FOREACH)
|
||||
forLnums: {}, // varname -> [lnum, stmt of the FOR/FOREACH header]
|
||||
forStack: [],
|
||||
trace: false,
|
||||
debug: false,
|
||||
}
|
||||
|
||||
function __reset() {
|
||||
state.vars = _initialConsts()
|
||||
state.indexBase = 0
|
||||
state.dataConsts = []
|
||||
state.dataCursor = 0
|
||||
state.gotoLabels = {}
|
||||
state.lineList = []
|
||||
state.rnd = Math.random()
|
||||
state.forVar = {}
|
||||
state.forLnums = {}
|
||||
state.forStack = []
|
||||
}
|
||||
|
||||
function __data(values) { state.dataConsts = values.slice() }
|
||||
function __labels(map) { state.gotoLabels = Object.assign({}, map) }
|
||||
function __setLines(arr) { state.lineList = arr.slice() }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compiler-emitted operator helpers (need behaviour not directly expressible
|
||||
// in raw JS without losing semantics)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function __add(lh, rh) {
|
||||
return (!isNaN(lh) && !isNaN(rh)) ? (tonum(lh) + tonum(rh)) : (lh + rh)
|
||||
}
|
||||
function __div(lh, rh) { if (rh == 0) throw Error("Division by zero"); return lh / rh }
|
||||
function __intdiv(lh, rh) { if (rh == 0) throw Error("Division by zero"); return (lh / rh) | 0 }
|
||||
function __mod(lh, rh) { if (rh == 0) throw Error("Division by zero"); return lh % rh }
|
||||
function __pow(lh, rh) {
|
||||
let r = Math.pow(lh, rh)
|
||||
if (isNaN(r)) throw Error("Illegal function call")
|
||||
if (!isFinite(r)) throw Error("Division by zero")
|
||||
return r
|
||||
}
|
||||
|
||||
function __test(v) { return !!v } // matches builtin TEST: string "false" is truthy
|
||||
|
||||
function __dim(dims) {
|
||||
let revdims = dims.slice().reverse()
|
||||
let inner = new Array(revdims[0]).fill(0)
|
||||
for (let k = 1; k < revdims.length; k++) {
|
||||
const sz = revdims[k]
|
||||
const prev = inner
|
||||
inner = new Array(sz).fill(0).map(_ => JSON.parse(JSON.stringify(prev)))
|
||||
}
|
||||
return inner
|
||||
}
|
||||
|
||||
function __subscriptError(idx, dim) {
|
||||
return Error("Subscript out of range (index " + idx + ", dim " + dim + ")")
|
||||
}
|
||||
function __arrGet(arr, idx) {
|
||||
let v = arr
|
||||
for (let i = 0; i < idx.length; i++) {
|
||||
if (v === undefined || v === null) throw __subscriptError(idx[i], i)
|
||||
v = v[idx[i] - state.indexBase]
|
||||
}
|
||||
return v
|
||||
}
|
||||
function __arrSet(arr, idx, value) {
|
||||
let v = arr
|
||||
for (let i = 0; i < idx.length - 1; i++) {
|
||||
if (v === undefined || v === null) throw __subscriptError(idx[i], i)
|
||||
v = v[idx[i] - state.indexBase]
|
||||
}
|
||||
if (v === undefined || v === null) throw __subscriptError(idx[idx.length - 1], idx.length - 1)
|
||||
v[idx[idx.length - 1] - state.indexBase] = value
|
||||
}
|
||||
|
||||
// FOR / FOREACH setup. Lowered as:
|
||||
// __forSetup(varname, iterable, bodyLnum, bodyStmt)
|
||||
// where iterable is a ForGen (FOR…TO…STEP) OR an Array (FOREACH IN…), and
|
||||
// (bodyLnum, bodyStmt) is the PC of the statement immediately following the
|
||||
// FOR header — i.e. where NEXT should jump back to. The compiler supplies
|
||||
// this directly so the state machine doesn't rely on fall-through.
|
||||
function __forSetup(varname, iterable, bodyLnum, bodyStmt) {
|
||||
const v = varname.toUpperCase()
|
||||
if (isGenerator(iterable)) {
|
||||
state.vars[v] = iterable.start
|
||||
state.forVar[v] = iterable
|
||||
} else if (Array.isArray(iterable)) {
|
||||
state.vars[v] = iterable[0]
|
||||
state.forVar[v] = iterable.slice(1) // remainder
|
||||
} else {
|
||||
throw Error("FOR: not a generator or array")
|
||||
}
|
||||
state.forLnums[v] = [bodyLnum, bodyStmt]
|
||||
state.forStack.push(v)
|
||||
}
|
||||
|
||||
// NEXT [varname]. Without varname, pops the most recent.
|
||||
// Returns [lnum, stmt] to jump back to (just-after the FOR header) if more
|
||||
// iterations remain, or undefined if the loop is exhausted (caller falls
|
||||
// through).
|
||||
function __forNext(varname) {
|
||||
let v
|
||||
if (varname === undefined || varname === null) {
|
||||
v = state.forStack.pop()
|
||||
} else {
|
||||
v = varname.toUpperCase()
|
||||
// remove this varname from the stack
|
||||
const idx = state.forStack.lastIndexOf(v)
|
||||
if (idx >= 0) state.forStack.splice(idx, 1)
|
||||
}
|
||||
if (v === undefined) throw Error("NEXT without FOR")
|
||||
|
||||
const it = state.forVar[v]
|
||||
let nextVal
|
||||
if (isGenerator(it)) {
|
||||
nextVal = genGetNext(it, state.vars[v])
|
||||
} else {
|
||||
nextVal = it.shift()
|
||||
}
|
||||
|
||||
if (nextVal !== undefined) {
|
||||
state.vars[v] = nextVal
|
||||
state.forStack.push(v)
|
||||
return state.forLnums[v] // already the PC of the loop body
|
||||
} else {
|
||||
if (isGenerator(it)) state.vars[v] = it.current
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function __readData() {
|
||||
const r = state.dataConsts[state.dataCursor++]
|
||||
if (r === undefined) throw Error("Out of DATA")
|
||||
return r
|
||||
}
|
||||
|
||||
// Resolve a GOTO/GOSUB target — accepts numeric line, label string, or
|
||||
// already-evaluated expression. For numeric targets that don't match an
|
||||
// existing source line, snap upward to the next one (matches the
|
||||
// interpreter's behaviour, where the main loop simply increments lnum until
|
||||
// it finds a populated cmdbuf entry).
|
||||
function __resolveTarget(t) {
|
||||
if (typeof t === "string" && state.gotoLabels[t] !== undefined) {
|
||||
return state.gotoLabels[t]
|
||||
}
|
||||
let target
|
||||
if (typeof t === "number") target = t
|
||||
else if (isNumable(t)) target = tonum(t)
|
||||
else throw Error("Invalid jump target: " + t)
|
||||
|
||||
const lines = state.lineList
|
||||
if (lines.length === 0) return [target, 0]
|
||||
// linear scan is fine for the line counts BASIC programs reach
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i] >= target) return [lines[i], 0]
|
||||
}
|
||||
return [Infinity, 0]
|
||||
}
|
||||
|
||||
// Invoke a usrdefun (compiled to a JS function), or — when the parser
|
||||
// couldn't tell array-indexing apart from function-call (e.g. `A(5)` for an
|
||||
// unknown identifier) — index into an array. Used by MAP/FOLD/FILTER, monad
|
||||
// operators, and the compiler's default `function` lowering.
|
||||
function __runFn(fn, args) {
|
||||
if (typeof fn === "function") return fn.apply(null, args)
|
||||
if (Array.isArray(fn)) return __arrGet(fn, args)
|
||||
if (isMonad(fn) && fn.mType === "funseq") {
|
||||
let arg = args[0]
|
||||
for (let i = 0; i < fn.mVal.length; i++) arg = __runFn(fn.mVal[i], [arg])
|
||||
return arg
|
||||
}
|
||||
throw Error("Not a callable: " + fn)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Operator builtins (where JS doesn't already do the right thing)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function _AND(a, b) { if (typeof a !== "boolean" || typeof b !== "boolean") throw Error("Type mismatch"); return a && b }
|
||||
function _OR (a, b) { if (typeof a !== "boolean" || typeof b !== "boolean") throw Error("Type mismatch"); return a || b }
|
||||
function _NOT(a) { return !a }
|
||||
|
||||
function _CONS(lh, rh) { // !
|
||||
if (Array.isArray(rh)) return [lh].concat(rh)
|
||||
if (rh && rh.mType === "list") { rh.mVal = [lh].concat(rh.mVal); return rh }
|
||||
throw Error("Type mismatch")
|
||||
}
|
||||
function _PUSH(lh, rh) { // ~
|
||||
if (Array.isArray(lh)) return lh.concat([rh])
|
||||
if (lh && lh.mType === "list") { lh.mVal = [lh.mVal].concat([rh]); return lh }
|
||||
throw Error("Type mismatch")
|
||||
}
|
||||
function _CONCAT(lh, rh) { // #
|
||||
if (Array.isArray(lh) && Array.isArray(rh)) return lh.concat(rh)
|
||||
if (lh && rh && lh.mType === "list" && rh.mType === "list") return new BasicListMonad(lh.mVal.concat(rh.mVal))
|
||||
throw Error("Type mismatch")
|
||||
}
|
||||
|
||||
function _TO(from, to) { return new ForGen(from, to, 1) }
|
||||
function _STEP(gen, step) {
|
||||
if (!isGenerator(gen)) throw Error("Type mismatch (STEP)")
|
||||
return new ForGen(gen.start, gen.end, step)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I/O builtins
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// PRINT(values, seps) — values: array of resolved JS values; seps: array of
|
||||
// length values.length-1 with "," | ";" between each consecutive pair.
|
||||
// Trailing semicolon? The compiler signals "no newline" by passing a final
|
||||
// `null` element in `values` and "noNewline" flag — we use the convention
|
||||
// that the LAST entry of `values` being a marker `__noNewline` suppresses
|
||||
// the newline (matches basic.js trailing-null behaviour).
|
||||
const __PRINT_NONL = Symbol("PRINT_NONL")
|
||||
function PRINT(values, seps) {
|
||||
seps = seps || []
|
||||
if (values.length === 0) {
|
||||
println()
|
||||
return
|
||||
}
|
||||
let suppressNewline = false
|
||||
let realLen = values.length
|
||||
if (values[realLen - 1] === __PRINT_NONL) {
|
||||
suppressNewline = true
|
||||
realLen -= 1
|
||||
}
|
||||
for (let i = 0; i < realLen; i++) {
|
||||
if (i >= 1 && seps[i - 1] === ",") print("\t")
|
||||
const v = values[i]
|
||||
let s
|
||||
if (Array.isArray(v)) s = arrayToString(v)
|
||||
else if (v === undefined || v === "") s = ""
|
||||
else if (v.toString !== undefined) s = v.toString()
|
||||
else s = v
|
||||
print(s)
|
||||
}
|
||||
if (!suppressNewline) println()
|
||||
}
|
||||
function EMIT(values, seps) {
|
||||
seps = seps || []
|
||||
if (values.length === 0) { println(); return }
|
||||
let suppressNewline = false
|
||||
let realLen = values.length
|
||||
if (values[realLen - 1] === __PRINT_NONL) { suppressNewline = true; realLen -= 1 }
|
||||
for (let i = 0; i < realLen; i++) {
|
||||
if (i >= 1 && seps[i - 1] === ",") print("\t")
|
||||
const v = values[i]
|
||||
if (v === undefined) print("")
|
||||
else if (isNumable(v)) {
|
||||
const c = con.getyx()
|
||||
con.addch(tonum(v))
|
||||
con.move(c[0], c[1] + 1)
|
||||
} else if (v.toString !== undefined) print(v.toString())
|
||||
else print(v)
|
||||
}
|
||||
if (!suppressNewline) println()
|
||||
}
|
||||
|
||||
function INPUT(promptOrVarname) {
|
||||
print("? ")
|
||||
let r = sys.read().trim()
|
||||
if (!isNaN(r)) r = tonum(r)
|
||||
return r
|
||||
}
|
||||
function CIN() { return sys.read().trim() }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Numeric builtins
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const _num = (f) => (x) => { if (!isNumable(x)) throw Error("Type mismatch"); return f(tonum(x)) }
|
||||
const _num2 = (f) => (a, b) => {
|
||||
if (!isNumable(a) || !isNumable(b)) throw Error("Type mismatch")
|
||||
return f(tonum(a), tonum(b))
|
||||
}
|
||||
|
||||
const ABS = _num(Math.abs)
|
||||
const SGN = _num(x => x > 0 ? 1 : x < 0 ? -1 : 0)
|
||||
const INT = _num(Math.floor)
|
||||
const FLOOR = _num(Math.floor)
|
||||
const CEIL = _num(Math.ceil)
|
||||
const FIX = _num(x => x | 0)
|
||||
const ROUND = _num(Math.round)
|
||||
const SQR = _num(Math.sqrt)
|
||||
const CBR = _num(Math.cbrt)
|
||||
const SIN = _num(Math.sin)
|
||||
const COS = _num(Math.cos)
|
||||
const TAN = _num(Math.tan)
|
||||
const ASN = _num(Math.asin)
|
||||
const ACO = _num(Math.acos)
|
||||
const ATN = _num(Math.atan)
|
||||
const SINH = _num(Math.sinh)
|
||||
const COSH = _num(Math.cosh)
|
||||
const TANH = _num(Math.tanh)
|
||||
const EXP = _num(Math.exp)
|
||||
const LOG = _num(Math.log)
|
||||
const MIN = _num2((a,b) => a > b ? b : a)
|
||||
const MAX = _num2((a,b) => a < b ? b : a)
|
||||
|
||||
function RND(x) {
|
||||
// matches basic.js:1199 — only re-roll when arg !== 0
|
||||
if (!(x === 0)) state.rnd = Math.random()
|
||||
return state.rnd
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// String builtins
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SPC(n) { return " ".repeat(n) }
|
||||
function LEFT(s, n) { return String(s).substring(0, n) }
|
||||
function RIGHT(s, n) { return String(s).substring(String(s).length - n) }
|
||||
function MID(s, start, len) { return String(s).substring(start - state.indexBase, start - state.indexBase + len) }
|
||||
function CHR(n) { return String.fromCharCode(n) }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// List builtins
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function LEN(x) { if (x === undefined || x.length === undefined) throw Error("Type mismatch"); return x.length }
|
||||
function HEAD(x) { if (!x || x.length < 1) throw Error("Type mismatch"); return x[0] }
|
||||
function TAIL(x) { if (!x || x.length < 1) throw Error("Type mismatch"); return x.slice(1) }
|
||||
function INIT(x) { if (!x || x.length < 1) throw Error("Type mismatch"); return x.slice(0, x.length - 1) }
|
||||
function LAST(x) { if (!x || x.length < 1) throw Error("Type mismatch"); return x[x.length - 1] }
|
||||
|
||||
function MAP(fn, functor) {
|
||||
if (typeof fn !== "function" && !(isMonad(fn) && fn.mType === "funseq")) throw Error("MAP: not a function")
|
||||
if (isGenerator(functor)) functor = genToArray(functor)
|
||||
if (!Array.isArray(functor)) throw Error("MAP: not iterable")
|
||||
return functor.map(it => __runFn(fn, [it]))
|
||||
}
|
||||
function FOLD(fn, init, functor) {
|
||||
if (typeof fn !== "function" && !(isMonad(fn) && fn.mType === "funseq")) throw Error("FOLD: not a function")
|
||||
if (isGenerator(functor)) functor = genToArray(functor)
|
||||
if (!Array.isArray(functor)) throw Error("FOLD: not iterable")
|
||||
let akku = init
|
||||
for (let i = 0; i < functor.length; i++) akku = __runFn(fn, [akku, functor[i]])
|
||||
return akku
|
||||
}
|
||||
function FILTER(fn, functor) {
|
||||
if (typeof fn !== "function" && !(isMonad(fn) && fn.mType === "funseq")) throw Error("FILTER: not a function")
|
||||
if (isGenerator(functor)) functor = genToArray(functor)
|
||||
if (!Array.isArray(functor)) throw Error("FILTER: not iterable")
|
||||
return functor.filter(it => __runFn(fn, [it]))
|
||||
}
|
||||
|
||||
// Array literal constructor — emitted by the compiler for `[a,b,c]` syntax
|
||||
function ARRAY() { return Array.prototype.slice.call(arguments) }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graphics / system
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function CLS() { con.clear() }
|
||||
function CLPX() { graphics.clearPixels(255) }
|
||||
function PLOT(x, y, c) { graphics.plotPixel(x, y, c) }
|
||||
function GOTOYX(y, x) { con.move(y + (1 - state.indexBase), x + (1 - state.indexBase)) }
|
||||
function TEXTFORE(c) { print(String.fromCharCode(27, 91) + "38;5;" + (c | 0) + "m") }
|
||||
function TEXTBACK(c) { print(String.fromCharCode(27, 91) + "48;5;" + (c | 0) + "m") }
|
||||
function POKE(addr, v) { sys.poke(addr, v) }
|
||||
function PEEK(addr) { return sys.peek(addr) }
|
||||
function GETKEYSDOWN() {
|
||||
const keys = []
|
||||
sys.poke(-40, 255)
|
||||
for (let k = -41; k >= -48; k--) keys.push(sys.peek(k))
|
||||
return keys
|
||||
}
|
||||
|
||||
function CPUT(devnum, msg) { com.sendMessage(devnum, msg); return com.getStatusCode(devnum) }
|
||||
function CGET(devnum, ptr) {
|
||||
const msg = com.pullMessage(devnum)
|
||||
const len = msg.length | 0
|
||||
for (let i = 0; i < len; i++) sys.poke(ptr + i, msg.charCodeAt(i))
|
||||
return len
|
||||
}
|
||||
function CSTA(devnum) { return com.getStatusCode(devnum) }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type / debug
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TYPEOF(v) {
|
||||
if (v === undefined) return "null"
|
||||
if (typeof v === "boolean") return "bool"
|
||||
if (Array.isArray(v)) return "array"
|
||||
if (isGenerator(v)) return "generator"
|
||||
if (isMonad(v)) return v.mType + "-monad"
|
||||
if (typeof v === "function") return "usrdefun"
|
||||
if (isNumable(v)) return "num"
|
||||
if (typeof v === "string") return "string"
|
||||
return typeof v
|
||||
}
|
||||
|
||||
function OPTIONBASE(n) {
|
||||
if (n != 0 && n != 1) throw Error("Syntax error: OPTIONBASE")
|
||||
state.indexBase = n | 0
|
||||
}
|
||||
function OPTIONDEBUG(n) { state.debug = (n | 0) === 1 }
|
||||
function OPTIONTRACE(n) { state.trace = (n | 0) === 1 }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Monad / functional ops (best-effort port)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MRET(v) { return new BasicMemoMonad(v) }
|
||||
function MLIST(v) { return new BasicListMonad(v) }
|
||||
function MJOIN(m) { if (!isMonad(m)) throw Error("Type mismatch"); return m.mVal }
|
||||
|
||||
function _BIND(ma, fn) { // >>=
|
||||
if (!isMonad(ma)) throw Error(">>=: left is not a monad")
|
||||
if (typeof fn !== "function") throw Error(">>=: right is not a function")
|
||||
const mb = __runFn(fn, [ma.mVal])
|
||||
if (!isMonad(mb)) throw Error(">>=: function did not return a monad")
|
||||
return mb
|
||||
}
|
||||
function _SEQ(ma, mb) { // >>~
|
||||
if (!isMonad(ma) || !isMonad(mb)) throw Error("Type mismatch")
|
||||
return mb
|
||||
}
|
||||
function _COMPOSE(fa, fb) { // .
|
||||
const ma = (typeof fa === "function") ? [fa] : fa.mVal
|
||||
const mb = (typeof fb === "function") ? [fb] : fb.mVal
|
||||
return new BasicFunSeq(mb.concat(ma))
|
||||
}
|
||||
function _APPLY(fn, value) { // $
|
||||
return __runFn(fn, [value])
|
||||
}
|
||||
function _PIPE(value, fn) { // &
|
||||
return _APPLY(fn, value)
|
||||
}
|
||||
function _CURRY(fn, value) { // ~<
|
||||
if (typeof fn !== "function") throw Error("~<: left is not a function")
|
||||
return function() {
|
||||
const rest = Array.prototype.slice.call(arguments)
|
||||
return fn.apply(null, [value].concat(rest))
|
||||
}
|
||||
}
|
||||
function _SEQAPP(fns, functor) { // <*>
|
||||
if (!Array.isArray(fns)) throw Error("<*>: first arg must be an array of functions")
|
||||
if (isGenerator(functor)) functor = genToArray(functor)
|
||||
if (!Array.isArray(functor)) throw Error("<*>: not iterable")
|
||||
let ret = []
|
||||
for (let i = 0; i < fns.length; i++) ret = ret.concat(functor.map(it => __runFn(fns[i], [it])))
|
||||
return ret
|
||||
}
|
||||
function _SEQCURRYMAP(fns, functor) { // <~>
|
||||
if (typeof fns === "function") fns = [fns]
|
||||
if (!Array.isArray(fns)) throw Error("<~>: first arg must be a function or array of functions")
|
||||
if (isGenerator(functor)) functor = genToArray(functor)
|
||||
if (!Array.isArray(functor)) throw Error("<~>: not iterable")
|
||||
let ret = []
|
||||
for (let i = 0; i < fns.length; i++) ret = ret.concat(functor.map(it => _CURRY(fns[i], it)))
|
||||
return ret
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
exports = {
|
||||
// state & introspection
|
||||
__state: state, __reset, __data, __labels, __setLines,
|
||||
__PRINT_NONL,
|
||||
|
||||
// operator helpers
|
||||
__add, __div, __intdiv, __mod, __pow, __test,
|
||||
__dim, __arrGet, __arrSet,
|
||||
__forSetup, __forNext, __readData, __resolveTarget,
|
||||
__runFn,
|
||||
|
||||
// type ctors
|
||||
__ForGen: ForGen, __isGenerator: isGenerator, __genToArray: genToArray,
|
||||
__isMonad: isMonad,
|
||||
|
||||
// operators
|
||||
AND: _AND, OR: _OR, NOT: _NOT,
|
||||
UNARYLOGICNOT: _NOT,
|
||||
UNARYBNOT: (a) => ~a,
|
||||
UNARYMINUS: (a) => -a,
|
||||
UNARYPLUS: (a) => +a,
|
||||
BAND: (a,b)=>a&b, BOR: (a,b)=>a|b, BXOR: (a,b)=>a^b,
|
||||
"<<": (a,b)=>a<<b, ">>": (a,b)=>a>>>b,
|
||||
"!": _CONS, "~": _PUSH, "#": _CONCAT,
|
||||
TO: _TO, STEP: _STEP,
|
||||
|
||||
// i/o
|
||||
PRINT, EMIT, INPUT, CIN,
|
||||
|
||||
// numeric
|
||||
ABS, SGN, INT, FLOOR, CEIL, FIX, ROUND, SQR, CBR,
|
||||
SIN, COS, TAN, ASN, ACO, ATN, SINH, COSH, TANH,
|
||||
EXP, LOG, MIN, MAX, RND,
|
||||
|
||||
// strings
|
||||
SPC, LEFT, RIGHT, MID, CHR,
|
||||
|
||||
// lists
|
||||
LEN, HEAD, TAIL, INIT, LAST, MAP, FOLD, FILTER,
|
||||
ARRAY,
|
||||
|
||||
// graphics / system
|
||||
CLS, CLPX, PLOT, GOTOYX, TEXTFORE, TEXTBACK,
|
||||
POKE, PEEK, GETKEYSDOWN, CPUT, CGET, CSTA,
|
||||
|
||||
// type / option
|
||||
TYPEOF, OPTIONBASE, OPTIONDEBUG, OPTIONTRACE,
|
||||
|
||||
// monads / functional
|
||||
MRET, MLIST, MJOIN,
|
||||
">>=": _BIND, ">>~": _SEQ,
|
||||
".": _COMPOSE, "$": _APPLY, "&": _PIPE, "~<": _CURRY,
|
||||
"<*>": _SEQAPP, "<$>": MAP, "<~>": _SEQCURRYMAP,
|
||||
|
||||
// misc
|
||||
DO: function() { return arguments[arguments.length - 1] },
|
||||
CLEAR: function() { state.vars = _initialConsts() },
|
||||
END: function() { /* compiler emits pc=[Infinity,0] */ },
|
||||
LABEL: function() { /* harvested at compile time */ },
|
||||
DATA: function() { /* harvested at compile time */ },
|
||||
// DIM as an expression (e.g. `WS = DIM(H, V)`): allocate and return a
|
||||
// freshly zero-filled N-D array. The statement form `DIM A(H, V)` is
|
||||
// compiled inline and never reaches this entry.
|
||||
DIM: function() { return __dim(Array.prototype.slice.call(arguments)) },
|
||||
}
|
||||
331
assets/disk0/tvdos/include/typesetter.mjs
Normal file
331
assets/disk0/tvdos/include/typesetter.mjs
Normal file
@@ -0,0 +1,331 @@
|
||||
/*
|
||||
* typesetter.mjs - Rich-text typesetter for TVDOS console output.
|
||||
*
|
||||
* Wraps and aligns text using a tiny markup language. Originally lifted
|
||||
* out of taut_helpmsg.js so other tools (motd, help popups, ...) can
|
||||
* share the same formatter.
|
||||
*
|
||||
* Markup
|
||||
* ------
|
||||
* <b>...</b> emphasised foreground colour
|
||||
* <c>...</c> centre-align this source line
|
||||
* <r>...</r> right-align this source line
|
||||
* <l>...</l> left-align this source line
|
||||
* <o>...</o> virtual typesetting box. Left anchor is the cursor
|
||||
* column at the open tag, right anchor is the wrap edge.
|
||||
* default alignment is fully justified (override per-call via opts).
|
||||
*
|
||||
* Entities
|
||||
* --------
|
||||
* µtone; "Microtone" wordmark
|
||||
* &bul; &ddot; &mdot; bullet glyphs
|
||||
* &updn; &udlr; arrow glyphs
|
||||
* &keyoffsym; ¬ecutsym;
|
||||
* &demisharp; ♯ &sesquisharp; &doublesharp; &triplesharp; &quadsharp;
|
||||
* &demiflat; ♭ &sesquiflat; &doubleflat; &tripleflat; &quadflat;
|
||||
* &accuptick; &accdntick; &accupup; &accdndn;
|
||||
* non-breaking space
|
||||
* ­ soft hyphen (currently dropped)
|
||||
* < > literal angle brackets
|
||||
*
|
||||
* Usage
|
||||
* -----
|
||||
* let ts = require("typesetter")
|
||||
* let lines = ts.typeset(text, width) // array of width-wide strings
|
||||
* let lines = ts.typeset(text) // width = rest of current row
|
||||
* let lines = ts.typeset(text, width, { defaultAlign: 'l' })
|
||||
*/
|
||||
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Palette / ANSI helpers
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
const COL_TEXT = 239 // popup body default (== colWHITE)
|
||||
const COL_EMPH = 230 // <b>...</b> highlight (== colVoiceHdr)
|
||||
const COL_BRAND = 211 // first half of "Microtone"
|
||||
const COL_BRAND_DIM = 239 // second half of "Microtone"
|
||||
|
||||
const fgEsc = (n) => `\x1B[38;5;${n}m`
|
||||
const ESC_DEFAULT = fgEsc(COL_TEXT)
|
||||
const ESC_EMPH = fgEsc(COL_EMPH)
|
||||
const MICROTONE = `${fgEsc(COL_BRAND)}Micro${fgEsc(COL_BRAND_DIM)}tone${ESC_DEFAULT}`
|
||||
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Entity expansion
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Replace &xxx; entities with their final printable representations.
|
||||
function expandEntities(s) {
|
||||
return s
|
||||
.replaceAll('µtone;', MICROTONE)
|
||||
.replaceAll('&bul;', '\u00F9')
|
||||
.replaceAll('&ddot;', '\u008419u')
|
||||
.replaceAll('&mdot;', '\u00FA')
|
||||
.replaceAll('&updn;', '\u008418u')
|
||||
.replaceAll('&udlr;', '\u008428u\u008429u')
|
||||
.replaceAll('&keyoffsym;', '\u00A0\u00B1\u00B1\u00A1')
|
||||
.replaceAll('¬ecutsym;', '\u00A4\u00A4\u00A4\u00A4')
|
||||
.replaceAll(' ', '\u007F')
|
||||
.replaceAll('­', '')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('&demisharp;', '\u0080\u0081')
|
||||
.replaceAll('♯', '\u0082\u0083')
|
||||
.replaceAll('&sesquisharp;', '\u0084132u\u0085')
|
||||
.replaceAll('&doublesharp;', '\u0086\u0087')
|
||||
.replaceAll('&triplesharp;', '\u0088\u0089')
|
||||
.replaceAll('&quadsharp;', '\u008A\u008B')
|
||||
.replaceAll('&demiflat;', '\u008C\u008D')
|
||||
.replaceAll('♭', '\u008E\u008F')
|
||||
.replaceAll('&sesquiflat;', '\u0090\u0091')
|
||||
.replaceAll('&doubleflat;', '\u0092\u0093')
|
||||
.replaceAll('&tripleflat;', '\u0094\u0095')
|
||||
.replaceAll('&quadflat;', '\u0096\u0097')
|
||||
.replaceAll('&accuptick;', '\u009A')
|
||||
.replaceAll('&accdntick;', '\u009B')
|
||||
.replaceAll('&accupup;', '\u009C')
|
||||
.replaceAll('&accdndn;', '\u009D')
|
||||
}
|
||||
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Tokeniser
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Tokenise a (post-entity-expansion) line. Returns an array of:
|
||||
// {type:'word', text:String, w:int} - non-breakable run of visible chars (may carry ANSI escapes)
|
||||
// {type:'sp'} - a single soft space (eligible for break/expansion)
|
||||
// {type:'anchor', open:Boolean} - <o>/</o> markers (zero width)
|
||||
//
|
||||
// Width accounting:
|
||||
// - ANSI escapes (`\x1B[...m`) : 0 visible chars
|
||||
// - TSVM unicode escapes (`\u0084..u`) : 1 visible char
|
||||
// - non-breaking space (\u007F) : 1 visible char (consumed as part of a word)
|
||||
// - soft hyphen (\u00AD) : dropped (not implemented as a break point)
|
||||
// - everything else : 1 visible char
|
||||
function tokenise(line) {
|
||||
const tokens = []
|
||||
let buf = ''
|
||||
let bufW = 0
|
||||
let i = 0
|
||||
|
||||
const flushWord = () => {
|
||||
if (buf.length > 0) {
|
||||
tokens.push({type: 'word', text: buf, w: bufW})
|
||||
buf = ''
|
||||
bufW = 0
|
||||
}
|
||||
}
|
||||
|
||||
while (i < line.length) {
|
||||
// inline tags (case-sensitive for <b>, case-insensitive for <o>)
|
||||
if (line.slice(i, i + 3) === '<b>') { buf += ESC_EMPH; i += 3; continue }
|
||||
if (line.slice(i, i + 4) === '</b>') { buf += ESC_DEFAULT; i += 4; continue }
|
||||
const head3 = line.slice(i, i + 3).toLowerCase()
|
||||
const head4 = line.slice(i, i + 4).toLowerCase()
|
||||
if (head3 === '<o>') { flushWord(); tokens.push({type: 'anchor', open: true}); i += 3; continue }
|
||||
if (head4 === '</o>') { flushWord(); tokens.push({type: 'anchor', open: false}); i += 4; continue }
|
||||
|
||||
const c = line[i]
|
||||
const cc = line.charCodeAt(i)
|
||||
|
||||
if (cc === 0x1B) {
|
||||
// pre-existing ANSI escape - copy verbatim, zero visible width
|
||||
const m = line.indexOf('m', i)
|
||||
const end = (m < 0) ? line.length : m + 1
|
||||
buf += line.slice(i, end)
|
||||
i = end
|
||||
}
|
||||
else if (cc === 0x84) {
|
||||
// TSVM \u0084<digits>u escape - copy verbatim, one visible char
|
||||
const u = line.indexOf('u', i)
|
||||
const end = (u < 0) ? line.length : u + 1
|
||||
buf += line.slice(i, end)
|
||||
bufW += 1
|
||||
i = end
|
||||
}
|
||||
else if (c === ' ') {
|
||||
flushWord()
|
||||
tokens.push({type: 'sp'})
|
||||
i += 1
|
||||
}
|
||||
else if (cc === 0x00AD) {
|
||||
// soft hyphen: drop (no break-point handling for now)
|
||||
i += 1
|
||||
}
|
||||
else {
|
||||
buf += c
|
||||
bufW += 1
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
flushWord()
|
||||
return tokens
|
||||
}
|
||||
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Line builder
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Build wrapped lines from a token stream then format each one according to alignment.
|
||||
// Returns an array of strings, each exactly `width` visible chars wide (padded with
|
||||
// trailing spaces) so the caller can blit them without further math.
|
||||
function wrapAndAlign(tokens, width, alignment) {
|
||||
const lines = [] // each: {tokens, indent, contentW}
|
||||
let curTokens = []
|
||||
let curW = 0
|
||||
let curIndent = 0
|
||||
let nextIndent = 0 // indent the *next* flushed line should use
|
||||
|
||||
const flushLine = () => {
|
||||
// strip trailing soft spaces
|
||||
while (curTokens.length > 0 && curTokens[curTokens.length - 1].type === 'sp') {
|
||||
curTokens.pop()
|
||||
curW -= 1
|
||||
}
|
||||
lines.push({tokens: curTokens, indent: curIndent, contentW: curW})
|
||||
curTokens = []
|
||||
curW = 0
|
||||
curIndent = nextIndent
|
||||
}
|
||||
|
||||
for (const tok of tokens) {
|
||||
if (tok.type === 'anchor') {
|
||||
// anchor opens at the current visible column (accounting for indent)
|
||||
if (tok.open) nextIndent = curIndent + curW
|
||||
else nextIndent = 0
|
||||
continue
|
||||
}
|
||||
|
||||
if (tok.type === 'sp') {
|
||||
// ignore leading soft spaces on a fresh line
|
||||
if (curW === 0) continue
|
||||
// hard wrap if the line is already at the right edge
|
||||
if (curIndent + curW + 1 > width) { flushLine(); continue }
|
||||
curTokens.push(tok)
|
||||
curW += 1
|
||||
continue
|
||||
}
|
||||
|
||||
// word
|
||||
const tw = tok.w
|
||||
if (curIndent + curW + tw > width) {
|
||||
flushLine()
|
||||
// word too wide for the wrapped line: emit it on its own row (possibly clipped by terminal)
|
||||
if (curIndent + tw > width) {
|
||||
curTokens.push(tok)
|
||||
curW += tw
|
||||
flushLine()
|
||||
continue
|
||||
}
|
||||
}
|
||||
curTokens.push(tok)
|
||||
curW += tw
|
||||
}
|
||||
|
||||
if (curTokens.length > 0 || lines.length === 0) flushLine()
|
||||
|
||||
return lines.map((line, i) => formatLine(line, width, alignment, i === lines.length - 1))
|
||||
}
|
||||
|
||||
function formatLine(line, totalWidth, alignment, isLast) {
|
||||
if (line.tokens.length === 0) return ' '.repeat(totalWidth)
|
||||
|
||||
const indent = ' '.repeat(line.indent)
|
||||
const remaining = totalWidth - line.indent - line.contentW
|
||||
const pad = (n) => (n > 0) ? ' '.repeat(n) : ''
|
||||
const flatText = () => line.tokens.map(t => (t.type === 'sp') ? ' ' : t.text).join('')
|
||||
|
||||
if (alignment === 'c') {
|
||||
const left = remaining >> 1
|
||||
return indent + pad(left) + flatText() + pad(remaining - left)
|
||||
}
|
||||
if (alignment === 'r') return indent + pad(remaining) + flatText()
|
||||
if (alignment === 'l') return indent + flatText() + pad(remaining)
|
||||
|
||||
// justified: only expand spaces when there's slack and we're not on the
|
||||
// last (or single) wrapped line
|
||||
if (isLast || remaining <= 0) return indent + flatText() + pad(remaining)
|
||||
|
||||
const spaceCount = line.tokens.reduce((n, t) => n + (t.type === 'sp' ? 1 : 0), 0)
|
||||
if (spaceCount === 0) return indent + flatText() + pad(remaining)
|
||||
|
||||
const baseExtra = (remaining / spaceCount) | 0
|
||||
let leftover = remaining - baseExtra * spaceCount
|
||||
|
||||
let out = indent
|
||||
for (const tok of line.tokens) {
|
||||
if (tok.type === 'sp') {
|
||||
const extra = baseExtra + (leftover > 0 ? 1 : 0)
|
||||
if (leftover > 0) leftover -= 1
|
||||
out += ' '.repeat(1 + extra)
|
||||
} else {
|
||||
out += tok.text
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Process a single source line: peel a leading <c>/<r>/<l> alignment tag (if present),
|
||||
// strip its matching close tag, then tokenise + wrap.
|
||||
function typesetSourceLine(line, width, defaultAlign) {
|
||||
if (line.length === 0) return [' '.repeat(width)]
|
||||
|
||||
let alignment = defaultAlign || 'j' // justified default
|
||||
const startMatch = line.match(/^<([crl])>/i)
|
||||
if (startMatch) {
|
||||
alignment = startMatch[1].toLowerCase()
|
||||
line = line.slice(startMatch[0].length)
|
||||
const closeRe = new RegExp(`</${alignment}>$`, 'i')
|
||||
line = line.replace(closeRe, '')
|
||||
}
|
||||
|
||||
const tokens = tokenise(line)
|
||||
return wrapAndAlign(tokens, width, alignment)
|
||||
}
|
||||
|
||||
function typesetText(text, width, defaultAlign) {
|
||||
text = expandEntities(text)
|
||||
const out = []
|
||||
for (const srcLine of text.split('\n')) {
|
||||
for (const outLine of typesetSourceLine(srcLine, width, defaultAlign)) out.push(outLine)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Convenience entry: `typeset(text)` defaults the wrap width to "rest of current row".
|
||||
// `opts` may be `{ defaultAlign: 'l' | 'c' | 'r' | 'j' }`.
|
||||
function typeset(text, customWidth, opts) {
|
||||
let typesetWidth = customWidth
|
||||
if (typesetWidth === undefined) {
|
||||
const SCRW = con.getmaxyx()[1]
|
||||
const currentPosX = con.getyx()[1] // 1-indexed
|
||||
typesetWidth = SCRW - currentPosX + 1
|
||||
}
|
||||
let defaultAlign = (opts && opts.defaultAlign) || 'j'
|
||||
return typesetText(text, typesetWidth, defaultAlign)
|
||||
}
|
||||
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Module exports
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
exports = {
|
||||
typeset,
|
||||
typesetText,
|
||||
typesetSourceLine,
|
||||
tokenise,
|
||||
expandEntities,
|
||||
fgEsc,
|
||||
COL_TEXT,
|
||||
COL_EMPH,
|
||||
COL_BRAND,
|
||||
COL_BRAND_DIM,
|
||||
ESC_DEFAULT,
|
||||
ESC_EMPH,
|
||||
MICROTONE,
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* WinTex — TUI window management and renderer
|
||||
* @author CuriousTorvald
|
||||
*/
|
||||
|
||||
class WindowObject {
|
||||
|
||||
constructor(x, y, w, h, inputProcessor, drawContents, title, drawFrame) {
|
||||
@@ -60,12 +65,12 @@ class WindowObject {
|
||||
}
|
||||
if (this.titleRight !== undefined) {
|
||||
let tt = ''+this.titleRight
|
||||
con.move(this.y, this.x + this.width - tt.length - 2)
|
||||
con.move(this.y + this.height - 1, this.x + this.width - tt.length - 2)
|
||||
print(`\x84${charset[4]}u`)
|
||||
if (this.titleBackRight !== undefined) print(`\x1B[48;5;${this.titleBackRight}m`)
|
||||
print(`\x1B[38;5;${colourText}m${tt}`)
|
||||
if (this.titleBackRight !== undefined) print(`\x1B[48;5;${oldBack}m`)
|
||||
print(`\x1B[38;5;${colour}m\x84${charset[1]}u`)
|
||||
print(`\x1B[38;5;${colour}m\x84${charset[3]}u`)
|
||||
}
|
||||
|
||||
|
||||
@@ -91,8 +96,6 @@ class WindowObject {
|
||||
* @return [new cursor pos, new scroll pos]
|
||||
*/
|
||||
function scrollVert(dy, listSize, listHeight, currentCursorPos, currentScrollPos, scrollPeek) {
|
||||
let peek = 1
|
||||
|
||||
// clamp dy
|
||||
if (currentCursorPos + dy > listSize - 1)
|
||||
dy = (listSize - 1) - currentCursorPos
|
||||
@@ -103,13 +106,13 @@ function scrollVert(dy, listSize, listHeight, currentCursorPos, currentScrollPos
|
||||
|
||||
// update vertical scroll stats
|
||||
if (dy != 0) {
|
||||
let visible = listHeight - 1 - peek
|
||||
let visible = listHeight - 1 - scrollPeek
|
||||
|
||||
if (nextRow - currentScrollPos > visible) {
|
||||
currentScrollPos = nextRow - visible
|
||||
}
|
||||
else if (nextRow - currentScrollPos < 0 + peek) {
|
||||
currentScrollPos = nextRow - peek // nextRow is less than zero
|
||||
else if (nextRow - currentScrollPos < 0 + scrollPeek) {
|
||||
currentScrollPos = nextRow - scrollPeek // nextRow is less than zero
|
||||
}
|
||||
|
||||
// NOTE: future-proofing here -- scroll clamping is moved outside of go-up/go-down
|
||||
@@ -140,8 +143,6 @@ function scrollVert(dy, listSize, listHeight, currentCursorPos, currentScrollPos
|
||||
* @return [new cursor pos, new scroll pos]
|
||||
*/
|
||||
function scrollHorz(dx, stringSize, stringViewSize, currentCursorPos, currentScrollPos, scrollPeek) {
|
||||
let peek = 1
|
||||
|
||||
// clamp dx
|
||||
if (currentCursorPos + dx > stringSize - 1)
|
||||
dx = (stringSize - 1) - currentCursorPos
|
||||
@@ -152,13 +153,13 @@ function scrollHorz(dx, stringSize, stringViewSize, currentCursorPos, currentScr
|
||||
|
||||
// update vertical scroll stats
|
||||
if (dx != 0) {
|
||||
let visible = stringViewSize - 1 - peek
|
||||
let visible = stringViewSize - 1 - scrollPeek
|
||||
|
||||
if (nextCol - currentScrollPos > visible) {
|
||||
currentScrollPos = nextCol - visible
|
||||
}
|
||||
else if (nextCol - currentScrollPos < 0 + peek) {
|
||||
currentScrollPos = nextCol - peek // nextCol is less than zero
|
||||
else if (nextCol - currentScrollPos < 0 + scrollPeek) {
|
||||
currentScrollPos = nextCol - scrollPeek // nextCol is less than zero
|
||||
}
|
||||
|
||||
// NOTE: future-proofing here -- scroll clamping is moved outside of go-up/go-down
|
||||
@@ -179,4 +180,746 @@ function scrollHorz(dx, stringSize, stringViewSize, currentCursorPos, currentScr
|
||||
return [currentCursorPos, currentScrollPos]
|
||||
}
|
||||
|
||||
exports = { WindowObject, scrollVert, scrollHorz }
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modal dialog with optional body text, input fields, a scrollable selection
|
||||
// list, and OK/Cancel-style buttons. Layout from top to bottom:
|
||||
// title bar, message, fields, list, buttons.
|
||||
//
|
||||
// opts = {
|
||||
// title: string,
|
||||
// message: string | string[]?, -- optional body text drawn above fields/list
|
||||
// drawFrame: function(wo)?, -- override for the window-frame painter;
|
||||
// same contract as WindowObject's
|
||||
// `drawFrame` slot. Useful when the caller
|
||||
// wants its own border / title styling.
|
||||
//
|
||||
// fields: [{label, initial?, width, maxLength?}, ...] -- omit / [] for no input
|
||||
// field. Label does NOT get auto-colon.
|
||||
// `maxLength` caps insertable chars
|
||||
// (default: width * 4).
|
||||
//
|
||||
// list: { -- optional vertical selection list
|
||||
// items: [{label, ...}, ...], -- arbitrary user objects; only `label`
|
||||
// is read by the default renderer.
|
||||
// height: number, -- visible row count.
|
||||
// width: number?, -- inner width override (default: popup w-4).
|
||||
// cursor: number?, -- initial cursor row (default: first selectable).
|
||||
// selectable: function(item, i)->bool?, -- default: every item selectable. Non-
|
||||
// selectable rows are skipped by arrow keys.
|
||||
// When NO row is selectable, arrow / PgUp
|
||||
// / PgDn scroll the view instead.
|
||||
// renderItem: function(ctx)?, -- per-row painter; ctx exposes
|
||||
// { y, x, w, item, idx, isCursor, focused,
|
||||
// listBg, selBg, fg, hlFg, dimFg }.
|
||||
// Default prints `item.label`.
|
||||
// onActivate: function(item, i, key)?, -- fired on Enter ('\n') / Space (' ')
|
||||
// / left-click ('click'); return an
|
||||
// action string to close the dialog,
|
||||
// or null to stay open.
|
||||
// showScrollbar: bool?, -- default: auto (true when overflowing).
|
||||
// bg: number?, -- list background colour (default 242).
|
||||
// },
|
||||
//
|
||||
// buttons: [{label, action, default?}, ...] -- defaults to [OK, Cancel] (+ Delete
|
||||
// if `allowDelete:true`)
|
||||
// allowDelete: bool, -- inserts a Delete button (fsh compat)
|
||||
// colours: {fg?, bg?, fieldBg?, dimFg?, hlFg?, focusBg?, listBg?, listSelBg?}
|
||||
// -- per-call overrides
|
||||
// disableKeyRepeat: bool, -- when true, key won't repeat when held down
|
||||
// onKey: function(ks, shiftDown, ctx)?, -- escape hatch for callers that need
|
||||
// extra key bindings. Runs BEFORE the
|
||||
// built-in handlers. Return true to
|
||||
// consume the key. `ctx` exposes
|
||||
// { render, close(result),
|
||||
// getListCursor, setListCursor }.
|
||||
// }
|
||||
//
|
||||
// Returns {action, values, listCursor, listItem}: `action` is the chosen button's
|
||||
// `action` or the value returned from `onActivate` (default "ok"/"cancel"/"delete"),
|
||||
// or "cancel" on Esc; `values` is the array of field strings in field order;
|
||||
// `listCursor` is the final cursor index (-1 if there is no list); `listItem` is
|
||||
// the item at that index.
|
||||
//
|
||||
// Behaviour:
|
||||
// - Tab / Shift+Tab and arrow Down / Up cycle focus across fields, list, and buttons.
|
||||
// Inside the list, arrow Up / Down move the cursor between selectable rows;
|
||||
// PgUp/PgDn move a page; Home/End jump to the first/last selectable row.
|
||||
// - Left / Right inside a field move the caret; on the list or a button they cycle focus.
|
||||
// - Home / End jump to start / end of the focused field.
|
||||
// - Enter on a field jumps to the next field, then to the first button. Enter
|
||||
// or Space on a button activates it. Enter or Space on a list row invokes
|
||||
// `onActivate(item, idx, key)`; if that returns a string, the dialog closes
|
||||
// with that action.
|
||||
// - Insert at caret. Backspace deletes left of caret; Forward-Del deletes right.
|
||||
// - Blinking caret (`con.curs_set(1)`) is positioned on the focused field and
|
||||
// hidden when the list or a button has focus.
|
||||
// - Mouse: left-click on a button activates it; click on a field puts focus
|
||||
// on that field and positions the caret under the click; click on a list row
|
||||
// moves the cursor (and fires `onActivate` if defined); mouse-wheel inside the
|
||||
// list scrolls it. Mouse hover on a button moves focus to it (the same focus
|
||||
// the keyboard uses).
|
||||
const _dialogScreen = con.getmaxyx()
|
||||
const _dialogPixDim = graphics.getPixelDimension()
|
||||
const _CELL_PW = (_dialogPixDim[0] / _dialogScreen[1]) | 0
|
||||
const _CELL_PH = (_dialogPixDim[1] / _dialogScreen[0]) | 0
|
||||
function _pxToCell(px, py) { return [(py / _CELL_PH | 0) + 1, (px / _CELL_PW | 0) + 1] }
|
||||
|
||||
function showDialog(opts) {
|
||||
const fields = opts.fields || []
|
||||
const values = fields.map(f => (f.initial == null) ? '' : ('' + f.initial))
|
||||
const cursors = values.map(v => v.length)
|
||||
|
||||
let oldFG = con.get_color_fore()
|
||||
let oldBG = con.get_color_back()
|
||||
|
||||
let buttons
|
||||
if (opts.buttons) {
|
||||
buttons = opts.buttons
|
||||
} else {
|
||||
buttons = [{label: 'OK', action: 'ok', default: true}]
|
||||
if (opts.allowDelete) buttons.push({label: 'Delete', action: 'delete'})
|
||||
buttons.push({label: 'Cancel', action: 'cancel'})
|
||||
}
|
||||
|
||||
const title = opts.title || ''
|
||||
const message = opts.message
|
||||
const messageLines = !message ? []
|
||||
: Array.isArray(message) ? message
|
||||
: ('' + message).split('\n')
|
||||
|
||||
const c = opts.colours || {}
|
||||
const fg = (c.fg != null) ? c.fg : 254
|
||||
const bg = (c.bg != null) ? c.bg : 244
|
||||
const fieldBg = (c.fieldBg != null) ? c.fieldBg : 240
|
||||
const dimFg = (c.dimFg != null) ? c.dimFg : 249
|
||||
const hlFg = (c.hlFg != null) ? c.hlFg : 240
|
||||
const focusBg = (c.focusBg != null) ? c.focusBg : 253
|
||||
const listBg = (c.listBg != null) ? c.listBg : 243
|
||||
const listSelBg = (c.listSelBg != null) ? c.listSelBg : focusBg
|
||||
|
||||
// List state
|
||||
const list = opts.list || null
|
||||
const listItems = list ? (list.items || []) : []
|
||||
const listSelectable = list && list.selectable ? list.selectable : (() => true)
|
||||
const listHeight = list ? (list.height || Math.min(8, listItems.length)) : 0
|
||||
const hasList = !!list
|
||||
const listOnActivate = list ? list.onActivate : null
|
||||
const listBgColour = (list && list.bg != null) ? list.bg : listBg
|
||||
function firstSelectable(from, dir) {
|
||||
if (!hasList || listItems.length === 0) return -1
|
||||
let i = from
|
||||
for (let n = 0; n < listItems.length; n++) {
|
||||
if (i >= 0 && i < listItems.length && listSelectable(listItems[i], i)) return i
|
||||
i += dir
|
||||
if (i < 0) i = listItems.length - 1
|
||||
if (i >= listItems.length) i = 0
|
||||
}
|
||||
return -1
|
||||
}
|
||||
let listCursor = hasList
|
||||
? (list.cursor != null ? list.cursor : firstSelectable(0, +1))
|
||||
: -1
|
||||
let listScroll = 0
|
||||
|
||||
// Layout
|
||||
const buttonGap = 3
|
||||
const maxFieldW = fields.reduce((m, f) => Math.max(m, f.width), 16)
|
||||
const longestMsg = messageLines.reduce((m, l) => Math.max(m, l.length), 0)
|
||||
// When the caller pins `list.width`, trust it — string `.length` overcounts
|
||||
// visual width whenever items embed ANSI escapes or TVDOS \x84NNu sequences
|
||||
// (e.g. taut's help popup, whose rows are pre-typeset with fg-colour escapes).
|
||||
const longestItem = hasList && list.width == null
|
||||
? listItems.reduce((m, it) => Math.max(m, (it.label || '').length), 0)
|
||||
: 0
|
||||
const titleW = title.length + 4
|
||||
const btnRowW = buttons.reduce((s, b) => s + b.label.length + 4, 0) + buttonGap * Math.max(0, buttons.length - 1)
|
||||
const listMinW = hasList
|
||||
? (list.width != null ? list.width + 4 : longestItem + 6)
|
||||
: 0
|
||||
const w = Math.max(maxFieldW + 6, titleW + 4, longestMsg + 6, btnRowW + 4, listMinW, 24)
|
||||
|
||||
const msgRows = messageLines.length + (messageLines.length > 0 ? 1 : 0)
|
||||
const fieldsBlockH = fields.length * 4
|
||||
const listBlockH = hasList ? listHeight + 2 : 0 // top border + rows + bottom border
|
||||
|
||||
let bodyRows = msgRows
|
||||
if (fields.length > 0) bodyRows += fieldsBlockH + 1 // +1 spacing after fields
|
||||
if (hasList) bodyRows += listBlockH + 1 // +1 spacing after list
|
||||
if (bodyRows === 0) bodyRows = 1 // at least one row above buttons
|
||||
const buttonsRowOff = 1 + bodyRows
|
||||
const h = buttonsRowOff + 2
|
||||
|
||||
const screen = con.getmaxyx()
|
||||
const row = Math.max(2, Math.floor((screen[0] - h) / 2))
|
||||
const col = Math.max(2, Math.floor((screen[1] - w) / 2))
|
||||
|
||||
// Focus layout: 0..fields.length-1 = fields, [+1 = list if present], then buttons.
|
||||
const listFocusIdx = hasList ? fields.length : -1
|
||||
const buttonsFocusBase = fields.length + (hasList ? 1 : 0)
|
||||
const totalFocus = buttonsFocusBase + buttons.length
|
||||
|
||||
// Pick initial focus: explicit default > list > first field > first button.
|
||||
let focusIdx = -1
|
||||
for (let i = 0; i < buttons.length; i++) {
|
||||
if (buttons[i].default) { focusIdx = buttonsFocusBase + i; break }
|
||||
}
|
||||
if (focusIdx < 0) {
|
||||
if (fields.length > 0) focusIdx = 0
|
||||
else if (hasList) focusIdx = listFocusIdx
|
||||
else focusIdx = buttonsFocusBase
|
||||
}
|
||||
let done = null
|
||||
|
||||
function fieldScroll(cur, fw) { return cur < fw ? 0 : cur - fw + 1 }
|
||||
function fieldLabelRow(i) { return row + 1 + msgRows + i * 4 }
|
||||
function fieldBoxRow(i) { return fieldLabelRow(i) + 1 }
|
||||
function fieldContentRow(i) { return fieldLabelRow(i) + 2 }
|
||||
function fieldBoxCol() { return col + 2 }
|
||||
function fieldContentRegion(i) { return { x: fieldBoxCol() + 1, y: fieldContentRow(i), w: fields[i].width } }
|
||||
|
||||
function listBlockTopRow() {
|
||||
return row + 1 + msgRows + (fields.length > 0 ? fieldsBlockH + 1 : 0)
|
||||
}
|
||||
function listBlockCol() { return col + 2 }
|
||||
function listBlockWidth() { return w - 4 } // inner content width incl. borders
|
||||
function listContentRow(i) { return listBlockTopRow() + 1 + (i - listScroll) }
|
||||
function listContentCol() { return listBlockCol() + 1 }
|
||||
function listScrollbarNeeded() {
|
||||
if (!hasList) return false
|
||||
if (list.showScrollbar != null) return list.showScrollbar
|
||||
return listItems.length > listHeight
|
||||
}
|
||||
function listContentInnerW() {
|
||||
return listBlockWidth() - 2 - (listScrollbarNeeded() ? 1 : 0)
|
||||
}
|
||||
|
||||
function buttonRegions() {
|
||||
let bx = col + Math.floor((w - btnRowW) / 2)
|
||||
return buttons.map(b => {
|
||||
const r = { x: bx, y: row + buttonsRowOff, w: b.label.length + 4 }
|
||||
bx += b.label.length + 4 + buttonGap
|
||||
return r
|
||||
})
|
||||
}
|
||||
|
||||
function drawFrameBox() {
|
||||
con.color_pair(fg, bg)
|
||||
for (let r = row; r < row + h; r++) {
|
||||
con.move(r, col)
|
||||
print(' '.repeat(w))
|
||||
}
|
||||
const wo = new WindowObject(col, row, w, h, ()=>{}, ()=>{}, title, opts.drawFrame)
|
||||
wo.isHighlighted = true
|
||||
wo.titleBack = bg
|
||||
wo.drawFrame()
|
||||
con.color_pair(fg, bg)
|
||||
}
|
||||
|
||||
function drawMessage() {
|
||||
if (messageLines.length === 0) return
|
||||
con.color_pair(fg, bg)
|
||||
for (let i = 0; i < messageLines.length; i++) {
|
||||
con.move(row + 1 + i, col + 2)
|
||||
print(messageLines[i].padEnd(w - 4, ' '))
|
||||
}
|
||||
}
|
||||
|
||||
function drawField(i) {
|
||||
const f = fields[i]
|
||||
const fbCol = fieldBoxCol()
|
||||
const fbRow = fieldBoxRow(i)
|
||||
const fw = f.width
|
||||
const focused = (focusIdx === i)
|
||||
const frameFg = focused ? fg : dimFg
|
||||
|
||||
// Label
|
||||
con.color_pair(fg, bg)
|
||||
con.move(fieldLabelRow(i), fbCol)
|
||||
print(f.label)
|
||||
|
||||
// Top border (3px padding w/ TSVM chr rom)
|
||||
con.color_pair(fieldBg, bg)
|
||||
con.move(fbRow, fbCol)
|
||||
print('\u00EC' + '\u00A9'.repeat(fw) + '\u00ED')
|
||||
|
||||
// Left border (3px padding w/ TSVM chr rom)
|
||||
con.move(fbRow + 1, fbCol)
|
||||
print('\u00AB')
|
||||
|
||||
// the content
|
||||
con.color_pair(fg, fieldBg)
|
||||
const s = fieldScroll(cursors[i], fw)
|
||||
const vis = values[i].substring(s, s + fw)
|
||||
print(vis.padEnd(fw, ' '))
|
||||
|
||||
// Right border (3px padding w/ TSVM chr rom)
|
||||
con.color_pair(fieldBg, bg)
|
||||
con.move(fbRow + 1, fbCol + fw + 1)
|
||||
print('\u00AA')
|
||||
|
||||
// Bottom border (3px padding w/ TSVM chr rom)
|
||||
con.move(fbRow + 2, fbCol)
|
||||
print('\u00F4' + '\u00AC'.repeat(fw) + '\u00F5')
|
||||
con.color_pair(fg, bg)
|
||||
}
|
||||
|
||||
function drawList() {
|
||||
if (!hasList) return
|
||||
const lbCol = listBlockCol()
|
||||
const lbRow = listBlockTopRow()
|
||||
const lw = listBlockWidth()
|
||||
const innerW = listContentInnerW()
|
||||
const focused = (focusIdx === listFocusIdx)
|
||||
const frameFg = focused ? fg : dimFg
|
||||
const sbar = listScrollbarNeeded()
|
||||
|
||||
// Top border (drawField style)
|
||||
con.color_pair(listBgColour, bg)
|
||||
con.move(lbRow, lbCol)
|
||||
print('\u00EC' + '\u00A9'.repeat(lw - 2) + '\u00ED')
|
||||
|
||||
// Side borders + rows
|
||||
for (let r = 0; r < listHeight; r++) {
|
||||
con.color_pair(listBgColour, bg)
|
||||
con.move(lbRow + 1 + r, lbCol)
|
||||
print('\u00AB')
|
||||
con.move(lbRow + 1 + r, lbCol + lw - 1)
|
||||
print('\u00AA')
|
||||
|
||||
const idx = listScroll + r
|
||||
con.move(lbRow + 1 + r, lbCol + 1)
|
||||
if (idx >= listItems.length) {
|
||||
con.color_pair(fg, listBgColour)
|
||||
print(' '.repeat(innerW))
|
||||
continue
|
||||
}
|
||||
const it = listItems[idx]
|
||||
const isCursor = (idx === listCursor)
|
||||
const ctx = {
|
||||
y: lbRow + 1 + r,
|
||||
x: lbCol + 1,
|
||||
w: innerW,
|
||||
item: it,
|
||||
idx: idx,
|
||||
isCursor: isCursor,
|
||||
focused: focused,
|
||||
listBg: listBgColour,
|
||||
selBg: listSelBg,
|
||||
fg: fg,
|
||||
hlFg: hlFg,
|
||||
dimFg: dimFg,
|
||||
}
|
||||
if (list.renderItem) {
|
||||
list.renderItem(ctx)
|
||||
} else {
|
||||
const useFg = (isCursor && focused) ? hlFg : fg
|
||||
const useBg = (isCursor && focused) ? listSelBg : listBgColour
|
||||
con.color_pair(useFg, useBg)
|
||||
const label = (it.label || '').substring(0, innerW - 1)
|
||||
print(' ' + label.padEnd(innerW - 1, ' '))
|
||||
}
|
||||
|
||||
// Scrollbar column
|
||||
if (sbar) {
|
||||
con.color_pair(dimFg, listBgColour)
|
||||
con.move(lbRow + 1 + r, lbCol + lw - 2)
|
||||
const maxScroll = Math.max(1, listItems.length - listHeight)
|
||||
const indPos = (maxScroll <= 0) ? 0 : ((listScroll * (listHeight - 1) / maxScroll) | 0)
|
||||
let trough = (r === 0) ? 0xBA : (r === listHeight - 1) ? 0xBC : 0xBB
|
||||
con.addch(r === indPos ? (trough + 3) : trough)
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom border
|
||||
con.color_pair(listBgColour, bg)
|
||||
con.move(lbRow + 1 + listHeight, lbCol)
|
||||
print('\u00F4' + '\u00AC'.repeat(lw - 2) + '\u00F5')
|
||||
con.color_pair(fg, bg)
|
||||
}
|
||||
|
||||
function drawButton(i, regions) {
|
||||
const b = buttons[i]
|
||||
const bIdx = buttonsFocusBase + i
|
||||
const focused = (focusIdx === bIdx)
|
||||
const r = regions[i]
|
||||
const useFg = focused ? hlFg : fg
|
||||
const useBg = focused ? focusBg : bg
|
||||
con.color_pair(useFg, useBg)
|
||||
con.move(r.y, r.x-1)
|
||||
if (focused) {
|
||||
con.color_pair(useBg, bg)
|
||||
print('\u00DE')
|
||||
con.color_pair(useFg, useBg)
|
||||
print('[ ' + b.label + ' ]')
|
||||
con.color_pair(useBg, bg)
|
||||
print('\u00DD')
|
||||
}
|
||||
else
|
||||
print(' [ ' + b.label + ' ] ')
|
||||
con.color_pair(fg, bg)
|
||||
}
|
||||
|
||||
function positionCaret() {
|
||||
if (focusIdx < fields.length) {
|
||||
const fw = fields[focusIdx].width
|
||||
const s = fieldScroll(cursors[focusIdx], fw)
|
||||
con.move(fieldContentRow(focusIdx), fieldBoxCol() + 1 + (cursors[focusIdx] - s))
|
||||
con.curs_set(1)
|
||||
} else {
|
||||
con.curs_set(0)
|
||||
}
|
||||
}
|
||||
|
||||
function ensureListCursorVisible() {
|
||||
if (!hasList) return
|
||||
if (listCursor < 0) return
|
||||
if (listCursor < listScroll) listScroll = listCursor
|
||||
else if (listCursor >= listScroll + listHeight) listScroll = listCursor - listHeight + 1
|
||||
const maxScroll = Math.max(0, listItems.length - listHeight)
|
||||
if (listScroll > maxScroll) listScroll = maxScroll
|
||||
if (listScroll < 0) listScroll = 0
|
||||
}
|
||||
|
||||
function scrollListBy(dir) {
|
||||
const maxScroll = Math.max(0, listItems.length - listHeight)
|
||||
let s = listScroll + dir
|
||||
if (s < 0) s = 0
|
||||
if (s > maxScroll) s = maxScroll
|
||||
listScroll = s
|
||||
}
|
||||
|
||||
function moveListCursor(dir) {
|
||||
if (!hasList || listItems.length === 0) return
|
||||
// Scroll the view when nothing in the list is selectable (e.g. a help text body).
|
||||
if (listCursor < 0) { scrollListBy(dir); return }
|
||||
let next = listCursor
|
||||
for (let n = 0; n < listItems.length; n++) {
|
||||
next += dir
|
||||
if (next < 0 || next >= listItems.length) return
|
||||
if (listSelectable(listItems[next], next)) {
|
||||
listCursor = next
|
||||
ensureListCursorVisible()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pageListCursor(dir) {
|
||||
if (!hasList || listItems.length === 0) return
|
||||
if (listCursor < 0) { scrollListBy(dir * listHeight); return }
|
||||
let target = listCursor + dir * listHeight
|
||||
if (target < 0) target = 0
|
||||
if (target >= listItems.length) target = listItems.length - 1
|
||||
// Snap to nearest selectable
|
||||
let probe = target
|
||||
const step = dir < 0 ? -1 : 1
|
||||
while (probe >= 0 && probe < listItems.length && !listSelectable(listItems[probe], probe)) probe += step
|
||||
if (probe < 0 || probe >= listItems.length) probe = firstSelectable(target, -step)
|
||||
if (probe >= 0) { listCursor = probe; ensureListCursorVisible() }
|
||||
}
|
||||
|
||||
function render() {
|
||||
drawFrameBox()
|
||||
drawMessage()
|
||||
for (let i = 0; i < fields.length; i++) drawField(i)
|
||||
drawList()
|
||||
const regs = buttonRegions()
|
||||
for (let i = 0; i < buttons.length; i++) drawButton(i, regs)
|
||||
positionCaret()
|
||||
}
|
||||
|
||||
function moveFocus(dir) {
|
||||
focusIdx = (focusIdx + dir + totalFocus) % totalFocus
|
||||
render()
|
||||
}
|
||||
|
||||
function activateButton(i) {
|
||||
done = {
|
||||
action: buttons[i].action,
|
||||
values: values.slice(),
|
||||
listCursor: listCursor,
|
||||
listItem: (hasList && listCursor >= 0) ? listItems[listCursor] : null,
|
||||
}
|
||||
}
|
||||
|
||||
function activateListItem(idx, key) {
|
||||
if (!hasList || !listOnActivate) return false
|
||||
if (idx < 0 || idx >= listItems.length) return false
|
||||
if (!listSelectable(listItems[idx], idx)) return false
|
||||
const result = listOnActivate(listItems[idx], idx, key)
|
||||
if (result == null) {
|
||||
// Callback consumed the event but kept the dialog open (e.g. radio
|
||||
// toggle); reflect any state changes it made.
|
||||
render()
|
||||
return true
|
||||
}
|
||||
done = {
|
||||
action: result,
|
||||
values: values.slice(),
|
||||
listCursor: idx,
|
||||
listItem: listItems[idx],
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function hitTestMouse(ev) {
|
||||
const cell = _pxToCell(ev[1], ev[2])
|
||||
const cy = cell[0], cx = cell[1]
|
||||
const btnRegs = buttonRegions()
|
||||
for (let i = 0; i < btnRegs.length; i++) {
|
||||
const r = btnRegs[i]
|
||||
if (cy === r.y && cx >= r.x && cx < r.x + r.w) return { kind: 'button', idx: i }
|
||||
}
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
const r = fieldContentRegion(i)
|
||||
if (cy === r.y && cx >= r.x && cx < r.x + r.w) return { kind: 'field', idx: i, cx: cx, region: r }
|
||||
}
|
||||
if (hasList) {
|
||||
const lbRow = listBlockTopRow()
|
||||
const lbCol = listBlockCol()
|
||||
const innerW = listContentInnerW()
|
||||
if (cy > lbRow && cy <= lbRow + listHeight && cx >= lbCol + 1 && cx < lbCol + 1 + innerW) {
|
||||
const r = cy - (lbRow + 1)
|
||||
const idx = listScroll + r
|
||||
if (idx >= 0 && idx < listItems.length) return { kind: 'list', idx: idx }
|
||||
}
|
||||
if (cy > lbRow && cy <= lbRow + listHeight && cx >= lbCol && cx < lbCol + listBlockWidth()) {
|
||||
return { kind: 'listblank' }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const externalCtx = {
|
||||
render: () => render(),
|
||||
close: (result) => {
|
||||
done = Object.assign({
|
||||
action: 'cancel',
|
||||
values: values.slice(),
|
||||
listCursor: listCursor,
|
||||
listItem: (hasList && listCursor >= 0) ? listItems[listCursor] : null,
|
||||
}, result || {})
|
||||
},
|
||||
getListCursor: () => listCursor,
|
||||
setListCursor: (n) => {
|
||||
if (!hasList) return
|
||||
if (n < 0 || n >= listItems.length) return
|
||||
listCursor = n
|
||||
ensureListCursorVisible()
|
||||
},
|
||||
}
|
||||
|
||||
ensureListCursorVisible()
|
||||
render()
|
||||
|
||||
let eventJustReceived = true
|
||||
while (done === null) {
|
||||
input.withEvent(ev => {
|
||||
if (eventJustReceived && (ev[0] === 'key_down' || ev[0] === 'mouse_down')) {
|
||||
eventJustReceived = false; return
|
||||
}
|
||||
|
||||
if (ev[0] === 'mouse_move') {
|
||||
const hit = hitTestMouse(ev)
|
||||
if (hit && hit.kind === 'button') {
|
||||
const newFocus = buttonsFocusBase + hit.idx
|
||||
if (newFocus !== focusIdx) {
|
||||
focusIdx = newFocus
|
||||
render()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (ev[0] === 'mouse_down') {
|
||||
if (ev[3] !== 1) return
|
||||
const hit = hitTestMouse(ev)
|
||||
if (!hit) return
|
||||
if (hit.kind === 'button') {
|
||||
focusIdx = buttonsFocusBase + hit.idx
|
||||
render()
|
||||
activateButton(hit.idx)
|
||||
return
|
||||
}
|
||||
if (hit.kind === 'field') {
|
||||
focusIdx = hit.idx
|
||||
const fw = fields[hit.idx].width
|
||||
const s = fieldScroll(cursors[hit.idx], fw)
|
||||
const newCur = s + (hit.cx - hit.region.x)
|
||||
cursors[hit.idx] = Math.min(values[hit.idx].length, Math.max(0, newCur))
|
||||
render()
|
||||
return
|
||||
}
|
||||
if (hit.kind === 'list') {
|
||||
focusIdx = listFocusIdx
|
||||
if (listSelectable(listItems[hit.idx], hit.idx)) {
|
||||
listCursor = hit.idx
|
||||
ensureListCursorVisible()
|
||||
render()
|
||||
if (activateListItem(hit.idx, 'click')) return
|
||||
} else {
|
||||
render()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (hit.kind === 'listblank') {
|
||||
focusIdx = listFocusIdx
|
||||
render()
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
if (ev[0] === 'mouse_wheel' && hasList) {
|
||||
const hit = hitTestMouse(ev)
|
||||
if (!hit || (hit.kind !== 'list' && hit.kind !== 'listblank')) return
|
||||
const dy = (ev[3] | 0) * 3
|
||||
const maxScroll = Math.max(0, listItems.length - listHeight)
|
||||
let next = listScroll + dy
|
||||
if (next < 0) next = 0
|
||||
if (next > maxScroll) next = maxScroll
|
||||
if (next !== listScroll) { listScroll = next; render() }
|
||||
return
|
||||
}
|
||||
if (ev[0] !== 'key_down') return
|
||||
if (opts.disableKeyRepeat && 1 !== ev[2]) return
|
||||
const ks = ev[1]
|
||||
const shiftDown = (ev.includes(59) || ev.includes(60))
|
||||
|
||||
if (opts.onKey && opts.onKey(ks, shiftDown, externalCtx)) return
|
||||
|
||||
if (ks === '<ESC>') {
|
||||
done = {
|
||||
action: 'cancel',
|
||||
values: values.slice(),
|
||||
listCursor: listCursor,
|
||||
listItem: (hasList && listCursor >= 0) ? listItems[listCursor] : null,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (ks === '\t' || ks === '<TAB>') { moveFocus(shiftDown ? -1 : 1); return }
|
||||
|
||||
// Vertical movement: arrows operate within the list when it has focus.
|
||||
if (ks === '<UP>') {
|
||||
if (focusIdx === listFocusIdx) { moveListCursor(-1); render() }
|
||||
else moveFocus(-1)
|
||||
return
|
||||
}
|
||||
if (ks === '<DOWN>') {
|
||||
if (focusIdx === listFocusIdx) { moveListCursor(+1); render() }
|
||||
else moveFocus(+1)
|
||||
return
|
||||
}
|
||||
if (ks === '<PAGE_UP>') {
|
||||
if (focusIdx === listFocusIdx) { pageListCursor(-1); render() }
|
||||
return
|
||||
}
|
||||
if (ks === '<PAGE_DOWN>') {
|
||||
if (focusIdx === listFocusIdx) { pageListCursor(+1); render() }
|
||||
return
|
||||
}
|
||||
|
||||
if (ks === '<LEFT>') {
|
||||
if (focusIdx < fields.length) {
|
||||
if (cursors[focusIdx] > 0) { cursors[focusIdx] -= 1; render() }
|
||||
} else moveFocus(-1)
|
||||
return
|
||||
}
|
||||
if (ks === '<RIGHT>') {
|
||||
if (focusIdx < fields.length) {
|
||||
if (cursors[focusIdx] < values[focusIdx].length) { cursors[focusIdx] += 1; render() }
|
||||
} else moveFocus(+1)
|
||||
return
|
||||
}
|
||||
if (ks === '<HOME>') {
|
||||
if (focusIdx < fields.length) { cursors[focusIdx] = 0; render() }
|
||||
else if (focusIdx === listFocusIdx) {
|
||||
const t = firstSelectable(0, +1)
|
||||
if (t >= 0) { listCursor = t; ensureListCursorVisible(); render() }
|
||||
else { listScroll = 0; render() }
|
||||
}
|
||||
return
|
||||
}
|
||||
if (ks === '<END>') {
|
||||
if (focusIdx < fields.length) { cursors[focusIdx] = values[focusIdx].length; render() }
|
||||
else if (focusIdx === listFocusIdx) {
|
||||
const t = firstSelectable(listItems.length - 1, -1)
|
||||
if (t >= 0) { listCursor = t; ensureListCursorVisible(); render() }
|
||||
else { listScroll = Math.max(0, listItems.length - listHeight); render() }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (focusIdx < fields.length) {
|
||||
if (ks === '\n') {
|
||||
if (focusIdx < fields.length - 1) focusIdx = focusIdx + 1
|
||||
else if (hasList) focusIdx = listFocusIdx
|
||||
else focusIdx = buttonsFocusBase
|
||||
render()
|
||||
return
|
||||
}
|
||||
if (ks === '\x08') {
|
||||
const cur = cursors[focusIdx]
|
||||
if (cur > 0) {
|
||||
const v = values[focusIdx]
|
||||
values[focusIdx] = v.substring(0, cur - 1) + v.substring(cur)
|
||||
cursors[focusIdx] = cur - 1
|
||||
render()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (ks === '<DEL>') {
|
||||
const cur = cursors[focusIdx]
|
||||
const v = values[focusIdx]
|
||||
if (cur < v.length) {
|
||||
values[focusIdx] = v.substring(0, cur) + v.substring(cur + 1)
|
||||
render()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (typeof ks === 'string' && ks.length === 1) {
|
||||
const code = ks.charCodeAt(0)
|
||||
const cap = fields[focusIdx].maxLength != null
|
||||
? fields[focusIdx].maxLength
|
||||
: fields[focusIdx].width * 4
|
||||
if (code >= 32 && code < 256 && values[focusIdx].length < cap) {
|
||||
const v = values[focusIdx]
|
||||
const cur = cursors[focusIdx]
|
||||
values[focusIdx] = v.substring(0, cur) + ks + v.substring(cur)
|
||||
cursors[focusIdx] = cur + 1
|
||||
render()
|
||||
}
|
||||
return
|
||||
}
|
||||
} else if (focusIdx === listFocusIdx) {
|
||||
if (ks === '\n' || ks === ' ') {
|
||||
if (listCursor >= 0 && activateListItem(listCursor, ks)) return
|
||||
}
|
||||
} else {
|
||||
if (ks === '\n' || ks === ' ') { activateButton(focusIdx - buttonsFocusBase); return }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Modal-dialog convention: wait for the user to release whatever key closed
|
||||
// the dialog before handing control back. TVDOS's input strobo
|
||||
// (TVDOS.SYS:input.withEvent) keeps re-firing `key_down` for a held key
|
||||
// once its ~250 ms initial-press delay elapses; without this drain a brief
|
||||
// hold on Enter inside a popup would surface as a fresh Enter to whatever
|
||||
// the popup was covering, e.g. activating the file under zfm's More menu.
|
||||
// A mouse close (or any path with no key held) leaves the head key at 0
|
||||
// and skips the wait.
|
||||
sys.poke(-40, 255)
|
||||
const heldHead = sys.peek(-41)
|
||||
if (heldHead !== 0) {
|
||||
while (true) {
|
||||
input.withEvent(() => {})
|
||||
if (sys.peek(-41) !== heldHead) break
|
||||
}
|
||||
}
|
||||
|
||||
con.curs_set(0)
|
||||
con.color_pair(oldFG, oldBG)
|
||||
return done
|
||||
}
|
||||
|
||||
exports = { WindowObject, scrollVert, scrollHorz, showDialog }
|
||||
|
||||
6
assets/disk0/tvdos/tuidev/Makefile
Normal file
6
assets/disk0/tvdos/tuidev/Makefile
Normal file
@@ -0,0 +1,6 @@
|
||||
CC = gcc
|
||||
CFLAGS = -std=c99 -O3 -Wall -Wextra -Ofast -D_GNU_SOURCE
|
||||
|
||||
font_rom_builder:
|
||||
rm -f font_rom_builder
|
||||
$(CC) $(CFLAGS) font_rom_builder.c -o font_rom_builder
|
||||
202
assets/disk0/tvdos/tuidev/font_rom_builder.c
Normal file
202
assets/disk0/tvdos/tuidev/font_rom_builder.c
Normal file
@@ -0,0 +1,202 @@
|
||||
/*
|
||||
* font_rom_builder.c
|
||||
* Build TSVM 7x14 font ROM from human-readable images (.png, .tga)
|
||||
*
|
||||
* Input: Image with no gaps between characters (7x14 pixels per glyph)
|
||||
* Output: TSVM-compatible font ROM file(s) padded to 1920 bytes
|
||||
*
|
||||
* Usage:
|
||||
* gcc -O2 -std=c99 -Wall font_rom_builder.c -o font_rom_builder
|
||||
* ./font_rom_builder <input.png|tga> <output_prefix>
|
||||
*
|
||||
* For 128-char images: outputs <output_prefix>_high.chr
|
||||
* For 256-char images: outputs <output_prefix>_low.chr and <output_prefix>_high.chr
|
||||
*
|
||||
* Image layout:
|
||||
* - 128 chars: 16 columns × 8 rows = 112×112 pixels
|
||||
* - 256 chars: 16 columns × 16 rows = 112×224 pixels
|
||||
* or 32 columns × 8 rows = 224×112 pixels
|
||||
*
|
||||
* ROM format:
|
||||
* - Each glyph: 14 bytes (one byte per row)
|
||||
* - Bit 6 = leftmost pixel, Bit 0 = rightmost pixel
|
||||
* - Each ROM padded to 1920 bytes
|
||||
*/
|
||||
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#define GLYPH_W 7
|
||||
#define GLYPH_H 14
|
||||
#define GLYPH_BYTES 14
|
||||
#define ROM_PADDED_SIZE 1920
|
||||
|
||||
static void die(const char *msg) {
|
||||
fprintf(stderr, "Error: %s\n", msg);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
static void write_rom(const char *filename, const uint8_t *glyphs, int glyph_count) {
|
||||
FILE *out = fopen(filename, "wb");
|
||||
if (!out) {
|
||||
fprintf(stderr, "Failed to open output file: %s\n", filename);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Write glyph data
|
||||
size_t data_size = glyph_count * GLYPH_BYTES;
|
||||
fwrite(glyphs, 1, data_size, out);
|
||||
|
||||
// Pad to 1920 bytes
|
||||
if (data_size < ROM_PADDED_SIZE) {
|
||||
size_t padding = ROM_PADDED_SIZE - data_size;
|
||||
uint8_t *pad = calloc(padding, 1);
|
||||
fwrite(pad, 1, padding, out);
|
||||
free(pad);
|
||||
fprintf(stderr, " Wrote %zu bytes + %zu bytes padding = %d bytes total\n",
|
||||
data_size, padding, ROM_PADDED_SIZE);
|
||||
} else {
|
||||
fprintf(stderr, " Wrote %zu bytes (no padding needed)\n", data_size);
|
||||
}
|
||||
|
||||
fclose(out);
|
||||
fprintf(stderr, " Output: %s\n", filename);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc < 3) {
|
||||
fprintf(stderr, "Usage: %s <input.png|tga> <output_prefix>\n", argv[0]);
|
||||
fprintf(stderr, "\n");
|
||||
fprintf(stderr, "Converts human-readable font images to TSVM font ROM format.\n");
|
||||
fprintf(stderr, "\n");
|
||||
fprintf(stderr, "Input requirements:\n");
|
||||
fprintf(stderr, " - Image with no gaps between characters\n");
|
||||
fprintf(stderr, " - Each character is 7x14 pixels\n");
|
||||
fprintf(stderr, " - 128 chars: typically 112x112 (16 cols × 8 rows)\n");
|
||||
fprintf(stderr, " - 256 chars: typically 112x224 (16 cols × 16 rows)\n");
|
||||
fprintf(stderr, "\n");
|
||||
fprintf(stderr, "Output:\n");
|
||||
fprintf(stderr, " - 128 chars: <prefix>_high.chr (high ROM only)\n");
|
||||
fprintf(stderr, " - 256 chars: <prefix>_low.chr + <prefix>_high.chr\n");
|
||||
fprintf(stderr, " - Each ROM padded to 1920 bytes\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
const char *input_path = argv[1];
|
||||
const char *output_prefix = argv[2];
|
||||
|
||||
// Get image dimensions using ImageMagick identify
|
||||
char cmd[1024];
|
||||
snprintf(cmd, sizeof(cmd), "identify -format '%%w %%h' \"%s\" 2>/dev/null", input_path);
|
||||
|
||||
FILE *pipe = popen(cmd, "r");
|
||||
if (!pipe) die("Failed to run 'identify' command (ImageMagick required)");
|
||||
|
||||
int img_w = 0, img_h = 0;
|
||||
if (fscanf(pipe, "%d %d", &img_w, &img_h) != 2) {
|
||||
pclose(pipe);
|
||||
die("Failed to read image dimensions (is ImageMagick installed?)");
|
||||
}
|
||||
pclose(pipe);
|
||||
|
||||
fprintf(stderr, "Input: %s (%dx%d)\n", input_path, img_w, img_h);
|
||||
|
||||
// Calculate grid dimensions
|
||||
int cols = img_w / GLYPH_W;
|
||||
int rows = img_h / GLYPH_H;
|
||||
int total_chars = cols * rows;
|
||||
|
||||
if (img_w % GLYPH_W != 0 || img_h % GLYPH_H != 0) {
|
||||
fprintf(stderr, "Warning: Image dimensions not evenly divisible by %dx%d\n",
|
||||
GLYPH_W, GLYPH_H);
|
||||
}
|
||||
|
||||
fprintf(stderr, "Grid: %d columns × %d rows = %d characters\n", cols, rows, total_chars);
|
||||
|
||||
// Validate character count
|
||||
if (total_chars != 128 && total_chars != 256) {
|
||||
fprintf(stderr, "Error: Expected 128 or 256 characters, got %d\n", total_chars);
|
||||
fprintf(stderr, " For 128 chars: use 112x112 (16×8) or similar layout\n");
|
||||
fprintf(stderr, " For 256 chars: use 112x224 (16×16) or 224x112 (32×8)\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Read image as grayscale using ImageMagick convert
|
||||
// IMPORTANT: Flatten alpha onto black background first, so transparent pixels become black
|
||||
size_t img_size = img_w * img_h;
|
||||
uint8_t *img_data = malloc(img_size);
|
||||
if (!img_data) die("Memory allocation failed");
|
||||
|
||||
snprintf(cmd, sizeof(cmd),
|
||||
"convert \"%s\" -background black -alpha remove -colorspace Gray -depth 8 gray:- 2>/dev/null",
|
||||
input_path);
|
||||
|
||||
pipe = popen(cmd, "r");
|
||||
if (!pipe) die("Failed to run 'convert' command (ImageMagick required)");
|
||||
|
||||
if (fread(img_data, 1, img_size, pipe) != img_size) {
|
||||
pclose(pipe);
|
||||
die("Failed to read image data from ImageMagick");
|
||||
}
|
||||
pclose(pipe);
|
||||
|
||||
fprintf(stderr, "Read %zu bytes of grayscale data\n", img_size);
|
||||
|
||||
// Extract glyphs
|
||||
uint8_t *glyphs = calloc(total_chars, GLYPH_BYTES);
|
||||
if (!glyphs) die("Memory allocation failed");
|
||||
|
||||
for (int gy = 0; gy < rows; gy++) {
|
||||
for (int gx = 0; gx < cols; gx++) {
|
||||
int glyph_idx = gy * cols + gx;
|
||||
uint8_t *glyph = &glyphs[glyph_idx * GLYPH_BYTES];
|
||||
|
||||
for (int row = 0; row < GLYPH_H; row++) {
|
||||
uint8_t byte = 0;
|
||||
for (int col = 0; col < GLYPH_W; col++) {
|
||||
int px = gx * GLYPH_W + col;
|
||||
int py = gy * GLYPH_H + row;
|
||||
uint8_t pixel = img_data[py * img_w + px];
|
||||
|
||||
// Threshold: >= 128 is foreground (white/lit)
|
||||
int is_set = (pixel >= 128) ? 1 : 0;
|
||||
|
||||
// Pack: bit 6 = leftmost, bit 0 = rightmost
|
||||
if (is_set) {
|
||||
byte |= (1u << (6 - col));
|
||||
}
|
||||
}
|
||||
glyph[row] = byte;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
free(img_data);
|
||||
fprintf(stderr, "Extracted %d glyphs\n", total_chars);
|
||||
|
||||
// Write output ROM file(s)
|
||||
char out_path[1024];
|
||||
|
||||
if (total_chars == 128) {
|
||||
// High ROM only (chars 128-255)
|
||||
snprintf(out_path, sizeof(out_path), "%s.chr", output_prefix);
|
||||
fprintf(stderr, "\nWriting high ROM (128 chars):\n");
|
||||
write_rom(out_path, glyphs, 128);
|
||||
} else {
|
||||
// 256 chars: low ROM (0-127) and high ROM (128-255)
|
||||
snprintf(out_path, sizeof(out_path), "%s_low.chr", output_prefix);
|
||||
fprintf(stderr, "\nWriting low ROM (chars 0-127):\n");
|
||||
write_rom(out_path, glyphs, 128);
|
||||
|
||||
snprintf(out_path, sizeof(out_path), "%s_high.chr", output_prefix);
|
||||
fprintf(stderr, "\nWriting high ROM (chars 128-255):\n");
|
||||
write_rom(out_path, &glyphs[128 * GLYPH_BYTES], 128);
|
||||
}
|
||||
|
||||
free(glyphs);
|
||||
fprintf(stderr, "\nDone.\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ cp -r "../out/$RUNTIME" $DESTDIR/
|
||||
|
||||
# Copy over all the assets and a jarfile
|
||||
cp -r "../out/TerranBASIC.jar" $DESTDIR/
|
||||
cp "../lib/compiler-23.1.10.jar" "../lib/compiler-management-23.1.10.jar" "../lib/truffle-compiler-23.1.10.jar" "../lib/truffle-api-23.1.10.jar" "../lib/truffle-runtime-23.1.10.jar" "../lib/polyglot-23.1.10.jar" "../lib/collections-23.1.10.jar" "../lib/word-23.1.10.jar" "../lib/nativeimage-23.1.10.jar" "../lib/jniutils-23.1.10.jar" $DESTDIR/
|
||||
|
||||
# Pack everything to AppImage
|
||||
ARCH=arm_aarch64 "./$APPIMAGETOOL" $DESTDIR "out/$DESTDIR.AppImage" || { echo 'Building AppImage failed' >&2; exit 1; }
|
||||
|
||||
@@ -22,6 +22,7 @@ cp -r "../out/$RUNTIME" $DESTDIR/
|
||||
|
||||
# Copy over all the assets and a jarfile
|
||||
cp -r "../out/TerranBASIC.jar" $DESTDIR/
|
||||
cp "../lib/compiler-23.1.10.jar" "../lib/compiler-management-23.1.10.jar" "../lib/truffle-compiler-23.1.10.jar" "../lib/truffle-api-23.1.10.jar" "../lib/truffle-runtime-23.1.10.jar" "../lib/polyglot-23.1.10.jar" "../lib/collections-23.1.10.jar" "../lib/word-23.1.10.jar" "../lib/nativeimage-23.1.10.jar" "../lib/jniutils-23.1.10.jar" $DESTDIR/
|
||||
|
||||
# Pack everything to AppImage
|
||||
"./$APPIMAGETOOL" $DESTDIR "out/$DESTDIR.AppImage" || { echo 'Building AppImage failed' >&2; exit 1; }
|
||||
|
||||
@@ -23,5 +23,6 @@ cp -r "../out/$RUNTIME" $DESTDIR/Contents/MacOS/
|
||||
|
||||
# Copy over all the assets and a jarfile
|
||||
cp -r "../out/TerranBASIC.jar" $DESTDIR/Contents/MacOS/
|
||||
cp "../lib/compiler-23.1.10.jar" "../lib/compiler-management-23.1.10.jar" "../lib/truffle-compiler-23.1.10.jar" "../lib/truffle-api-23.1.10.jar" "../lib/truffle-runtime-23.1.10.jar" "../lib/polyglot-23.1.10.jar" "../lib/collections-23.1.10.jar" "../lib/word-23.1.10.jar" "../lib/nativeimage-23.1.10.jar" "../lib/jniutils-23.1.10.jar" $DESTDIR/Contents/MacOS/
|
||||
|
||||
echo "Build successful: $DESTDIR"
|
||||
|
||||
@@ -23,5 +23,6 @@ cp -r "../out/$RUNTIME" $DESTDIR/Contents/MacOS/
|
||||
|
||||
# Copy over all the assets and a jarfile
|
||||
cp -r "../out/TerranBASIC.jar" $DESTDIR/Contents/MacOS/
|
||||
cp "../lib/compiler-23.1.10.jar" "../lib/compiler-management-23.1.10.jar" "../lib/truffle-compiler-23.1.10.jar" "../lib/truffle-api-23.1.10.jar" "../lib/truffle-runtime-23.1.10.jar" "../lib/polyglot-23.1.10.jar" "../lib/collections-23.1.10.jar" "../lib/word-23.1.10.jar" "../lib/nativeimage-23.1.10.jar" "../lib/jniutils-23.1.10.jar" $DESTDIR/Contents/MacOS/
|
||||
|
||||
echo "Build successful: $DESTDIR"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user