mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-08 14:24:05 +09:00
Compare commits
123 Commits
937d3e27ed
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6068080bcb | ||
|
|
ffc1d420cd | ||
|
|
e32f7565ba | ||
|
|
c17f4828b0 | ||
|
|
95ac8c53dd | ||
|
|
c8fc363445 | ||
|
|
ce45929c4e | ||
|
|
0f5ede5276 | ||
|
|
aa45c2194f | ||
|
|
3444bdf63b | ||
|
|
df16b99ba5 | ||
|
|
6a0241a249 | ||
|
|
5c7ff9e906 | ||
|
|
c6e087e74c | ||
|
|
7dea413454 | ||
|
|
ee202efe09 | ||
|
|
6be98b5207 | ||
|
|
729e5246c9 | ||
|
|
e27a01dca6 | ||
|
|
35263eeaa4 | ||
|
|
d223adda25 | ||
|
|
a9d095e3cb | ||
|
|
dad345c027 | ||
|
|
2045da0286 | ||
|
|
3362a6b732 | ||
|
|
038db60b59 | ||
|
|
1d3b5ce8aa | ||
|
|
9e8af96c32 | ||
|
|
43e5baadf4 | ||
|
|
f863f6230d | ||
|
|
d8ac08162c | ||
|
|
e24870ce07 | ||
|
|
10e577699f | ||
|
|
01cc5c90ee | ||
|
|
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 |
13
.gitignore
vendored
13
.gitignore
vendored
@@ -62,11 +62,18 @@ tsvmman.pdf
|
|||||||
*.ilg
|
*.ilg
|
||||||
*.ind
|
*.ind
|
||||||
|
|
||||||
|
assets/disk0/tvdos/bin/tautfont.png
|
||||||
|
|
||||||
|
video_encoder/*
|
||||||
|
|
||||||
|
.idea/vcs.xml
|
||||||
|
|
||||||
|
# in-dev stuffs
|
||||||
assets/disk0/home/basic/*
|
assets/disk0/home/basic/*
|
||||||
assets/disk0/movtestimg/*.jpg
|
assets/disk0/movtestimg/*.jpg
|
||||||
assets/disk0/*.mov
|
assets/disk0/*.mov
|
||||||
assets/diskMediabin/*
|
assets/diskMediabin/*
|
||||||
|
assets/disk0/hopper/*
|
||||||
|
|
||||||
video_encoder/*
|
# TVDOS runtime caches (regenerated on the VM; never commit)
|
||||||
|
assets/disk0/tvdos/cache/
|
||||||
assets/disk0/tvdos/bin/tautfont.png
|
|
||||||
|
|||||||
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>
|
||||||
16
2taud.sh
16
2taud.sh
@@ -1,8 +1,12 @@
|
|||||||
#!/usr/bin/env fish
|
#!/usr/bin/env fish
|
||||||
|
|
||||||
for f in *.mod; python3 mod2taud.py $f assets/disk0/(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/(basename $f .s3m).taud; end
|
for f in *.MOD; python3 mod2taud.py $f assets/disk0/home/music/(basename $f .MOD).taud; end
|
||||||
for f in *.it; python3 it2taud.py $f assets/disk0/(basename $f .it).taud; end
|
for f in *.s3m; python3 s3m2taud.py $f assets/disk0/home/music/(basename $f .s3m).taud; end
|
||||||
for f in *.xm; python3 xm2taud.py $f assets/disk0/(basename $f .xm).taud; end
|
for f in *.S3M; python3 s3m2taud.py $f assets/disk0/home/music/(basename $f .S3M).taud; end
|
||||||
for f in *.mon; python3 mon2taud.py $f assets/disk0/(basename $f .mon).taud; end
|
for f in *.it; python3 it2taud.py $f assets/disk0/home/music/(basename $f .it).taud; end
|
||||||
for f in *.MON; python3 mon2taud.py $f assets/disk0/(basename $f .MON).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
|
||||||
|
|||||||
128
CLAUDE.md
128
CLAUDE.md
@@ -37,6 +37,7 @@ Current topics:
|
|||||||
- `reference_materials/impulse-tracker` — The original source code for ImpulseTracker
|
- `reference_materials/impulse-tracker` — The original source code for ImpulseTracker
|
||||||
- `reference_materials/MilkyTracker` — FastTracker 2 compatible tracker
|
- `reference_materials/MilkyTracker` — FastTracker 2 compatible tracker
|
||||||
- `reference_materials/schismtracker` — Open-source re-implementation of ImpulseTracker
|
- `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
|
When fetching new references, copy the relevant upstream files verbatim into
|
||||||
a topic folder, write a `README.md` summarising the relevant maths /
|
a topic folder, write a `README.md` summarising the relevant maths /
|
||||||
@@ -115,6 +116,16 @@ Use the build scripts in `buildapp/`:
|
|||||||
- `My_BASIC_Programs/`: Example BASIC programs for testing
|
- `My_BASIC_Programs/`: Example BASIC programs for testing
|
||||||
- TVDOS filesystem uses custom format with specialised drivers
|
- 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
|
## Videotron2K
|
||||||
|
|
||||||
The Videotron2K is a specialised video display controller with:
|
The Videotron2K is a specialised video display controller with:
|
||||||
@@ -154,6 +165,14 @@ Peripheral memories can be accessed using `vm.peek()` and `vm.poke()` functions,
|
|||||||
|
|
||||||
- The 'gzip' namespace in TSVM's JS programs is a misnomer: the actual 'gzip' functions (defined in CompressorDelegate.kt) call Zstd functions.
|
- The 'gzip' namespace in TSVM's JS programs is a misnomer: the actual 'gzip' functions (defined in CompressorDelegate.kt) call Zstd functions.
|
||||||
|
|
||||||
|
## Taud Tracker Engine
|
||||||
|
|
||||||
|
The Taud playback engine lives in `tsvm_core/src/net/torvald/tsvm/peripheral/AudioAdapter.kt`.
|
||||||
|
|
||||||
|
### Critical Implementation Notes
|
||||||
|
|
||||||
|
**Re-bind the local `inst` after any mid-tick `triggerNote`.** `applyTrackerTick` binds `var inst = instruments[voice.instrumentId]` once at the top of the per-voice loop. When the note-delay (`S$Dx`) deferred trigger fires mid-tick, `triggerNote` swaps the voice's `instrumentId` — but the rest of that tick (playback-rate recompute at the `computePlaybackRate(inst, finalPitch)` line, `advanceEnvelope`, `advancePfEnvelope`, `advanceAutoVibrato`, and the fadeout / filter-env reads of `inst.*`) keeps using the captured binding. The damage on a **never-triggered voice** (`instrumentId == 0` → stale `inst = instruments[0]`, whose `samplingRate == 0`) is that `playbackRate` is overwritten with `0.0`, freezing the sample at its start for the trigger tick — perceived as "the first delayed note on a fresh channel doesn't fire" (canonical: WHEN.taud cue 0 voice 13 pattern 0x0A row 16, inst `0x11` SD2 on a fresh play). On a warm voice the stale `inst` is a real instrument with non-zero rate, so the note sounds (at the wrong rate for one tick — a sub-perceptual glitch). Re-bind `inst = instruments[voice.instrumentId]` immediately after the note-delay fire block. Any future in-tick trigger paths (currently only S$Dx) must do the same.
|
||||||
|
|
||||||
## TVDOS
|
## TVDOS
|
||||||
|
|
||||||
### TVDOS Movie Formats
|
### TVDOS Movie Formats
|
||||||
@@ -417,3 +436,112 @@ The different weights for Mid and Side channels reflect the perceptual importanc
|
|||||||
- DC frequency underamplification (using 1.0 instead of 4.0/6.0)
|
- DC frequency underamplification (using 1.0 instead of 4.0/6.0)
|
||||||
- Incorrect stereo imaging and extreme side channel distortion
|
- Incorrect stereo imaging and extreme side channel distortion
|
||||||
- Severe frequency response errors that manifest as "clipping-like" distortion
|
- Severe frequency response errors that manifest as "clipping-like" distortion
|
||||||
|
|
||||||
|
## Virtual Consoles (vtmgr)
|
||||||
|
|
||||||
|
Linux-style virtual consoles for TVDOS: up to 6 independent shell sessions,
|
||||||
|
switched with **Alt-1..Alt-6** or the **`chvt N`** builtin, **Alt-0** to exit.
|
||||||
|
Implemented entirely in JS — **no tsvm_core changes**.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
- **Dispatcher**: `assets/disk0/tvdos/VTMGR.SYS`. Launched directly by the
|
||||||
|
`TVDOS.SYS` boot block (only when `!_TVDOS_IS_VT_PANE`); when it exits (Alt-0)
|
||||||
|
the boot block runs `AUTOEXEC.BAT` as the bare fallback shell. Owns the
|
||||||
|
physical keyboard and screen. Each VT runs in its own GraalVM context/thread
|
||||||
|
via the existing `parallel.spawnNewContext` / `attachProgram` / `launch` API
|
||||||
|
(see `VMJSR223Delegate.kt` `class Parallel`). VT 1 spawns at boot; VT 2-6 are
|
||||||
|
lazy-spawned on first switch and re-spawned if their shell exits.
|
||||||
|
- **Concurrency model**: truly concurrent — switching works mid-command, not
|
||||||
|
just at the prompt. Background panes keep running (no `Thread.suspend`; it is
|
||||||
|
unusable on JDK 21). A cooperative gate inside the shimmed `con.getch` parks
|
||||||
|
panes blocked on input; CPU-bound background panes are allowed to run.
|
||||||
|
- **Shared memory**: one `sys.malloc` region holds a control block (active VT,
|
||||||
|
switch request, debounce, spawned-bits) plus, per VT, an input ring buffer and
|
||||||
|
a 7682-byte text-plane buffer mirroring the GPU text-area layout
|
||||||
|
(cursor 2 + fore 2560 + back 2560 + char 2560).
|
||||||
|
- **Compositor** (30 Hz): blits the active VT's text plane to the physical GPU
|
||||||
|
text area via `sys.memcpy`, and pushes that VT's cursor-visibility into the GPU
|
||||||
|
blink bit (MMIO attribute byte 6, addressed at `-1 - (131072*gpuSlot + 6)`).
|
||||||
|
- **Boot config split (`commandrc` + `AUTOEXEC.BAT`)**: environment setup and
|
||||||
|
app-launch are split into two files so panes can replay one without the other.
|
||||||
|
`\commandrc` holds the `set` commands (PATH/INCLPATH/HELPPATH/KEYBOARD) and is
|
||||||
|
run by the `TVDOS.SYS` boot block in **every** context (boot and pane) — it has
|
||||||
|
no `.BAT` extension, so the boot block runs it line-by-line (`set` mutates the
|
||||||
|
shared `_TVDOS.variables`, so the effect persists). `\AUTOEXEC.BAT` is the
|
||||||
|
**per-console launch** script (Korean IME `tvdos/i18n/korean`, then
|
||||||
|
`command -fancy`); it is run once per console — by each pane's bootstrap, and
|
||||||
|
by the boot block as the post-vtmgr fallback. No env snapshot/replay anymore;
|
||||||
|
each pane gets PATH/KEYBOARD/etc. natively from `commandrc`, and Korean IME
|
||||||
|
(a per-context `unicode.uniprint` handler) now registers in every pane.
|
||||||
|
- **Per-pane bootstrap**: each pane re-evals `TVDOS.SYS` (with `_TVDOS_IS_VT_PANE`
|
||||||
|
set — which makes the boot block run `commandrc` but skip the vtmgr/AUTOEXEC
|
||||||
|
launch — and a `_BIOS` stub captured live from the main context) then runs
|
||||||
|
`command -c \AUTOEXEC.BAT`, all in ONE direct `eval` so the launcher shares
|
||||||
|
scope with `_TVDOS`/`files`/`execApp`.
|
||||||
|
|
||||||
|
### Output/input shimming (in the pane bootstrap)
|
||||||
|
|
||||||
|
`con` and the global `print`/`println` family are plain JS, so the bootstrap
|
||||||
|
overrides them to read/write the per-VT shared-memory buffers instead of the
|
||||||
|
physical GPU. **`sys` and `graphics` are host objects and CANNOT be overridden
|
||||||
|
from JS** — this is the key constraint that shapes everything below.
|
||||||
|
|
||||||
|
- The shimmed `print` is a faithful JS port of the GPU's TTY interpreter
|
||||||
|
(`GlassTty.acceptChar` + `GraphicsAdapter` handlers): control bytes, the
|
||||||
|
`\x84<decimal>u` "emit char by code" escape (used by `con.prnch`), CSI cursor
|
||||||
|
moves / erase / SGR colours, and the `?25` cursor-visibility private sequence.
|
||||||
|
A swallow-only parser is NOT enough — TVDOS apps drive the screen through
|
||||||
|
these `print` escapes.
|
||||||
|
- `con.move`/`con.getyx` are **1-based** (mirroring `graphics.setCursorYX`'s
|
||||||
|
`cx-1` and `getCursorYX`'s `cx+1`); `con.addch` does NOT advance the cursor
|
||||||
|
(matches `graphics.putSymbol`), while `con.prnch` DOES.
|
||||||
|
- `command.js`'s `shell.execute` reassigns the global print family to
|
||||||
|
`shell.stdio.out.*`, which call `sys.print` (→ physical GPU). `shell.stdio.out`
|
||||||
|
was made to delegate to a `globalThis.__VT_OUT` hook when present (set by the
|
||||||
|
bootstrap); outside a VT the hook is absent and the path is byte-identical.
|
||||||
|
|
||||||
|
### Direct-VRAM apps need a VT-aware base (the `vaddr` pattern)
|
||||||
|
|
||||||
|
Apps that write the text area directly via `graphics.getGpuMemBase()` (rather
|
||||||
|
than `con.*`/`print`) bypass the shims and paint the physical screen, invading
|
||||||
|
whatever VT is visible. They must resolve text-area byte `m` through a
|
||||||
|
VT-aware base:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// physical: backward (byte m at gpuBase - m) — getDev inverts to forward-native
|
||||||
|
// VT pane: forward (byte m at VT_TEXT_PLANE + m, the pane buffer the compositor blits)
|
||||||
|
const VT = (typeof globalThis.VT_TEXT_PLANE !== 'undefined')
|
||||||
|
const VRAM_BASE = VT ? globalThis.VT_TEXT_PLANE : (graphics.getGpuMemBase() - 253950)
|
||||||
|
const VRAM_SGN = VT ? 1 : -1
|
||||||
|
function vaddr(m) { return VRAM_BASE + VRAM_SGN * m }
|
||||||
|
```
|
||||||
|
|
||||||
|
`sys.memcpy`/`sys.pokeBytes` copy forward in the resolved native memory, so this
|
||||||
|
works for both directions. The physical branch is identical to the original
|
||||||
|
arithmetic (no regression outside vtmgr). Applied so far in
|
||||||
|
`assets/disk0/tvdos/bin/taut.js` and `assets/disk0/hopper/include/aa.mjs`
|
||||||
|
(used by `bb.js`). Any future direct-VRAM app needs the same one-line `vaddr`.
|
||||||
|
|
||||||
|
### Files
|
||||||
|
|
||||||
|
- New: `assets/disk0/tvdos/VTMGR.SYS` (dispatcher + per-pane bootstrap)
|
||||||
|
- `assets/disk0/tvdos/bin/command.js`: `chvt` builtin, `[N]` prompt prefix for
|
||||||
|
VT 2-6, `shell.stdio.out` → `__VT_OUT` delegation
|
||||||
|
- `assets/disk0/tvdos/TVDOS.SYS`: boot block runs `\commandrc` (env) in every
|
||||||
|
context, then — only when `!_TVDOS_IS_VT_PANE` — launches `tvdos/sbin/vtmgr`
|
||||||
|
and, on its exit, `\AUTOEXEC.BAT` as the fallback shell
|
||||||
|
- `assets/disk0/commandrc`: env-only `set` commands (PATH/INCLPATH/HELPPATH/KEYBOARD)
|
||||||
|
- `assets/disk0/AUTOEXEC.BAT`: per-console launch (Korean IME + `command -fancy`)
|
||||||
|
- `assets/disk0/tvdos/bin/taut.js`, `assets/disk0/hopper/include/aa.mjs`:
|
||||||
|
`vaddr` VT-aware direct-VRAM addressing
|
||||||
|
|
||||||
|
### Gotcha: injectIntChk vs. embedded source
|
||||||
|
|
||||||
|
`execApp`/`require` run a program's source through `injectIntChk` (TVDOS.SYS),
|
||||||
|
which sed-rewrites the **first** `while`/`for`/`do` of each kind to call a
|
||||||
|
per-exec `tvdosSIGTERM_<hash>()` SIGTERM check. When vtmgr embeds the pane
|
||||||
|
bootstrap as a string literal, one of those rewrites can land inside the literal
|
||||||
|
— and the pane context has no such symbol. vtmgr strips them from the bootstrap
|
||||||
|
string with `raw.replace(/tvdosSIGTERM_[A-Za-z0-9_]+\(\);?/g, '')`. Any future
|
||||||
|
code that builds executable source as a string literal must do the same.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -10,5 +10,7 @@
|
|||||||
<orderEntry type="module" module-name="tsvm_core" />
|
<orderEntry type="module" module-name="tsvm_core" />
|
||||||
<orderEntry type="library" name="TerranVirtualDisk" level="project" />
|
<orderEntry type="library" name="TerranVirtualDisk" level="project" />
|
||||||
<orderEntry type="library" name="lib" 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>
|
</component>
|
||||||
</module>
|
</module>
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
con.reset_graphics();con.curs_set(0);con.clear();
|
con.reset_graphics();con.curs_set(0);con.clear();
|
||||||
graphics.resetPalette();graphics.setPalette(0, 0, 0, 0, 15);graphics.setBackground(0,0,0);
|
graphics.resetPalette();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="));
|
|
||||||
|
|
||||||
|
let logo = gzip.decomp(base64.atob("KLUv/aTAZgEABUAAZjZzEeDpUsq9pdxbyp1kAwAAQIEBbABsAG4AM2iX1JTWdkQh0DgC2AAAYCcpIWMQM9tMW2aimiH1Z1+Gs/X33dfS13naMQYOYyi7vqBstcwUJO0jYKEmjCffvSl9rXfaK8QbcmjFEiYGDL4+8GqOs6dJec2D7FALXA4eSzbiIrY91x6wSZkSBYCpzrgjdC+wdrQkQvrTu3MIV6jD9xL9diN1ncSElF0ug1EVqTjCFiS8J3/3tmHEjjFySAAb+AfOmcwxclRwoAq+IVUKpHd5/u1bCUEkaLYBYHapqgJhCxI+/H79Me4Gll3rLfuZl75gh2ClQ3DuhC2NQSuEmUgnJgkFVTViyRg3hsJ3vyfSu9tToYJuIMmiDgP3FYeCDB/uo1lVGhpVm5F136/KzzjVz5c03IIR9v0o6m3uHEJwnHEAGanNBbNS4k3w6/kcd1cccPt7FAnWd1K66ggTT5cSRAzfEDATFVR96zTH3BE/E35auqOhFWaqNkc6iTjzNQPP/BAyeNWPAUC7C0Yx4H4E7bjqvyGUgswZ6TycAmaTY8wRUqXwh7uZ+ZFUSRHBmtlPCJlBJHNn/0d1dG6qjjYsIX5DqAqjOiNzZoycv4BZBoMbqALqfbUcgkKNgBCQEGkIaxsSQCAQBBAIBEEQEIQgBAlGoBAgBAjCIAyCwAzEsSTmjfKsYQypHIaqhY782TFH0zZk9v65rXj1wihIZhwaM9EJCM6oGrKY+HR9fateD4VUZCQ6YM7lMzz6/BCOyT0+DyY6xduMZwQ+IvB0W/J8nr/LrEB02XOAZ2GXwdk7vrVEXeHSoGu6a2GzcnxtqibNPJsDaw9b9ZbsCUobzYVqZo7PAtcoijH1PsdJMg8eoI1UiYn8GK6Ef/tYKXRIO7jy1b/N2HHZp4qkM/V5+GwwvuGslANy8mHLtBWe3WYoWKY5nrzlh3LL5OcCr8P2FWUG/ETfR7mkZeomXhtLqXzfiVabPVkhsdgoTEYATB4fhUqGpL/QUZuxmNSuhPQjkY9aG8vkbhib7siueLJ5dvadM30INl7WNtrc4egmSg9CPkobFRrsW/niGcNHsMn6B4xuTXx8hNhmKO1ML6mv7gMBIxG2GYI1U0/KV7zsCSBCYhPMdiGp3Vv6HtFt7Nkko/IRxsvERNY2IyNlWMMx0cRmgwcjTGpuMzhQpoVfH6X4q2wDP7G4zQjEDIwZH3VYKKGHmxhvBiGzOwlF+Sg1ElRXggZuxiFkfKdcNdR09uuxTEE4L/3jWoGD+ywc6ZjhDTJ9ut1PGtubE8TZipUigaXXMfDkVcNmS8DsR5YyTHKPI6OPmasYaDGW0kRk82/5/cqMVmaJzThEa/rWDjVZxGYKdoV6dsbdMobx9ZAepdj3N3LgoaQzDk0KZlguE0pCudLRd8dH8n6WOdqeTlwjMGM7WVltyPZHhWU/QIgVZ3+ucE2AGdvKytTxJ6wDz/5CCQT+3ETT/4wts2eQ5y3LOTsuYccfCqEoG4YSEcww+sxowh+9+aEyfHTghdBlypFghsuZ7YX7wK7CR0UJTfVTyiQwQ8vMZEh8sakD4+Sp+l9DvBICMybKaqr+k3OcolmAdRXeDKKAGZPJSlTxWbr7nP31GfHeeP8zhMrMUPT10A0VO1mOj/6fYUhmWhgVwUtb0YwqSErvnP1nhJ6BYmDmQDQfHVPdj07oi8mmFdBXA07oDpROeTqe/wwhMrth/l1aMx+1s0MSvL7+GUNkt6tDqjz7qMgj6dmL/WfclPEO1/YjeuqkNJj8/M/8M3KVkaA9n9r4DmvMD8E/45yMf4iPgxP2pFO+vGL/DI1kzAn5tiNYEEmjvtB/RtAMzNoPKUUz4q5hNpBxlv8MF5mUwBrwdbTXi0JSFw/in5EjwzbiQ/2z78Bmr73nDdk/4ySDbuIDFYpmdBMy5N76nxFoGV+7PrdGqmDbMqf/GSPM8o34XBYBvz1Vmav6ZwQ6g2/Eh7bPvhM/QPry9c/waCaLjHxcOkupiCLUIRwAanUsvp7Ax+a7SQTzcWY7lKYhfBS34FcKfjSqBcxYnkOPkE+xOPubV00IeJmXP2W09UvGfZQnQsuaFIqMxxYlIlYGAmclmlGk6eUZspS50IqMBwEER6zPgifis2GZtwyp50ApFStb1EcH3125BLCohNFHj5LnsG9sAMimTCL5dGBNBGULG04Z+64Pk+WWFudEIbUPUUyKullbxuYxtw8vY0+VStQSBIb+0O867s577g8PK1+DvBTDdf540PO3fpLNYjQ1Zb9eYNlc3dnIPB14Z0MIpUYls2Szge1ZVVjbtaWc5w9YllOo4yUWZd/kKp7e7hVsosD5hb0klIS4IbDUf0ZWT1P3UWnn36CDETiC2icObjVnOk9gEUs+CwnBrXRZ8lmCW+FCJl6IDK/UskGGhoK1PPmai6sXWRNFkCouVK1WJjKT50dEgiFjNI+hF85yoFOGIjIG3QcbvlLQ5hs2IoSGEfmGBTtDKIWQ7J7PN3dIyHPfiUj2AJQTS0aeh9/4L+aStPh15LwkEAiJZ5/FSxCsfjUzn8TDxBn46ovRPoSfIL8mP1X03Pabiy68ka+pJqRslV8laE6k+Q9HcHLpI+AQVtppJr8BoJzx6B750IQ8uuCrnhC5jQqKwkBTECiKgQ4HUqd4N/7BkwqOVTyp+LzCk4rHig8rPFdwXvFc8cnCkxXPFZ8sPFhwrHi68CH9xGzMoB3jyrOhVB3SqMvm"));
|
||||||
// display logo in kickin' ass-style of panasonic
|
// display logo in kickin' ass-style of panasonic
|
||||||
// hide entire framebuffer with black text to hide the slow image drawing
|
// hide entire framebuffer with black text to hide the slow image drawing
|
||||||
/*
|
/*
|
||||||
@@ -77,7 +76,7 @@ tmr = sys.nanoTime();
|
|||||||
while (sys.nanoTime() - tmr < 2147483648) sys.spin();
|
while (sys.nanoTime() - tmr < 2147483648) sys.spin();
|
||||||
// clear screen
|
// clear screen
|
||||||
graphics.clearPixels(255);con.color_pair(239,255);
|
graphics.clearPixels(255);con.color_pair(239,255);
|
||||||
con.clear();con.move(1,1);graphics.resetPalette();
|
con.clear();con.move(1,1);
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
|||||||
BIN
assets/bios/tsvmlogo.bin.zst
Normal file
BIN
assets/bios/tsvmlogo.bin.zst
Normal file
Binary file not shown.
@@ -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,10 +1,11 @@
|
|||||||
echo "Starting TVDOS..."
|
rem AUTOEXEC.BAT -- per-console launch script. Run once for every console:
|
||||||
|
rem each virtual-console pane runs it (via vtmgr's bootstrap), and the boot
|
||||||
rem put set-xxx commands here:
|
rem shell runs it as the fallback once vtmgr exits (Alt-0). Environment setup
|
||||||
set PATH=\tvdos\installer;\tvdos\tuidev;$PATH
|
rem (`set` commands) lives in \commandrc, which TVDOS.SYS runs before this.
|
||||||
set KEYBOARD=us_colemak
|
rem
|
||||||
|
rem Korean IME registers a per-CONTEXT handler (unicode.uniprint), so it must
|
||||||
rem this line specifies which shell to be presented after the boot precess:
|
rem run per-console here rather than once at boot.
|
||||||
tvdos/i18n/korean
|
tvdos/i18n/korean
|
||||||
zfm
|
|
||||||
|
rem The interactive shell for this console.
|
||||||
command -fancy
|
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
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
9
assets/disk0/commandrc
Normal file
9
assets/disk0/commandrc
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
rem commandrc -- environment setup, run by TVDOS.SYS in EVERY context
|
||||||
|
rem (the boot shell AND every virtual-console pane). Put `set` commands and
|
||||||
|
rem other env-only configuration here. Do NOT launch apps from this file:
|
||||||
|
rem app launches belong in AUTOEXEC.BAT (run per-console by vtmgr).
|
||||||
|
|
||||||
|
set PATH=\tvdos\installer;\tvdos\tuidev;\tbas;\hopper\bin;$PATH
|
||||||
|
set INCLPATH=\hopper\include;$INCLPATH
|
||||||
|
set HELPPATH=\hopper\help;$HELPPATH
|
||||||
|
set KEYBOARD=us_colemak
|
||||||
@@ -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.
|
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.setBackground(2,1,3)
|
||||||
graphics.resetPalette();
|
graphics.resetPalette()
|
||||||
|
const GL = require("gl")
|
||||||
|
const win = require("wintex")
|
||||||
|
const keysym = require("keysym")
|
||||||
|
|
||||||
function captureUserInput() {
|
function captureUserInput() {
|
||||||
sys.poke(-40, 1);
|
sys.poke(-40, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getKeyPushed(keyOrder) {
|
function getKeyPushed(keyOrder) {
|
||||||
return sys.peek(-41 - keyOrder);
|
return sys.peek(-41 - keyOrder)
|
||||||
}
|
}
|
||||||
|
|
||||||
let _fsh = {};
|
function readMousePos() {
|
||||||
_fsh.titlebarTex = new GL.Texture(2, 14, base64.atob("/u/+/v3+/f39/f39/f39/f39/P39/Pz8/Pv7+w=="));
|
let lx = sys.peek(-33) & 0xFF
|
||||||
_fsh.scrdim = con.getmaxyx();
|
let hx = sys.peek(-34) & 0xFF
|
||||||
_fsh.scrwidth = _fsh.scrdim[1];
|
let ly = sys.peek(-35) & 0xFF
|
||||||
_fsh.scrheight = _fsh.scrdim[0];
|
let hy = sys.peek(-36) & 0xFF
|
||||||
_fsh.brandName = "f\xb3Sh";
|
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(
|
_fsh.brandLogoTexSmall = new GL.Texture(24, 14, gzip.decomp(base64.atob(
|
||||||
"H4sIAAAAAAAAAPv/Hy/4Qbz458+fIeILQQBIwoSh6qECuMVBukCmIJkDVQ+RQNgLE0MX/w+1lyhxqIUwTLJ/sQMAcIXsbVABAAA="
|
"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() {
|
_fsh.drawWallpaper = function() {
|
||||||
let wp = files.open("A:/home/wall.bytes")
|
let wp = files.open("A:/home/wall.bytes")
|
||||||
@@ -28,85 +185,85 @@ _fsh.drawWallpaper = function() {
|
|||||||
wp.pread(b, 250880, 0)
|
wp.pread(b, 250880, 0)
|
||||||
dma.ramToFrame(b, 0, 250880)
|
dma.ramToFrame(b, 0, 250880)
|
||||||
sys.free(b)
|
sys.free(b)
|
||||||
};
|
}
|
||||||
|
|
||||||
_fsh.drawTitlebar = function(titletext) {
|
_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) {
|
if (titletext === undefined || titletext.length == 0) {
|
||||||
con.move(1,1);
|
con.move(1,1)
|
||||||
print(" ".repeat(_fsh.scrwidth));
|
print(" ".repeat(_fsh.scrwidth))
|
||||||
GL.drawTexImageOver(_fsh.brandLogoTexSmall, 268, 0);
|
GL.drawTexImageOver(_fsh.brandLogoTexSmall, 268, 0)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
con.color_pair(240, 255);
|
con.color_pair(240, 255)
|
||||||
GL.drawTexPattern(_fsh.titlebarTex, 268, 0, 24, 14);
|
GL.drawTexPattern(_fsh.titlebarTex, 268, 0, 24, 14)
|
||||||
con.move(1, 1 + (_fsh.scrwidth - titletext.length) / 2);
|
con.move(1, 1 + (_fsh.scrwidth - titletext.length) / 2)
|
||||||
print(titletext);
|
print(titletext)
|
||||||
}
|
}
|
||||||
con.color_pair(254, 255);
|
con.color_pair(254, 255)
|
||||||
};
|
}
|
||||||
|
|
||||||
|
|
||||||
_fsh.Widget = function(id, w, h) {
|
_fsh.Widget = function(id, w, h) {
|
||||||
this.identifier = id;
|
this.identifier = id
|
||||||
this.width = w;
|
this.width = w
|
||||||
this.height = h;
|
this.height = h
|
||||||
|
|
||||||
if (!this.identifier) {
|
if (!this.identifier) {
|
||||||
this.identifier = "";
|
this.identifier = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
//this.update = function() {};
|
//this.update = function() {}
|
||||||
/**
|
/**
|
||||||
* Params charXoff and charYoff are ZERO-BASED!
|
* Params charXoff and charYoff are ZERO-BASED!
|
||||||
*/
|
*/
|
||||||
this.draw = function(charXoff, charYoff) {};
|
this.draw = function(charXoff, charYoff) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
_fsh.widgets = {}
|
_fsh.widgets = {}
|
||||||
_fsh.registerNewWidget = function(widget) {
|
_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(
|
clockWidget.numberSheet = new GL.SpriteSheet(19, 22, new GL.Texture(190, 22, gzip.decomp(base64.atob(
|
||||||
"H4sIAAAAAAAAAMWVW3LEMAgE739aHcFJJV5ZMD2I9ToVfcl4GBr80HF8r/FaR1ozMuIyoUu87lEXI0al5qVR5AebSwchSaNE6Nyo1Nw5HXF3SfPT4Bshl"+
|
"H4sIAAAAAAAAAMWVW3LEMAgE739aHcFJJV5ZMD2I9ToVfcl4GBr80HF8r/FaR1ozMuIyoUu87lEXI0al5qVR5AebSwchSaNE6Nyo1Nw5HXF3SfPT4Bshl"+
|
||||||
"EycA8RD96mLlHbuhTgOrfLnUDZspafbSQWk56WEGvQEtWaWwgb8iz7a8AOXhsraO/q9Qw2/GnXovfVN+q2wM/p/oddn2cjF239GX3y11+SWCtc6FTHC1v"+
|
"EycA8RD96mLlHbuhTgOrfLnUDZspafbSQWk56WEGvQEtWaWwgb8iz7a8AOXhsraO/q9Qw2/GnXovfVN+q2wM/p/oddn2cjF239GX3y11+SWCtc6FTHC1v"+
|
||||||
"TVPkDPWWn0w+DDz93UX9v9mF5KIsQ6OdN2KJoB4ui1bXXr0AMp0YfiQo//4XhpK8555dsNehAqVS5uhb5iHn3Kko769J59KmLBe/TSR7hcsd+hr+HnrwR"+
|
"TVPkDPWWn0w+DDz93UX9v9mF5KIsQ6OdN2KJoB4ui1bXXr0AMp0YfiQo//4XhpK8555dsNehAqVS5uhb5iHn3Kko769J59KmLBe/TSR7hcsd+hr+HnrwR"+
|
||||||
"9uvRF9+D3MP14gN7lqx+8OuNT+uqt3NFX3SN9fTbeeHNq+C29pRWzX5+Rcm7SZyjOKJ/2hkSPqul4xN279DrSYvCrNu2NI7ZMp1ouBxK3KBVVnEeAUWbK"+
|
"9uvRF9+D3MP14gN7lqx+8OuNT+uqt3NFX3SN9fTbeeHNq+C29pRWzX5+Rcm7SZyjOKJ/2hkSPqul4xN279DrSYvCrNu2NI7ZMp1ouBxK3KBVVnEeAUWbK"+
|
||||||
"MUDn5DPsPxmUqHZQjGpy2hergM3EVBAAAA=="
|
"MUDn5DPsPxmUqHZQjGpy2hergM3EVBAAAA=="
|
||||||
))));
|
))))
|
||||||
|
|
||||||
clockWidget.clockColon = new GL.Texture(4, 3, base64.atob("7+/v7+/v7+/v7+/v"));
|
clockWidget.clockColon = new GL.Texture(4, 3, base64.atob("7+/v7+/v7+/v7+/v"))
|
||||||
clockWidget.monthNames = ["Spring", "Summer", "Autumn", "Winter"];
|
clockWidget.monthNames = ["Spring", "Summer", "Autumn", "Winter"]
|
||||||
clockWidget.dayNames = ["Mondag ", "Tysdag ", "Midtveke", "Torsdag ", "Fredag ", "Laurdag ", "Sundag ", "Verddag "];
|
clockWidget.dayNames = ["Mondag ", "Tysdag ", "Midtveke", "Torsdag ", "Fredag ", "Laurdag ", "Sundag ", "Verddag "]
|
||||||
clockWidget.draw = function(charXoff, charYoff) {
|
clockWidget.draw = function(charXoff, charYoff) {
|
||||||
con.color_pair(254, 255);
|
con.color_pair(254, 255)
|
||||||
let xoff = charXoff * 7;
|
let xoff = charXoff * 7
|
||||||
let yoff = charYoff * 14 + 3;
|
let yoff = charYoff * 14 + 3
|
||||||
let timeInMinutes = ((sys.currentTimeInMills() / 60000)|0);
|
let timeInMinutes = ((sys.currentTimeInMills() / 60000)|0)
|
||||||
let mins = timeInMinutes % 60;
|
let mins = timeInMinutes % 60
|
||||||
let hours = ((timeInMinutes / 60)|0) % 24;
|
let hours = ((timeInMinutes / 60)|0) % 24
|
||||||
let ordinalDay = ((timeInMinutes / (60*24))|0) % 120;
|
let ordinalDay = ((timeInMinutes / (60*24))|0) % 120
|
||||||
let visualDay = (ordinalDay % 30) + 1;
|
let visualDay = (ordinalDay % 30) + 1
|
||||||
let months = ((timeInMinutes / (60*24*30))|0) % 4;
|
let months = ((timeInMinutes / (60*24*30))|0) % 4
|
||||||
let dayName = ordinalDay % 7; // 0 for Mondag
|
let dayName = ordinalDay % 7 // 0 for Mondag
|
||||||
if (ordinalDay == 119) dayName = 7; // Verddag
|
if (ordinalDay == 119) dayName = 7 // Verddag
|
||||||
let years = ((timeInMinutes / (60*24*30*120))|0) + 125;
|
let years = ((timeInMinutes / (60*24*30*120))|0) + 125
|
||||||
// draw timepiece
|
// draw timepiece
|
||||||
GL.drawSprite(clockWidget.numberSheet, (hours / 10)|0, 0, xoff, 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.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 + 5, 1)
|
||||||
GL.drawTexImage(clockWidget.clockColon, xoff + 48, yoff + 14, 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, 0, xoff + 57, yoff, 1)
|
||||||
GL.drawSprite(clockWidget.numberSheet, mins % 10, 0, xoff + 81, yoff, 1);
|
GL.drawSprite(clockWidget.numberSheet, mins % 10, 0, xoff + 81, yoff, 1)
|
||||||
// print month and date
|
// print month and date
|
||||||
con.move(1 + charYoff, 17 + charXoff);
|
con.move(1 + charYoff, 17 + charXoff)
|
||||||
print(clockWidget.monthNames[months]+" "+visualDay);
|
print(clockWidget.monthNames[months]+" "+visualDay)
|
||||||
// print year and dayname
|
// print year and dayname
|
||||||
con.move(2 + charYoff, 17 + charXoff);
|
con.move(2 + charYoff, 17 + charXoff)
|
||||||
print("\xE7"+years+" "+clockWidget.dayNames[dayName]);
|
print("\xE7"+years+" "+clockWidget.dayNames[dayName])
|
||||||
};
|
}
|
||||||
|
|
||||||
|
|
||||||
let calendarWidget = new _fsh.Widget("com.fsh.calendar", (_fsh.scrwidth - 8) / 2, 7*6)
|
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)
|
let todoWidget = new _fsh.Widget("com.fsh.todo_list", (_fsh.scrwidth - 8) / 2, 7*10)
|
||||||
todoWidget.todoList = [["Hello, world!", true]]
|
todoWidget.todoList = [["Hello, world!", true]]
|
||||||
todoWidget.draw = function(charXoff, charYoff) {
|
todoWidget.draw = function(charXoff, charYoff) {
|
||||||
|
let focusIndex = (_fsh.focus && _fsh.focus.widgetId === todoWidget.identifier)
|
||||||
|
? _fsh.focus.index : -1
|
||||||
|
|
||||||
con.color_pair(254, 255)
|
con.color_pair(254, 255)
|
||||||
let xoff = charXoff * 7
|
let xoff = charXoff * 7
|
||||||
let yoff = charYoff * 14 + 3
|
let yoff = charYoff * 14 + 3
|
||||||
|
|
||||||
con.move(charYoff, charXoff)
|
con.move(charYoff, charXoff)
|
||||||
print("========== TODO ==========")
|
print('\u00CD'.repeat(10)+" TODO "+'\u00CD'.repeat(10))
|
||||||
|
|
||||||
for (let i = 0; i <= 12; i++) {
|
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)
|
else con.color_pair(254, 255)
|
||||||
|
|
||||||
con.move(charYoff + i + 2, charXoff)
|
con.move(charYoff + i + 2, charXoff)
|
||||||
con.addch((list[1] === null) ? 43 : (list[1]) ? 0x9F : 0x9E)
|
con.addch((list[1] === null) ? 43 : (list[1]) ? 0x9F : 0x9E)
|
||||||
|
|
||||||
if (i > todoWidget.todoList.length) {
|
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++) {
|
for (let k = 0; k < 24; k++) {
|
||||||
con.mvaddch(charYoff + i + 2, charXoff + 2 + k, 95)
|
con.mvaddch(charYoff + i + 2, charXoff + 2 + k, 95)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
con.move(charYoff + i + 2, charXoff + 2)
|
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)
|
let quickAccessWidget = new _fsh.Widget("com.fsh.quick_access", (_fsh.scrwidth - 8) / 2, 7*20)
|
||||||
quickAccessWidget.entries = [
|
quickAccessWidget.entries = [ // TODO read from /home/config/fshrc
|
||||||
["Files", "/tvdos/bin/explorer.js"],
|
["Files", "/tvdos/bin/zfm.js"],
|
||||||
["Editor", "/tvdos/bin/edit.js"],
|
["Editor", "/tvdos/bin/edit.js"],
|
||||||
["BASIC", "/tbas/basic.js"],
|
["BASIC", "/tbas/basic.js"],
|
||||||
["DOS Shell", "/tvdos/bin/command.js /fancy"]
|
["DOS Shell", "/tvdos/bin/command.js -fancy"]
|
||||||
]
|
]
|
||||||
quickAccessWidget.draw = function(charXoff, charYoff) {
|
quickAccessWidget.draw = function(charXoff, charYoff) {
|
||||||
|
let focusIndex = (_fsh.focus && _fsh.focus.widgetId === quickAccessWidget.identifier)
|
||||||
|
? _fsh.focus.index : -1
|
||||||
|
|
||||||
con.color_pair(254, 255)
|
con.color_pair(254, 255)
|
||||||
let xoff = charXoff * 7
|
let xoff = charXoff * 7
|
||||||
let yoff = charYoff * 14 + 3
|
let yoff = charYoff * 14 + 3
|
||||||
|
|
||||||
con.move(charYoff, charXoff)
|
con.move(charYoff, charXoff)
|
||||||
print("====== QUICK ACCESS ======")
|
print('\u00CD'.repeat(6)+" QUICK ACCESS "+'\u00CD'.repeat(6))
|
||||||
|
|
||||||
for (let i = 0; i <= 21; i++) {
|
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)
|
else con.color_pair(254, 255)
|
||||||
|
|
||||||
con.move(charYoff + i + 2, charXoff)
|
con.move(charYoff + i + 2, charXoff)
|
||||||
con.addch((list[1] === null) ? 0xF9 : (list[1]) ? 7 : 0x7F)
|
con.addch((list[1] === null) ? 0xF9 : (list[1]) ? 7 : 0x7F)
|
||||||
|
|
||||||
if (i > quickAccessWidget.entries.length) {
|
if (i > quickAccessWidget.entries.length) {
|
||||||
|
con.color_pair(254, 255)
|
||||||
for (let k = 0; k < 24; k++) {
|
for (let k = 0; k < 24; k++) {
|
||||||
con.mvaddch(charYoff + i + 2, charXoff + 2 + k, 95)
|
con.mvaddch(charYoff + i + 2, charXoff + 2 + k, 95)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
con.move(charYoff + i + 2, charXoff + 2)
|
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
|
// change graphics mode and check if it's supported
|
||||||
graphics.setGraphicsMode(3)
|
graphics.setGraphicsMode(3)
|
||||||
@@ -260,29 +631,130 @@ _fsh.drawWallpaper()
|
|||||||
_fsh.drawTitlebar()
|
_fsh.drawTitlebar()
|
||||||
|
|
||||||
|
|
||||||
// TEST
|
// Load persisted state before the first draw
|
||||||
con.move(2,1);
|
_fsh.loadConfig();
|
||||||
print("fSh is very much in-dev! Hit backspace to exit")
|
|
||||||
|
// 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) {
|
while (true) {
|
||||||
captureUserInput();
|
captureUserInput()
|
||||||
if (getKeyPushed(0) == 67) break;
|
|
||||||
|
|
||||||
_fsh.widgets["com.fsh.clock"].draw(25, 3);
|
// -- keyboard --
|
||||||
_fsh.widgets["com.fsh.calendar"].draw(12, 8);
|
if (isKeyDown(KEY_ESC)) break;
|
||||||
_fsh.widgets["com.fsh.todo_list"].draw(10, 17);
|
|
||||||
_fsh.widgets["com.fsh.quick_access"].draw(47, 8);
|
|
||||||
|
|
||||||
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.reset_graphics()
|
||||||
con.color_pair(201,255);
|
con.clear()
|
||||||
print("cya!");
|
|
||||||
|
|
||||||
let konsht = 3412341241;
|
|
||||||
println(konsht);
|
|
||||||
|
|
||||||
let pppp = graphics.getCursorYX();
|
|
||||||
println(pppp.toString());
|
|
||||||
@@ -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)
|
let file = files.open("B:\\"+url)
|
||||||
|
|
||||||
if (!file.exists) {
|
if (!file.exists) {
|
||||||
printerrln("No such URL: "+url)
|
printerrln("No such URL: "+url)
|
||||||
return 1
|
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)
|
println(text)
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ if (exec_args !== undefined && exec_args[1] !== undefined && exec_args[1].starts
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const THEVERSION = "1.2.1"
|
const THEVERSION = "1.2.2"
|
||||||
|
|
||||||
const PROD = true
|
const PROD = true
|
||||||
let INDEX_BASE = 0
|
let INDEX_BASE = 0
|
||||||
@@ -4197,7 +4197,7 @@ bF.load = function(args) { // LOAD function
|
|||||||
if (args[1] === undefined) throw lang.missingOperand
|
if (args[1] === undefined) throw lang.missingOperand
|
||||||
var fileOpened = fs.open(args[1], "R")
|
var fileOpened = fs.open(args[1], "R")
|
||||||
|
|
||||||
|
serial.printerr('load '+args[1])
|
||||||
if (replUsrConfirmed || cmdbuf.length == 0) {
|
if (replUsrConfirmed || cmdbuf.length == 0) {
|
||||||
if (!fileOpened) {
|
if (!fileOpened) {
|
||||||
fileOpened = fs.open(args[1]+".BAS", "R")
|
fileOpened = fs.open(args[1]+".BAS", "R")
|
||||||
@@ -4241,7 +4241,7 @@ bF.yes = function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
bF.catalog = function(args) { // CATALOG 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')
|
var pathOpened = fs.open(args[1], 'R')
|
||||||
if (!pathOpened) {
|
if (!pathOpened) {
|
||||||
throw lang.noSuchFile
|
throw lang.noSuchFile
|
||||||
@@ -4251,6 +4251,57 @@ bF.catalog = function(args) { // CATALOG function
|
|||||||
com.sendMessage(port, "LIST")
|
com.sendMessage(port, "LIST")
|
||||||
println(com.pullMessage(port))
|
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)
|
Object.freeze(bF)
|
||||||
|
|
||||||
if (exec_args !== undefined && exec_args[1] !== undefined) {
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
})();
|
||||||
@@ -19,9 +19,9 @@ var Note = (function() {
|
|||||||
if (flats[s] !== names[s]) t[flats[s] + oct] = n(oct, s);
|
if (flats[s] !== names[s]) t[flats[s] + oct] = n(oct, s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
t.OFF = 0x0000; // key-off
|
t.NOP = 0x0000; // no-op (empty row)
|
||||||
t.CUT = 0xFFFE; // note cut (immediate)
|
t.OFF = 0x0001; // key-off
|
||||||
t.NOP = 0xFFFF; // no-op (empty row)
|
t.CUT = 0x0002; // note cut (immediate)
|
||||||
return t;
|
return t;
|
||||||
}());
|
}());
|
||||||
|
|
||||||
|
|||||||
@@ -55,10 +55,12 @@ class PmemFSfile {
|
|||||||
// string representation (preferable)
|
// string representation (preferable)
|
||||||
if (typeof bytes === 'string' || bytes instanceof String) {
|
if (typeof bytes === 'string' || bytes instanceof String) {
|
||||||
this.data = bytes
|
this.data = bytes
|
||||||
|
this.length = bytes.length
|
||||||
}
|
}
|
||||||
// Javascript array OR JVM byte[]
|
// Javascript array OR JVM byte[]
|
||||||
else if (Array.isArray(bytes) || bytes.toString().startsWith("[B")) {
|
else if (Array.isArray(bytes) || bytes.toString().startsWith("[B")) {
|
||||||
this.bdata = bytes[i]
|
this.bdata = bytes
|
||||||
|
this.length = bytes.length
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
throw Error("Invalid type for directory")
|
throw Error("Invalid type for directory")
|
||||||
@@ -76,10 +78,10 @@ class PmemFSfile {
|
|||||||
|
|
||||||
dataAsBytes() {
|
dataAsBytes() {
|
||||||
if (this.bdata !== undefined) return this.bdata
|
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++) {
|
for (let i = 0; i < this.data.length; i++) {
|
||||||
let p = this.data.charCodeAt(i)
|
let p = this.data.charCodeAt(i)
|
||||||
this.bdata[i] = (p > 127) ? p - 255 : p
|
this.bdata[i] = p
|
||||||
}
|
}
|
||||||
return this.bdata
|
return this.bdata
|
||||||
}
|
}
|
||||||
@@ -147,10 +149,12 @@ _TVDOS.variables = {
|
|||||||
LANG: "EN",
|
LANG: "EN",
|
||||||
KEYBOARD: "us_qwerty",
|
KEYBOARD: "us_qwerty",
|
||||||
PATH: "\\tvdos\\bin;\\home",
|
PATH: "\\tvdos\\bin;\\home",
|
||||||
|
INCLPATH: "\\tvdos\\include;\\home",
|
||||||
PATHEXT: ".com;.bat;.app;.js;.alias",
|
PATHEXT: ".com;.bat;.app;.js;.alias",
|
||||||
HELPPATH: "\\tvdos\\help",
|
HELPPATH: "\\tvdos\\help",
|
||||||
OS_NAME: "TSVM Disk Operating System",
|
OS_NAME: "TSVM Disk Operating System",
|
||||||
OS_VERSION: _TVDOS.VERSION
|
OS_VERSION: _TVDOS.VERSION,
|
||||||
|
USERCONFIGPATH: "\\home\\config",
|
||||||
};
|
};
|
||||||
Object.freeze(_TVDOS);
|
Object.freeze(_TVDOS);
|
||||||
|
|
||||||
@@ -162,16 +166,16 @@ class TVDOSFileDescriptor {
|
|||||||
|
|
||||||
constructor(path0, driverID) {
|
constructor(path0, driverID) {
|
||||||
if (path0.startsWith("$")) {
|
if (path0.startsWith("$")) {
|
||||||
let path1 = path0.substring(3)
|
let path1 = path0.replaceAll("/", "\\").substring(3)
|
||||||
let slashPos = path1.indexOf("/")
|
let slashPos = path1.indexOf("\\")
|
||||||
let devName = path1.substring(0, (slashPos < 0) ? path1.length : slashPos)
|
let devName = path1.substring(0, (slashPos < 0) ? path1.length : slashPos)
|
||||||
|
|
||||||
if (!files.reservedNames.includes(devName)) {
|
if (!files.reservedNames.includes(devName)) {
|
||||||
throw Error(`${devName} is not a valid device file`)
|
throw Error(`${devName} is not a valid device file`)
|
||||||
}
|
}
|
||||||
|
|
||||||
this._driveLetter = undefined
|
this._driveLetter = '$'
|
||||||
this._path = path0
|
this._path = '\\' + path1
|
||||||
this._driverID = `DEV${devName}`
|
this._driverID = `DEV${devName}`
|
||||||
this._driver = _TVDOS.DRV.FS[`DEV${devName}`] // can't just put `driverID` here
|
this._driver = _TVDOS.DRV.FS[`DEV${devName}`] // can't just put `driverID` here
|
||||||
}
|
}
|
||||||
@@ -937,8 +941,9 @@ _TVDOS.DRV.FS.DEVTMP.bread = (fd) => {
|
|||||||
_TVDOS.DRV.FS.DEVTMP.pread = (fd, ptr, count, offset) => {
|
_TVDOS.DRV.FS.DEVTMP.pread = (fd, ptr, count, offset) => {
|
||||||
if (_TVDOS.TMPFS[fd.path] === undefined) throw Error(`No such file: ${fd.fullPath}`)
|
if (_TVDOS.TMPFS[fd.path] === undefined) throw Error(`No such file: ${fd.fullPath}`)
|
||||||
let str = _TVDOS.TMPFS[fd.path].dataAsString()
|
let str = _TVDOS.TMPFS[fd.path].dataAsString()
|
||||||
for (let i = 0; i < count - (offset || 0); i++) {
|
let off = offset || 0
|
||||||
sys.poke(ptr + i, String.charCodeAt(i + (offset || 0)))
|
for (let i = 0; i < count; i++) {
|
||||||
|
sys.poke(ptr + i, str.charCodeAt(off + i))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -986,6 +991,7 @@ _TVDOS.DRV.FS.DEVTMP.remove = (fd) => {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
_TVDOS.DRV.FS.DEVTMP.exists = (fd) => (_TVDOS.TMPFS[fd.path] !== undefined)
|
_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)
|
Object.freeze(_TVDOS.DRV.FS.DEVTMP)
|
||||||
|
|
||||||
@@ -1108,13 +1114,18 @@ inputwork.repeatCount = 0;
|
|||||||
* where:
|
* where:
|
||||||
* "key_down", <key symbol string>, <repeat count>, keycode0, keycode1 .. keycode7
|
* "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)
|
* "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_down", pos-x, pos-y, <button mask: 1=left, 2=right, 4=middle>, keycode0..keycode7
|
||||||
* "mouse_up", pos-x, pos-y, 0
|
* "mouse_up", pos-x, pos-y, <button mask of the released button>, keycode0..keycode7
|
||||||
* "mouse_move", pos-x, pos-y, <button down?>, oldpos-x, oldpos-y
|
* "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) {
|
input.withEvent = function(callback) {
|
||||||
|
|
||||||
// TODO mouse event
|
|
||||||
function arrayEq(a,b) {
|
function arrayEq(a,b) {
|
||||||
for (let i = 0; i < a.length; ++i) {
|
for (let i = 0; i < a.length; ++i) {
|
||||||
if (a[i] !== b[i]) return false;
|
if (a[i] !== b[i]) return false;
|
||||||
@@ -1135,7 +1146,33 @@ input.withEvent = function(callback) {
|
|||||||
|
|
||||||
sys.poke(-40, 255);
|
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 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 keyChanged = !arrayEq(keys, inputwork.oldKeys)
|
||||||
let keyDiff = arrayDiff(keys, inputwork.oldKeys)
|
let keyDiff = arrayDiff(keys, inputwork.oldKeys)
|
||||||
|
|
||||||
@@ -1405,9 +1442,6 @@ let requireFromMemory = (ptr) => {
|
|||||||
}*/
|
}*/
|
||||||
|
|
||||||
|
|
||||||
var GL = require("A:/tvdos/include/gl.mjs")
|
|
||||||
|
|
||||||
|
|
||||||
// @param cmdsrc JS source code
|
// @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.
|
// @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'
|
// for command line 'echo foo bar', args[0] must be 'echo'
|
||||||
@@ -1420,7 +1454,7 @@ var execApp = (cmdsrc, args, appname) => {
|
|||||||
`var ${appname}=function(exec_args){${injectIntChk(cmdsrc, intchkFunName)}\n};` +
|
`var ${appname}=function(exec_args){${injectIntChk(cmdsrc, intchkFunName)}\n};` +
|
||||||
`${appname}`); // making 'exec_args' a app-level global
|
`${appname}`); // making 'exec_args' a app-level global
|
||||||
|
|
||||||
execAppPrg(args);
|
return execAppPrg(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1437,9 +1471,40 @@ try {
|
|||||||
serial.println("Warning: Could not load HSDPA driver: " + e.message)
|
serial.println("Warning: Could not load HSDPA driver: " + e.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Boot script
|
// Boot script. The work is split across two files:
|
||||||
serial.println(`TVDOS.SYS initialised on VM ${sys.getVmId()}, running boot script...`);
|
// \commandrc -- environment (`set` commands); run in EVERY context.
|
||||||
|
// \AUTOEXEC.BAT -- per-console launch (IME + interactive shell).
|
||||||
|
// vtmgr re-evaluates TVDOS.SYS inside each per-VT pane; a pane sets
|
||||||
|
// _TVDOS_IS_VT_PANE so it only replays the environment here and leaves the
|
||||||
|
// AUTOEXEC launch to vtmgr's pane bootstrap (which avoids recursively
|
||||||
|
// spawning vtmgr inside a pane).
|
||||||
|
{
|
||||||
|
let cmdsrc = files.open("A:/tvdos/bin/command.js").sread()
|
||||||
|
let runBatch = (path) => eval(`var _BAT=function(exec_args){${cmdsrc}\n};_BAT`)(["", "-c", path])
|
||||||
|
|
||||||
let cmdfile = files.open("A:/tvdos/bin/command.js")
|
// Environment first, boot and pane alike. Gives every pane the same
|
||||||
eval(`var _AUTOEXEC=function(exec_args){${cmdfile.sread()}\n};` +
|
// PATH / KEYBOARD / etc. natively, with no env-snapshot replay needed.
|
||||||
`_AUTOEXEC`)(["", "-c", "\\AUTOEXEC.BAT"])
|
// \commandrc has no .BAT extension (so command.js's batch-file path,
|
||||||
|
// which keys off the extension, won't pick it up); run it line-by-line.
|
||||||
|
// `set` mutates the shared _TVDOS.variables, so the effect persists across
|
||||||
|
// the per-line shell invocations. Skip blanks and `rem` comments.
|
||||||
|
let rcFile = files.open("A:/commandrc")
|
||||||
|
if (rcFile.exists) {
|
||||||
|
rcFile.sread().split('\n').forEach((line) => {
|
||||||
|
let t = line.trim()
|
||||||
|
if (t.length > 0 && !/^rem(\s|$)/i.test(t)) runBatch(line)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof _TVDOS_IS_VT_PANE === "undefined" || !_TVDOS_IS_VT_PANE) {
|
||||||
|
serial.println(`TVDOS.SYS initialised on VM ${sys.getVmId()}, running boot script...`);
|
||||||
|
// Boot console: hand the screen to the virtual-console multiplexer.
|
||||||
|
// When it exits (Alt-0), fall through to AUTOEXEC so the console is
|
||||||
|
// never left bare.
|
||||||
|
runBatch("tvdos/VTMGR.SYS")
|
||||||
|
runBatch("\\AUTOEXEC.BAT")
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
serial.println(`TVDOS.SYS re-initialised in VT pane on VM ${sys.getVmId()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
572
assets/disk0/tvdos/VTMGR.SYS
Normal file
572
assets/disk0/tvdos/VTMGR.SYS
Normal file
@@ -0,0 +1,572 @@
|
|||||||
|
// vtmgr — virtual console manager for TVDOS
|
||||||
|
//
|
||||||
|
// Spawns up to 6 independent shell sessions (virtual consoles), each in its
|
||||||
|
// own parallel GraalVM context with its own thread. Each pane runs a real
|
||||||
|
// `command -fancy` shell. The dispatcher (this file) owns the physical
|
||||||
|
// keyboard, polls Alt-N hotkeys at 30 Hz, blits the active pane's text
|
||||||
|
// plane to the GPU's text area, and routes typed characters into the
|
||||||
|
// active pane's input ring buffer.
|
||||||
|
//
|
||||||
|
// Hotkeys: Alt-1..Alt-6 switch to that VT (lazy-spawn on first use).
|
||||||
|
// Alt-0 cleanly tears down vtmgr.
|
||||||
|
// Builtins: `chvt N` from inside a pane writes to the switch register.
|
||||||
|
|
||||||
|
// ─── shared memory layout ───────────────────────────────────────────────────
|
||||||
|
// CTRL_AREA (64 bytes from base)
|
||||||
|
// +0 active_vt u8 (1..6)
|
||||||
|
// +1 switch_request u8 (0 = none, 1..6 = target; set by chvt, cleared by dispatcher)
|
||||||
|
// +2 debounce_held u8
|
||||||
|
// +3 vt_spawned_bits u8 (bit n-1 set if VT n is alive)
|
||||||
|
// +4..63 reserved
|
||||||
|
// VT block (× MAX_VT) starting at base + 64, each VT_BLOCK_SIZE bytes
|
||||||
|
// +0..7 reserved (cursor & color state lives inside text plane itself)
|
||||||
|
// +8 queue_head u8 (next-read index)
|
||||||
|
// +9 queue_tail u8 (next-write index)
|
||||||
|
// +10..11 reserved
|
||||||
|
// +12..267 queue_data (256-byte ring buffer; one slot lost to full/empty disambiguation)
|
||||||
|
// +268..271 reserved (alignment)
|
||||||
|
// +272..7953 text_plane (7682 bytes; mirrors GPU textArea layout exactly)
|
||||||
|
|
||||||
|
const MAX_VT = 6
|
||||||
|
const CTRL_AREA_SIZE = 64
|
||||||
|
const VT_BLOCK_SIZE = 8000
|
||||||
|
const TEXT_PLANE_OFFSET = 272
|
||||||
|
const TEXT_PLANE_SIZE = 7682
|
||||||
|
const QUEUE_DATA_OFFSET = 12
|
||||||
|
|
||||||
|
const CTRL_ACTIVE_VT = 0
|
||||||
|
const CTRL_SWITCH_REQUEST = 1
|
||||||
|
const CTRL_DEBOUNCE_HELD = 2
|
||||||
|
const CTRL_SPAWNED_BITS = 3
|
||||||
|
|
||||||
|
const GPU_TEXTAREA_OFFSET = 253950
|
||||||
|
const TEXT_COLS = 80
|
||||||
|
const TEXT_ROWS = 32
|
||||||
|
|
||||||
|
const TP_FORE_BASE = 2
|
||||||
|
const TP_BACK_BASE = 2 + 2560
|
||||||
|
const TP_TEXT_BASE = 2 + 2560 + 2560
|
||||||
|
|
||||||
|
const TOTAL_ALLOC_SIZE = CTRL_AREA_SIZE + MAX_VT * VT_BLOCK_SIZE
|
||||||
|
const BASE = sys.malloc(TOTAL_ALLOC_SIZE)
|
||||||
|
if (!BASE || BASE === 0) { printerrln("vtmgr: sys.malloc failed"); return 1 }
|
||||||
|
for (let i = 0; i < TOTAL_ALLOC_SIZE; i++) sys.poke(BASE + i, 0)
|
||||||
|
|
||||||
|
const CTRL = BASE
|
||||||
|
function vtBlockAddr(n) { return BASE + CTRL_AREA_SIZE + (n - 1) * VT_BLOCK_SIZE }
|
||||||
|
function vtTextPlaneAddr(n) { return vtBlockAddr(n) + TEXT_PLANE_OFFSET }
|
||||||
|
|
||||||
|
// ─── pane bootstrap ─────────────────────────────────────────────────────────
|
||||||
|
// Read TVDOS.SYS once at startup. Each pane's bootstrap embeds the source
|
||||||
|
// (via JSON.stringify-escaped string literal) and evaluates it together with
|
||||||
|
// the shell-start code as ONE direct-eval call. This matters because strict-
|
||||||
|
// mode direct eval is scope-isolated; if TVDOS.SYS and the shell launcher
|
||||||
|
// were two separate evals, the shell launcher wouldn't see `_TVDOS`,
|
||||||
|
// `files`, `execApp`, etc. defined by the first eval.
|
||||||
|
|
||||||
|
const TVDOS_SYS_SRC = files.open("A:/tvdos/TVDOS.SYS").sread()
|
||||||
|
|
||||||
|
// _BIOS is set by the real BIOS before TVDOS.SYS runs; TVDOS.SYS reads
|
||||||
|
// _BIOS.FIRST_BOOTABLE_PORT during init. Each pane is a fresh context with no
|
||||||
|
// BIOS, so capture the live value here (vtmgr runs in the main context where
|
||||||
|
// _BIOS is visible) and re-declare it in every pane bootstrap.
|
||||||
|
const BIOS_FIRST_BOOTABLE_PORT = JSON.stringify(_BIOS.FIRST_BOOTABLE_PORT)
|
||||||
|
|
||||||
|
// Environment no longer needs snapshotting/replaying: each pane re-evaluates
|
||||||
|
// TVDOS.SYS, whose boot block runs \commandrc in every context, so the pane
|
||||||
|
// gets the same PATH / KEYBOARD / etc. natively. The pane then runs
|
||||||
|
// \AUTOEXEC.BAT (the per-console launch script: IME + interactive shell).
|
||||||
|
|
||||||
|
function makePaneBootstrap(vtNum) {
|
||||||
|
const TP_BASE = vtTextPlaneAddr(vtNum)
|
||||||
|
const VT_BLK = vtBlockAddr(vtNum)
|
||||||
|
|
||||||
|
// Launcher code runs after TVDOS.SYS in the SAME eval scope, so `files`,
|
||||||
|
// `eval`, `_TVDOS` etc. resolve via lexical closure. TVDOS.SYS's boot
|
||||||
|
// block already ran \commandrc (env) and skipped its own AUTOEXEC because
|
||||||
|
// the pane sets _TVDOS_IS_VT_PANE; here we run \AUTOEXEC.BAT to launch the
|
||||||
|
// per-console shell.
|
||||||
|
const SHELL_START = ";\n"
|
||||||
|
+ "var _cmdfileSrc = files.open('A:/tvdos/bin/command.js').sread();\n"
|
||||||
|
+ "eval('var _VTSHELL=function(exec_args){' + _cmdfileSrc + '\\n};_VTSHELL')(['', '-c', '\\\\AUTOEXEC.BAT']);\n"
|
||||||
|
|
||||||
|
const combined = TVDOS_SYS_SRC + SHELL_START
|
||||||
|
|
||||||
|
const raw = `
|
||||||
|
globalThis.VT_NUM = ${vtNum}
|
||||||
|
globalThis.VT_TEXT_PLANE = ${TP_BASE}
|
||||||
|
globalThis.VT_BLOCK_ADDR = ${VT_BLK}
|
||||||
|
globalThis.VT_CTRL_ADDR = ${CTRL}
|
||||||
|
const TP = ${TP_BASE}
|
||||||
|
const VT_BLK = ${VT_BLK}
|
||||||
|
const CTRL = ${CTRL}
|
||||||
|
const QUEUE_DATA = VT_BLK + ${QUEUE_DATA_OFFSET}
|
||||||
|
const QUEUE_HEAD_ADDR = VT_BLK + 8
|
||||||
|
const QUEUE_TAIL_ADDR = VT_BLK + 9
|
||||||
|
const ACTIVE_VT_ADDR = CTRL + ${CTRL_ACTIVE_VT}
|
||||||
|
const COLS = ${TEXT_COLS}, ROWS = ${TEXT_ROWS}
|
||||||
|
const FORE_BASE = ${TP_FORE_BASE}, BACK_BASE = ${TP_BACK_BASE}, TEXT_BASE = ${TP_TEXT_BASE}
|
||||||
|
|
||||||
|
// ── output shims (write into the per-VT text-plane buffer in shared mem) ──
|
||||||
|
// This is a faithful JS port of the GPU's TTY interpreter (GlassTty.acceptChar
|
||||||
|
// + GraphicsAdapter handlers). TVDOS apps drive the screen by printing control
|
||||||
|
// bytes and escape sequences through print(), so the shim must interpret them
|
||||||
|
// exactly as the hardware would: the \\x84<decimal>u "emit char by code" escape
|
||||||
|
// (used by con.prnch), CSI cursor moves / erase / SGR colours, and the ?25
|
||||||
|
// cursor-visibility private sequence.
|
||||||
|
let curX = 0, curY = 0
|
||||||
|
let foreCol = 254
|
||||||
|
let backCol = 255
|
||||||
|
|
||||||
|
// Per-pane cursor visibility lives at VT_BLK+2 (1 = blink on, 0 = hidden).
|
||||||
|
// The compositor pushes the active pane's value into the GPU's blink bit.
|
||||||
|
const CURSOR_VIS_ADDR = VT_BLK + 2
|
||||||
|
sys.poke(CURSOR_VIS_ADDR, 1)
|
||||||
|
|
||||||
|
// SGR 30-37 / 40-47 → default 8-colour palette (matches GraphicsAdapter).
|
||||||
|
const SGR_PAL = [240, 211, 61, 230, 49, 219, 114, 254]
|
||||||
|
|
||||||
|
function writeCursor() {
|
||||||
|
let pos = curY * COLS + curX
|
||||||
|
sys.poke(TP + 0, pos & 0xFF)
|
||||||
|
sys.poke(TP + 1, (pos >> 8) & 0xFF)
|
||||||
|
}
|
||||||
|
function scrollBufUp(n) {
|
||||||
|
if (n < 1) n = 1
|
||||||
|
if (n > ROWS) n = ROWS
|
||||||
|
for (let p of [FORE_BASE, BACK_BASE, TEXT_BASE]) {
|
||||||
|
for (let y = 0; y < ROWS - n; y++) {
|
||||||
|
for (let x = 0; x < COLS; x++) {
|
||||||
|
sys.poke(TP + p + y * COLS + x, sys.peek(TP + p + (y + n) * COLS + x))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let clearVal = (p === TEXT_BASE) ? 0 : (p === FORE_BASE ? foreCol : backCol)
|
||||||
|
for (let y = ROWS - n; y < ROWS; y++)
|
||||||
|
for (let x = 0; x < COLS; x++) sys.poke(TP + p + y * COLS + x, clearVal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function putCharRaw(x, y, c) {
|
||||||
|
if (x < 0 || x >= COLS || y < 0 || y >= ROWS) return
|
||||||
|
let off = y * COLS + x
|
||||||
|
sys.poke(TP + TEXT_BASE + off, c & 0xFF)
|
||||||
|
sys.poke(TP + FORE_BASE + off, foreCol)
|
||||||
|
sys.poke(TP + BACK_BASE + off, backCol)
|
||||||
|
}
|
||||||
|
// Mirror of GraphicsAdapter.setCursorPos: wrap on overflow x, scroll on
|
||||||
|
// overflow y, clamp y above the screen.
|
||||||
|
function setCursorPos(x, y) {
|
||||||
|
let nx = x, ny = y
|
||||||
|
if (nx >= COLS) { nx = 0; ny += 1 }
|
||||||
|
else if (nx < 0) nx = 0
|
||||||
|
if (ny < 0) ny = 0
|
||||||
|
else if (ny >= ROWS) { scrollBufUp(ny - ROWS + 1); ny = ROWS - 1 }
|
||||||
|
curX = nx; curY = ny
|
||||||
|
writeCursor()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TTY actions (mirror the GraphicsAdapter overrides) ────────────────────
|
||||||
|
function ttyPrintable(c) { putCharRaw(curX, curY, c); setCursorPos(curX + 1, curY) }
|
||||||
|
function ttyCrlf() {
|
||||||
|
let ny = curY + 1
|
||||||
|
setCursorPos(0, (ny >= ROWS) ? ROWS - 1 : ny)
|
||||||
|
if (ny >= ROWS) scrollBufUp(1)
|
||||||
|
}
|
||||||
|
function ttyBackspace() { let x = curX, y = curY; setCursorPos(x - 1, y); putCharRaw(curX, curY, 0x20) }
|
||||||
|
function ttyTab() { setCursorPos(((curX / 8 | 0) + 1) * 8, curY) }
|
||||||
|
function ttyResetStatus() { foreCol = 253; backCol = 255 }
|
||||||
|
function ttyEmitChar(code) { putCharRaw(curX, curY, code); setCursorPos(curX + 1, curY) }
|
||||||
|
function ttyCursorUp(n) { setCursorPos(curX, curY - n) }
|
||||||
|
function ttyCursorDown(n) { let ny = curY + n; setCursorPos(curX, (ny >= ROWS) ? ROWS - 1 : ny) }
|
||||||
|
function ttyCursorFwd(n) { setCursorPos(curX + n, curY) }
|
||||||
|
function ttyCursorBack(n) { setCursorPos(curX - n, curY) }
|
||||||
|
function ttyCursorNextLine(n) { let ny = curY + n; setCursorPos(0, (ny >= ROWS) ? ROWS - 1 : ny); if (ny >= ROWS) scrollBufUp(ny - ROWS + 1) }
|
||||||
|
function ttyCursorPrevLine(n) { setCursorPos(0, curY - n) }
|
||||||
|
function ttyCursorX(n) { setCursorPos(n, curY) }
|
||||||
|
function ttyCursorXY(row, col) { setCursorPos(col - 1, row - 1) }
|
||||||
|
function ttyEraseInDisp(arg) {
|
||||||
|
if (arg === 2) {
|
||||||
|
for (let i = 0; i < COLS * ROWS; i++) {
|
||||||
|
sys.poke(TP + TEXT_BASE + i, 0)
|
||||||
|
sys.poke(TP + FORE_BASE + i, foreCol)
|
||||||
|
sys.poke(TP + BACK_BASE + i, backCol)
|
||||||
|
}
|
||||||
|
curX = 0; curY = 0; writeCursor()
|
||||||
|
}
|
||||||
|
// other args: GraphicsAdapter TODOs (throws); we no-op for safety
|
||||||
|
}
|
||||||
|
function ttySgr1(arg) {
|
||||||
|
if (arg >= 30 && arg <= 37) foreCol = SGR_PAL[arg - 30]
|
||||||
|
else if (arg >= 40 && arg <= 47) backCol = SGR_PAL[arg - 40]
|
||||||
|
else if (arg === 7) { let t = foreCol; foreCol = backCol; backCol = t }
|
||||||
|
else if (arg === 0) { foreCol = 253; backCol = 255; sys.poke(CURSOR_VIS_ADDR, 1) }
|
||||||
|
}
|
||||||
|
function ttySgr3(a1, a2, a3) {
|
||||||
|
if (a1 === 38 && a2 === 5) foreCol = a3
|
||||||
|
else if (a1 === 48 && a2 === 5) backCol = a3
|
||||||
|
}
|
||||||
|
function ttyPrivH(arg) { if (arg === 25) sys.poke(CURSOR_VIS_ADDR, 1) }
|
||||||
|
function ttyPrivL(arg) { if (arg === 25) sys.poke(CURSOR_VIS_ADDR, 0) }
|
||||||
|
|
||||||
|
// ── escape-sequence state machine (mirror of GlassTty.acceptChar) ─────────
|
||||||
|
// States: 0 INITIAL, 1 ESC, 2 CSI, 3 NUM1, 4 SEP1, 5 NUM2, 6 SEP2, 7 NUM3,
|
||||||
|
// 8 PRIVATESEQ, 9 PRIVATENUM, 10 XCSI, 11 XNUM1
|
||||||
|
let escState = 0
|
||||||
|
let escArgs = []
|
||||||
|
function isDig(c) { return c >= 0x30 && c <= 0x39 }
|
||||||
|
function escReset() { escState = 0; escArgs.length = 0 }
|
||||||
|
// reject() in hardware returns the char as printable; replicate by printing it
|
||||||
|
function escRejectPrint(c) { escReset(); ttyPrintable(c) }
|
||||||
|
|
||||||
|
function processByte(c) {
|
||||||
|
switch (escState) {
|
||||||
|
case 0: // INITIAL
|
||||||
|
if (c === 0x1B) escState = 1
|
||||||
|
else if (c === 0x84) escState = 10
|
||||||
|
else if (c === 0x0A) ttyCrlf()
|
||||||
|
else if (c === 0x08) ttyBackspace()
|
||||||
|
else if (c === 0x09) ttyTab()
|
||||||
|
else if (c === 0x07) { /* bell */ }
|
||||||
|
else if (c >= 0x00 && c <= 0x1F) { /* other control: ignored */ }
|
||||||
|
else ttyPrintable(c)
|
||||||
|
break
|
||||||
|
case 1: // ESC
|
||||||
|
if (c === 0x63) { ttyResetStatus(); escReset() } // 'c'
|
||||||
|
else if (c === 0x5B) escState = 2 // '['
|
||||||
|
else escRejectPrint(c)
|
||||||
|
break
|
||||||
|
case 2: // CSI
|
||||||
|
if (c === 0x41) { ttyCursorUp(1); escReset() }
|
||||||
|
else if (c === 0x42) { ttyCursorDown(1); escReset() }
|
||||||
|
else if (c === 0x43) { ttyCursorFwd(1); escReset() }
|
||||||
|
else if (c === 0x44) { ttyCursorBack(1); escReset() }
|
||||||
|
else if (c === 0x45) { ttyCursorNextLine(1); escReset() }
|
||||||
|
else if (c === 0x46) { ttyCursorPrevLine(1); escReset() }
|
||||||
|
else if (c === 0x47) { ttyCursorX(1); escReset() }
|
||||||
|
else if (c === 0x4A) { ttyEraseInDisp(0); escReset() }
|
||||||
|
else if (c === 0x4B) { escReset() } // eraseInLine: no-op
|
||||||
|
else if (c === 0x53) { scrollBufUp(1); escReset() } // S
|
||||||
|
else if (c === 0x54) { escReset() } // T scrollDown: no-op
|
||||||
|
else if (c === 0x6D) { ttySgr1(0); escReset() } // m
|
||||||
|
else if (c === 0x3F) escState = 8 // '?'
|
||||||
|
else if (c === 0x3B) { escArgs.push(0); escState = 4 } // ';'
|
||||||
|
else if (isDig(c)) { escArgs.push(c - 0x30); escState = 3 }
|
||||||
|
else escRejectPrint(c)
|
||||||
|
break
|
||||||
|
case 3: // NUM1
|
||||||
|
if (c === 0x41) { ttyCursorUp(escArgs.pop()); escReset() }
|
||||||
|
else if (c === 0x42) { ttyCursorDown(escArgs.pop()); escReset() }
|
||||||
|
else if (c === 0x43) { ttyCursorFwd(escArgs.pop()); escReset() }
|
||||||
|
else if (c === 0x44) { ttyCursorBack(escArgs.pop()); escReset() }
|
||||||
|
else if (c === 0x45) { ttyCursorNextLine(escArgs.pop()); escReset() }
|
||||||
|
else if (c === 0x46) { ttyCursorPrevLine(escArgs.pop()); escReset() }
|
||||||
|
else if (c === 0x47) { ttyCursorX(escArgs.pop()); escReset() }
|
||||||
|
else if (c === 0x4A) { ttyEraseInDisp(escArgs.pop()); escReset() }
|
||||||
|
else if (c === 0x4B) { escArgs.pop(); escReset() }
|
||||||
|
else if (c === 0x53) { scrollBufUp(escArgs.pop()); escReset() }
|
||||||
|
else if (c === 0x54) { escArgs.pop(); escReset() }
|
||||||
|
else if (c === 0x6D) { ttySgr1(escArgs.pop()); escReset() }
|
||||||
|
else if (c === 0x3B) escState = 4
|
||||||
|
else if (isDig(c)) escArgs.push(escArgs.pop() * 10 + (c - 0x30))
|
||||||
|
else escRejectPrint(c)
|
||||||
|
break
|
||||||
|
case 4: // SEP1 (seen "n;")
|
||||||
|
if (isDig(c)) { escArgs.push(c - 0x30); escState = 5 }
|
||||||
|
else if (c === 0x48) { let a1 = escArgs.pop(); ttyCursorXY(a1, 0); escReset() } // H
|
||||||
|
else if (c === 0x6D) { ttySgr1(escArgs.pop()); escReset() } // m (2-arg unimpl in HW)
|
||||||
|
else if (c === 0x3B) { escArgs.push(0); escState = 6 }
|
||||||
|
else escRejectPrint(c)
|
||||||
|
break
|
||||||
|
case 5: // NUM2 (seen "n;n")
|
||||||
|
if (isDig(c)) escArgs.push(escArgs.pop() * 10 + (c - 0x30))
|
||||||
|
else if (c === 0x48) { let a2 = escArgs.pop(), a1 = escArgs.pop(); ttyCursorXY(a1, a2); escReset() }
|
||||||
|
else if (c === 0x6D) { escArgs.pop(); escArgs.pop(); escReset() } // 2-arg SGR unimpl in HW
|
||||||
|
else if (c === 0x3B) escState = 6
|
||||||
|
else escRejectPrint(c)
|
||||||
|
break
|
||||||
|
case 6: // SEP2 (seen "n;n;")
|
||||||
|
if (c === 0x6D) { let a2 = escArgs.pop(), a1 = escArgs.pop(); ttySgr3(a1, a2, 0); escReset() }
|
||||||
|
else if (isDig(c)) { escArgs.push(c - 0x30); escState = 7 }
|
||||||
|
else escRejectPrint(c)
|
||||||
|
break
|
||||||
|
case 7: // NUM3 (seen "n;n;n")
|
||||||
|
if (isDig(c)) escArgs.push(escArgs.pop() * 10 + (c - 0x30))
|
||||||
|
else if (c === 0x6D) { let a3 = escArgs.pop(), a2 = escArgs.pop(), a1 = escArgs.pop(); ttySgr3(a1, a2, a3); escReset() }
|
||||||
|
else escRejectPrint(c)
|
||||||
|
break
|
||||||
|
case 8: // PRIVATESEQ (seen "?")
|
||||||
|
if (isDig(c)) { escArgs.push(c - 0x30); escState = 9 }
|
||||||
|
else escRejectPrint(c)
|
||||||
|
break
|
||||||
|
case 9: // PRIVATENUM (seen "?n")
|
||||||
|
if (c === 0x68) { ttyPrivH(escArgs.pop()); escReset() } // h
|
||||||
|
else if (c === 0x6C) { ttyPrivL(escArgs.pop()); escReset() } // l
|
||||||
|
else if (isDig(c)) escArgs.push(escArgs.pop() * 10 + (c - 0x30))
|
||||||
|
else escRejectPrint(c)
|
||||||
|
break
|
||||||
|
case 10: // XCSI (seen \\x84)
|
||||||
|
if (c === 0x75) { ttyEmitChar(0); escReset() } // 'u'
|
||||||
|
else if (isDig(c)) { escArgs.push(c - 0x30); escState = 11 }
|
||||||
|
else escRejectPrint(c)
|
||||||
|
break
|
||||||
|
case 11: // XNUM1 (seen \\x84<digits>)
|
||||||
|
if (c === 0x75) { ttyEmitChar(escArgs.pop()); escReset() } // 'u'
|
||||||
|
else if (isDig(c)) escArgs.push(escArgs.pop() * 10 + (c - 0x30))
|
||||||
|
else escRejectPrint(c)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print = function(s) {
|
||||||
|
if (s === undefined || s === null) return
|
||||||
|
let str = '' + s
|
||||||
|
for (let i = 0; i < str.length; i++) processByte(str.charCodeAt(i))
|
||||||
|
}
|
||||||
|
println = function(s) {
|
||||||
|
if (s === undefined) print("\\n")
|
||||||
|
else print(s + "\\n")
|
||||||
|
}
|
||||||
|
printerr = function(s) { print(s) }
|
||||||
|
printerrln = function(s) { println(s) }
|
||||||
|
|
||||||
|
// command.js's shell.execute reassigns the global print/println/printerr/
|
||||||
|
// printerrln to shell.stdio.out.* (which call sys.print → physical GPU,
|
||||||
|
// bypassing these shims). Expose the buffer writers through a global hook so
|
||||||
|
// shell.stdio.out can delegate to them when running inside a VT pane. The
|
||||||
|
// non-VT path in command.js stays unchanged (hook is undefined there).
|
||||||
|
globalThis.__VT_OUT = { print: print, println: println, printerr: printerr, printerrln: printerrln }
|
||||||
|
|
||||||
|
// con.move / con.getyx are 1-based in TVDOS (graphics.setCursorYX does cx-1,
|
||||||
|
// getCursorYX returns cx+1). Internal curX/curY are 0-based, so convert.
|
||||||
|
con.move = function(y, x) {
|
||||||
|
curY = Math.max(0, Math.min(ROWS - 1, (y | 0) - 1))
|
||||||
|
curX = Math.max(0, Math.min(COLS - 1, (x | 0) - 1))
|
||||||
|
writeCursor()
|
||||||
|
}
|
||||||
|
con.getyx = function() { return [curY + 1, curX + 1] }
|
||||||
|
con.getmaxyx = function() { return [ROWS, COLS] }
|
||||||
|
con.color_pair = function(f, b) { foreCol = f & 0xFF; backCol = b & 0xFF }
|
||||||
|
con.color_fore = function(n) { foreCol = n & 0xFF }
|
||||||
|
con.color_back = function(n) { backCol = n & 0xFF }
|
||||||
|
con.get_color_fore = function() { return foreCol }
|
||||||
|
con.get_color_back = function() { return backCol }
|
||||||
|
// addch writes a glyph at the cursor WITHOUT advancing — matching
|
||||||
|
// graphics.putSymbol(). TVDOS code pairs addch with explicit curs_right();
|
||||||
|
// advancing here would double-step and leave gaps (e.g. the fancy prompt).
|
||||||
|
con.addch = function(c) { putCharRaw(curX, curY, c) }
|
||||||
|
con.mvaddch = function(y, x, c) { con.move(y, x); con.addch(c) }
|
||||||
|
con.curs_up = function(n) { n = n || 1; curY = Math.max(0, curY - n); writeCursor() }
|
||||||
|
con.curs_down = function(n) { n = n || 1; curY = Math.min(ROWS - 1, curY + n); writeCursor() }
|
||||||
|
con.curs_left = function(n) { n = n || 1; curX = Math.max(0, curX - n); writeCursor() }
|
||||||
|
con.curs_right = function(n) { n = n || 1; curX = Math.min(COLS - 1, curX + n); writeCursor() }
|
||||||
|
con.curs_set = function(arg) { sys.poke(CURSOR_VIS_ADDR, ((arg | 0) === 0) ? 0 : 1) }
|
||||||
|
con.video_reverse = function() { /* unsupported; ANSI swallowed */ }
|
||||||
|
con.reset_graphics = function() { foreCol = 254; backCol = 255 }
|
||||||
|
con.clear = function() {
|
||||||
|
for (let i = 0; i < COLS * ROWS; i++) {
|
||||||
|
sys.poke(TP + TEXT_BASE + i, 0)
|
||||||
|
sys.poke(TP + FORE_BASE + i, foreCol)
|
||||||
|
sys.poke(TP + BACK_BASE + i, backCol)
|
||||||
|
}
|
||||||
|
curX = 0; curY = 0; writeCursor()
|
||||||
|
}
|
||||||
|
// prnch prints a glyph and DOES advance (unlike addch) — the real impl emits
|
||||||
|
// it through print() as \\x84<code>u, so route it through the interpreter.
|
||||||
|
con.prnch = function(c) {
|
||||||
|
if (Array.isArray(c)) c.forEach(x => ttyEmitChar(x))
|
||||||
|
else ttyEmitChar(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── input shims ──────────────────────────────────────────────────────────
|
||||||
|
// Pane reads from its own ring buffer in shared mem. NEVER touches physical
|
||||||
|
// keyboard MMIO — that's the dispatcher's exclusive territory. Cooperative
|
||||||
|
// gate on active_vt keeps background panes parked when they call getch.
|
||||||
|
|
||||||
|
function queuePop() {
|
||||||
|
let head = sys.peek(QUEUE_HEAD_ADDR)
|
||||||
|
let tail = sys.peek(QUEUE_TAIL_ADDR)
|
||||||
|
if (head === tail) return -1
|
||||||
|
let b = sys.peek(QUEUE_DATA + head)
|
||||||
|
sys.poke(QUEUE_HEAD_ADDR, (head + 1) & 0xFF)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
con.getch = function() {
|
||||||
|
while (true) {
|
||||||
|
if (sys.peek(ACTIVE_VT_ADDR) === VT_NUM) {
|
||||||
|
let k = queuePop()
|
||||||
|
if (k >= 0) return k
|
||||||
|
}
|
||||||
|
sys.sleep(20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
con.hitterminate = function() { return false }
|
||||||
|
con.hiteof = function() { return false }
|
||||||
|
con.resetkeybuf = function() { sys.poke(QUEUE_HEAD_ADDR, sys.peek(QUEUE_TAIL_ADDR)) }
|
||||||
|
con.poll_keys = function() { return [0,0,0,0,0,0,0,0] }
|
||||||
|
|
||||||
|
// ── TVDOS.SYS init flags + BIOS stub ───────────────────────────────────────
|
||||||
|
globalThis._TVDOS_IS_VT_PANE = true
|
||||||
|
globalThis._BIOS = { FIRST_BOOTABLE_PORT: ${BIOS_FIRST_BOOTABLE_PORT} }
|
||||||
|
|
||||||
|
// ── load TVDOS.SYS and run AUTOEXEC.BAT (the per-console shell) in one direct-eval ─────
|
||||||
|
// Strict-mode direct eval is scope-isolated, so TVDOS.SYS's \`const _TVDOS\`
|
||||||
|
// only survives within the eval scope. The shell launcher must run inside
|
||||||
|
// the same eval to access it (via lexical closure into nested evals).
|
||||||
|
eval(${JSON.stringify(combined)})
|
||||||
|
`
|
||||||
|
// The outer execApp's injectIntChk rewrote the first while/for/do (each
|
||||||
|
// kind) in our literal source to call a per-exec SIGTERM check function.
|
||||||
|
// Some of those rewrites landed inside this template literal — the pane
|
||||||
|
// has no such symbol in scope. Strip them; panes don't need SIGTERM
|
||||||
|
// checks (parallel.kill handles teardown).
|
||||||
|
return raw.replace(/tvdosSIGTERM_[A-Za-z0-9_]+\(\);?/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── pane lifecycle ─────────────────────────────────────────────────────────
|
||||||
|
// Lazy spawn: VT 1 at boot; VT 2-6 the first time the user requests them.
|
||||||
|
// Re-spawn if the previous pane's thread has died (e.g. user typed `exit`).
|
||||||
|
|
||||||
|
const panes = {} // n -> { runner, thread }
|
||||||
|
|
||||||
|
function isPaneAlive(n) {
|
||||||
|
return panes[n] && parallel.isRunning(panes[n].thread)
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnPane(n) {
|
||||||
|
serial.println("[vtmgr] spawning VT " + n)
|
||||||
|
let runner = parallel.spawnNewContext()
|
||||||
|
let thread = parallel.attachProgram("vt" + n, runner, makePaneBootstrap(n))
|
||||||
|
parallel.launch(thread)
|
||||||
|
panes[n] = { runner: runner, thread: thread }
|
||||||
|
sys.poke(CTRL + CTRL_SPAWNED_BITS, sys.peek(CTRL + CTRL_SPAWNED_BITS) | (1 << (n - 1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePane(n) {
|
||||||
|
if (!isPaneAlive(n)) {
|
||||||
|
sys.poke(CTRL + CTRL_SPAWNED_BITS, sys.peek(CTRL + CTRL_SPAWNED_BITS) & ~(1 << (n - 1)))
|
||||||
|
spawnPane(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ensurePane(1)
|
||||||
|
sys.poke(CTRL + CTRL_ACTIVE_VT, 1)
|
||||||
|
// VT 1's TVDOS.SYS eval is slow; give it room before we start compositing.
|
||||||
|
sys.sleep(800)
|
||||||
|
|
||||||
|
// ─── compositor / dispatcher loop ───────────────────────────────────────────
|
||||||
|
// 30 Hz: blit active pane → GPU text area; honour switch_request; detect
|
||||||
|
// Alt-N with debounce; drain typed chars into active pane's queue.
|
||||||
|
|
||||||
|
const gpuBase = graphics.getGpuMemBase()
|
||||||
|
const TEXTAREA_BASE_ABS = gpuBase - GPU_TEXTAREA_OFFSET
|
||||||
|
function blitVt(srcAddr) {
|
||||||
|
sys.memcpy(srcAddr, TEXTAREA_BASE_ABS, TEXT_PLANE_SIZE - 2)
|
||||||
|
sys.poke(TEXTAREA_BASE_ABS - (TEXT_PLANE_SIZE - 2), sys.peek(srcAddr + TEXT_PLANE_SIZE - 2))
|
||||||
|
sys.poke(TEXTAREA_BASE_ABS - (TEXT_PLANE_SIZE - 1), sys.peek(srcAddr + TEXT_PLANE_SIZE - 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GPU textmode-attribute MMIO byte (offset 6): bit 0 = blinkCursor, bit 1 =
|
||||||
|
// rawMode, bits 4-7 = chrrom. We flip only bit 0 to match the active pane's
|
||||||
|
// cursor visibility. getGpuMemBase() = -1 - 1MB*slot; the peripheral's MMIO
|
||||||
|
// window sits at IOSpace offset 128KB*slot, so MMIO byte k = -1 - (128KB*slot + k).
|
||||||
|
const gpuSlot = (((-gpuBase) - 1) / 1048576) | 0
|
||||||
|
const GPU_MMIO_ATTR = -1 - (131072 * gpuSlot + 6)
|
||||||
|
let lastCursorVis = -1
|
||||||
|
function applyCursorVis(active) {
|
||||||
|
let vis = sys.peek(vtBlockAddr(active) + 2)
|
||||||
|
if (vis === lastCursorVis) return
|
||||||
|
let attr = sys.peek(GPU_MMIO_ATTR)
|
||||||
|
sys.poke(GPU_MMIO_ATTR, vis ? (attr | 1) : (attr & 0xFE))
|
||||||
|
lastCursorVis = vis
|
||||||
|
}
|
||||||
|
|
||||||
|
function queuePush(vtN, byte) {
|
||||||
|
let qBase = vtBlockAddr(vtN)
|
||||||
|
let head = sys.peek(qBase + 8)
|
||||||
|
let tail = sys.peek(qBase + 9)
|
||||||
|
let next = (tail + 1) & 0xFF
|
||||||
|
if (next === head) return false
|
||||||
|
sys.poke(qBase + QUEUE_DATA_OFFSET + tail, byte)
|
||||||
|
sys.poke(qBase + 9, next)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchTo(n) {
|
||||||
|
if (n < 1 || n > MAX_VT) return
|
||||||
|
ensurePane(n)
|
||||||
|
sys.poke(CTRL + CTRL_ACTIVE_VT, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
sys.poke(-39, 1) // enable physical keyboard input collection
|
||||||
|
|
||||||
|
let running = true
|
||||||
|
while (running) {
|
||||||
|
let active = sys.peek(CTRL + CTRL_ACTIVE_VT)
|
||||||
|
if (active < 1 || active > MAX_VT) active = 1
|
||||||
|
blitVt(vtTextPlaneAddr(active))
|
||||||
|
applyCursorVis(active)
|
||||||
|
|
||||||
|
// honour chvt's switch request
|
||||||
|
let req = sys.peek(CTRL + CTRL_SWITCH_REQUEST)
|
||||||
|
if (req >= 1 && req <= MAX_VT) {
|
||||||
|
if (req !== active) {
|
||||||
|
serial.println("[vtmgr] chvt switch -> VT " + req)
|
||||||
|
switchTo(req)
|
||||||
|
}
|
||||||
|
sys.poke(CTRL + CTRL_SWITCH_REQUEST, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alt-N (and Alt-0 = exit) detection
|
||||||
|
sys.poke(-40, 1)
|
||||||
|
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 altHeld = keys.indexOf(57) >= 0 || keys.indexOf(58) >= 0
|
||||||
|
let digit = -1
|
||||||
|
for (let n = 0; n <= MAX_VT; n++) {
|
||||||
|
if (keys.indexOf(7 + n) >= 0) { digit = n; break }
|
||||||
|
}
|
||||||
|
let debounce = sys.peek(CTRL + CTRL_DEBOUNCE_HELD) !== 0
|
||||||
|
|
||||||
|
if (debounce) {
|
||||||
|
if (!altHeld && digit < 0) sys.poke(CTRL + CTRL_DEBOUNCE_HELD, 0)
|
||||||
|
}
|
||||||
|
else if (altHeld && digit === 0) {
|
||||||
|
serial.println("[vtmgr] Alt-0 -> exit")
|
||||||
|
running = false
|
||||||
|
sys.poke(CTRL + CTRL_DEBOUNCE_HELD, 1)
|
||||||
|
sys.poke(-39, 1)
|
||||||
|
}
|
||||||
|
else if (altHeld && digit >= 1) {
|
||||||
|
serial.println("[vtmgr] Alt-" + digit + " -> switching to VT " + digit)
|
||||||
|
switchTo(digit)
|
||||||
|
sys.poke(CTRL + CTRL_DEBOUNCE_HELD, 1)
|
||||||
|
sys.poke(-39, 1) // swallow the digit char so it doesn't leak into the queue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!running) break
|
||||||
|
|
||||||
|
// Re-assert ownership of the cooked keyboard. keyboardInputRequested (-39)
|
||||||
|
// is a single global flag gating whether typed chars reach keyboardBuffer;
|
||||||
|
// the dispatcher relies on it staying 1. An active-pane app that used cooked
|
||||||
|
// host input (sys.read / sys.readKey leave it at 0) or crashed mid-read
|
||||||
|
// leaves it off, and the shimmed con.getch — unlike the base getch, which
|
||||||
|
// calls sys.readKey (→ -39=1) every time — never re-asserts it. That is why
|
||||||
|
// the current VT's keyboard locks up (and why it never happens without
|
||||||
|
// vtmgr). Re-enable ONLY when it is actually off: poke(-39,1) clears
|
||||||
|
// keyboardBuffer, so doing it every frame would drop chars typed last frame.
|
||||||
|
if (sys.peek(-39) === 0) sys.poke(-39, 1)
|
||||||
|
|
||||||
|
// drain typed chars into the active pane's queue
|
||||||
|
while (sys.peek(-50) !== 0) {
|
||||||
|
let k = sys.peek(-38)
|
||||||
|
if (k < 0) k += 256
|
||||||
|
queuePush(active, k)
|
||||||
|
}
|
||||||
|
|
||||||
|
sys.sleep(33)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let n = 1; n <= MAX_VT; n++) if (panes[n]) parallel.kill(panes[n].thread)
|
||||||
|
con.color_pair(254, 255)
|
||||||
|
con.clear()
|
||||||
|
println("vtmgr exited.")
|
||||||
|
return 0
|
||||||
16
assets/disk0/tvdos/bin/color.js.synopsis
Normal file
16
assets/disk0/tvdos/bin/color.js.synopsis
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "color",
|
||||||
|
"summary": "Set the screen background and foreground colours",
|
||||||
|
"symbols": {
|
||||||
|
"code": {
|
||||||
|
"kind": "positional",
|
||||||
|
"type": "string",
|
||||||
|
"name": "BF",
|
||||||
|
"summary": "Two hex digits: background then foreground",
|
||||||
|
"validation": { "pattern": "^[0-9A-Fa-f]{2}$" },
|
||||||
|
"completion": { "method": "none" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"synopsis": { "type": "reference", "symbol": "code" }
|
||||||
|
}
|
||||||
@@ -30,7 +30,18 @@ function makeHash() {
|
|||||||
const shellID = makeHash()
|
const shellID = makeHash()
|
||||||
|
|
||||||
function print_prompt_text() {
|
function print_prompt_text() {
|
||||||
|
// VT pane indicator: shown for VT 2..6, not VT 1 (the default) so the
|
||||||
|
// unmodified prompt is what users see when they never touch virtual
|
||||||
|
// consoles. VT_NUM is set by vtmgr's pane bootstrap.
|
||||||
|
let vtPrefix = ""
|
||||||
|
if (typeof VT_NUM !== "undefined" && VT_NUM > 1) vtPrefix = "[" + VT_NUM + "] "
|
||||||
if (goFancy) {
|
if (goFancy) {
|
||||||
|
if (vtPrefix) {
|
||||||
|
con.color_pair(161,253)
|
||||||
|
print(`\u00DD${VT_NUM}`)
|
||||||
|
con.color_pair(253,161)
|
||||||
|
con.addch(16);con.curs_right()
|
||||||
|
}
|
||||||
con.color_pair(239,161)
|
con.color_pair(239,161)
|
||||||
print(" "+CURRENT_DRIVE+":")
|
print(" "+CURRENT_DRIVE+":")
|
||||||
con.color_pair(161,253)
|
con.color_pair(161,253)
|
||||||
@@ -49,9 +60,9 @@ function print_prompt_text() {
|
|||||||
else {
|
else {
|
||||||
// con.color_pair(253,255)
|
// con.color_pair(253,255)
|
||||||
if (errorlevel != 0 && errorlevel != "undefined" && errorlevel != undefined)
|
if (errorlevel != 0 && errorlevel != "undefined" && errorlevel != undefined)
|
||||||
print(CURRENT_DRIVE + ":\\" + shell_pwd.join("\\") + " [" + errorlevel + "]" + PROMPT_TEXT)
|
print(vtPrefix + CURRENT_DRIVE + ":\\" + shell_pwd.join("\\") + " [" + errorlevel + "]" + PROMPT_TEXT)
|
||||||
else
|
else
|
||||||
print(CURRENT_DRIVE + ":\\" + shell_pwd.join("\\") + PROMPT_TEXT)
|
print(vtPrefix + CURRENT_DRIVE + ":\\" + shell_pwd.join("\\") + PROMPT_TEXT)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,56 +88,31 @@ function printmotd() {
|
|||||||
let motd = motdFile.sread().trim()
|
let motd = motdFile.sread().trim()
|
||||||
let width = con.getmaxyx()[1]
|
let width = con.getmaxyx()[1]
|
||||||
|
|
||||||
|
let ts = require("typesetter")
|
||||||
|
|
||||||
if (goFancy) {
|
if (goFancy) {
|
||||||
let margin = 4
|
let margin = 4
|
||||||
let internalWidth = width - 2*margin
|
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 lines = ts.typeset(motd, textWidth)
|
||||||
|
lines.forEach(line => {
|
||||||
let [cy, cx] = con.getyx()
|
let [cy, _cx] = con.getyx()
|
||||||
|
con.color_pair(255,253) // ribbon edge: white text, transparent back
|
||||||
con.mvaddch(cy, 4, 16);con.curs_right();print(' ')
|
con.mvaddch(cy, margin, 16); con.curs_right()
|
||||||
|
print(' ')
|
||||||
const PCX_INIT = margin - 2
|
con.color_pair(240,253) // body: black text, white back
|
||||||
let tcnt = 0
|
print(line)
|
||||||
let pcx = PCX_INIT
|
con.color_pair(255,253)
|
||||||
con.color_pair(240,253) // black text, white back (first line of text)
|
print(' ')
|
||||||
while (tcnt <= motd.length) {
|
con.addch(17); println()
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
con.reset_graphics()
|
con.reset_graphics()
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
println()
|
println()
|
||||||
println(motd)
|
let lines = ts.typeset(motd, width)
|
||||||
|
lines.forEach(line => println(line))
|
||||||
}
|
}
|
||||||
|
|
||||||
println()
|
println()
|
||||||
@@ -203,6 +189,19 @@ shell.replaceVarCall = function(value) {
|
|||||||
shell.getPwd = function() { return shell_pwd; }
|
shell.getPwd = function() { return shell_pwd; }
|
||||||
shell.getPwdString = function() { return "\\" + (shell_pwd.concat([""])).join("\\"); }
|
shell.getPwdString = function() { return "\\" + (shell_pwd.concat([""])).join("\\"); }
|
||||||
shell.getCurrentDrive = function() { return CURRENT_DRIVE; }
|
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
|
// example input: echo "the string" > subdir\test.txt
|
||||||
shell.parse = function(input) {
|
shell.parse = function(input) {
|
||||||
let tokens = []
|
let tokens = []
|
||||||
@@ -577,8 +576,76 @@ shell.coreutils = {
|
|||||||
ver: function(args) {
|
ver: function(args) {
|
||||||
println(welcome_text)
|
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) {
|
panic: function(args) {
|
||||||
throw Error("Panicking command.js")
|
throw Error("Panicking command.js")
|
||||||
|
},
|
||||||
|
chvt: function(args) {
|
||||||
|
// Request a switch to another virtual console. Only meaningful when
|
||||||
|
// running inside a pane spawned by vtmgr (VT_CTRL_ADDR is set by the
|
||||||
|
// pane bootstrap). Outside that environment this is a no-op error.
|
||||||
|
if (args[1] === undefined) { printerrln("Usage: chvt N (1..6)"); return 1 }
|
||||||
|
let n = parseInt(args[1])
|
||||||
|
if (isNaN(n) || n < 1 || n > 6) { printerrln("chvt: N must be in 1..6"); return 1 }
|
||||||
|
if (typeof VT_CTRL_ADDR === "undefined") {
|
||||||
|
printerrln("chvt: not running under vtmgr (no VT context)"); return 1
|
||||||
|
}
|
||||||
|
// CTRL_SWITCH_REQUEST is byte +1 of the shared CTRL area. Dispatcher
|
||||||
|
// picks this up on its next 30 Hz tick and performs the switch.
|
||||||
|
sys.poke(VT_CTRL_ADDR + 1, n)
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// define command aliases here
|
// define command aliases here
|
||||||
@@ -590,14 +657,19 @@ shell.coreutils.ls = shell.coreutils.dir
|
|||||||
shell.coreutils.time = shell.coreutils.date
|
shell.coreutils.time = shell.coreutils.date
|
||||||
shell.coreutils.md = shell.coreutils.mkdir
|
shell.coreutils.md = shell.coreutils.mkdir
|
||||||
shell.coreutils.move = shell.coreutils.mv
|
shell.coreutils.move = shell.coreutils.mv
|
||||||
|
shell.coreutils.where = shell.coreutils.which
|
||||||
// end of command aliases
|
// end of command aliases
|
||||||
Object.freeze(shell.coreutils)
|
Object.freeze(shell.coreutils)
|
||||||
shell.stdio = {
|
shell.stdio = {
|
||||||
out: {
|
out: {
|
||||||
print: function(s) { sys.print(s) },
|
// When running inside a vtmgr virtual console, __VT_OUT routes output
|
||||||
println: function(s) { if (s === undefined) sys.print("\n"); else sys.print(s+"\n") },
|
// to the pane's text-plane buffer instead of the physical GPU (which
|
||||||
printerr: function(s) { sys.print("\x1B[31m"+s+"\x1B[m") },
|
// the compositor would otherwise overwrite). Outside a VT the hook is
|
||||||
printerrln: function(s) { if (s === undefined) sys.print("\n"); else sys.print("\x1B[31m"+s+"\x1B[m\n") },
|
// absent and these fall through to sys.print exactly as before.
|
||||||
|
print: function(s) { if (globalThis.__VT_OUT) globalThis.__VT_OUT.print(s); else sys.print(s) },
|
||||||
|
println: function(s) { if (globalThis.__VT_OUT) globalThis.__VT_OUT.println(s); else { if (s === undefined) sys.print("\n"); else sys.print(s+"\n") } },
|
||||||
|
printerr: function(s) { if (globalThis.__VT_OUT) globalThis.__VT_OUT.printerr(s); else sys.print("\x1B[31m"+s+"\x1B[m") },
|
||||||
|
printerrln: function(s) { if (globalThis.__VT_OUT) globalThis.__VT_OUT.printerrln(s); else { if (s === undefined) sys.print("\n"); else sys.print("\x1B[31m"+s+"\x1B[m\n") } },
|
||||||
},
|
},
|
||||||
pipe: {
|
pipe: {
|
||||||
print: function(s) { if (shell.getPipe() === undefined) throw Error("No pipe opened"); shell.appendToCurrentPipe(s); },
|
print: function(s) { if (shell.getPipe() === undefined) throw Error("No pipe opened"); shell.appendToCurrentPipe(s); },
|
||||||
@@ -614,13 +686,25 @@ require = function(path) {
|
|||||||
if (path[1] == ":") return shell.require(path)
|
if (path[1] == ":") return shell.require(path)
|
||||||
else {
|
else {
|
||||||
// if the path starts with ".", look for the current directory
|
// 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")
|
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
|
if (0 == line.size) return
|
||||||
let parsedTokens = shell.parse(line) // echo, "hai", |, less
|
let parsedTokens = shell.parse(line) // echo, "hai", |, less
|
||||||
let statements = [] // [[echo, "hai"], [less]]
|
let statements = [] // [[echo, "hai"], [less]]
|
||||||
@@ -746,6 +830,8 @@ shell.execute = function(line) {
|
|||||||
let programCode = searchFile.sread()
|
let programCode = searchFile.sread()
|
||||||
let extension = searchFile.extension.toUpperCase()
|
let extension = searchFile.extension.toUpperCase()
|
||||||
|
|
||||||
|
shell.runningScriptPaths.push(searchFile.fullPath)
|
||||||
|
try {
|
||||||
if ("BAT" == extension) {
|
if ("BAT" == extension) {
|
||||||
// parse and run as batch file
|
// 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!
|
var lines = programCode.split('\n').filter(function(it) { return it.length > 0 }) // this return is not shell's return!
|
||||||
@@ -757,19 +843,28 @@ shell.execute = function(line) {
|
|||||||
// parse alias
|
// parse alias
|
||||||
// $0: all arguments
|
// $0: all arguments
|
||||||
// $1..9: specific 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!
|
var lines = programCode.split('\n').filter(function(it) { return it.length > 0 }) // this return is not shell's return!
|
||||||
lines.forEach(function(line) {
|
lines.forEach(function(line) {
|
||||||
var newLine = line
|
var newLine = line
|
||||||
|
|
||||||
// replace $1..$9
|
// replace $1..$9
|
||||||
for (let j = 1; j < 9; j++) {
|
for (let j = 1; j <= 9; j++) {
|
||||||
newLine = newLine.replaceAll('$'+j, tokens[j])
|
newLine = newLine.replaceAll('$'+j, quoteAliasArg(tokens[j]))
|
||||||
}
|
}
|
||||||
|
|
||||||
// replace $0
|
// replace $0
|
||||||
newLine = newLine.replaceAll('$0', tokens.slice(1).join(' '))
|
newLine = newLine.replaceAll('$0', tokens.slice(1).map(quoteAliasArg).join(' '))
|
||||||
|
|
||||||
shell.execute(newLine)
|
shell.execute(newLine, cmd)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
else if ("APP" == extension) {
|
else if ("APP" == extension) {
|
||||||
@@ -786,6 +881,10 @@ shell.execute = function(line) {
|
|||||||
errorlevel = 0 // reset the number
|
errorlevel = 0 // reset the number
|
||||||
|
|
||||||
if (_G.shellProgramTitles === undefined) _G.shellProgramTitles = []
|
if (_G.shellProgramTitles === undefined) _G.shellProgramTitles = []
|
||||||
|
if (nameOverride !== undefined) {
|
||||||
|
tokens[0] = (''+nameOverride)
|
||||||
|
cmd = tokens[0]
|
||||||
|
}
|
||||||
_G.shellProgramTitles.push(cmd.toUpperCase())
|
_G.shellProgramTitles.push(cmd.toUpperCase())
|
||||||
sendLcdMsg(_G.shellProgramTitles[_G.shellProgramTitles.length - 1])
|
sendLcdMsg(_G.shellProgramTitles[_G.shellProgramTitles.length - 1])
|
||||||
//serial.println(_G.shellProgramTitles)
|
//serial.println(_G.shellProgramTitles)
|
||||||
@@ -825,6 +924,9 @@ shell.execute = function(line) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
shell.runningScriptPaths.pop()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -884,6 +986,246 @@ Object.freeze(shell)
|
|||||||
_G.shell = shell
|
_G.shell = shell
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// TAB AUTOCOMPLETION
|
||||||
|
//
|
||||||
|
// Invoked by TAB at the interactive prompt. Only active when BOTH:
|
||||||
|
// 1. wintex.mjs is available (provides the selection popup), AND
|
||||||
|
// 2. goFancy == true.
|
||||||
|
// One candidate -> expand immediately (no popup).
|
||||||
|
// Many candidates -> wintex popup; user scrolls and selects, or Esc/Cancel to
|
||||||
|
// discard. The popup over-draws the screen without saving
|
||||||
|
// what was beneath it, so we snapshot the text plane before
|
||||||
|
// and copy it back after (the shell can't just redraw like a
|
||||||
|
// full-screen TUI — there's scrollback above the prompt).
|
||||||
|
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// Lazily-resolved wintex module. undefined = not probed yet, null = unavailable.
|
||||||
|
let _acWin = undefined
|
||||||
|
function getAutocompleteWin() {
|
||||||
|
if (_acWin !== undefined) return _acWin
|
||||||
|
_acWin = null
|
||||||
|
try {
|
||||||
|
let w = require("wintex") // resolved through INCLPATH (\tvdos\include\wintex.mjs)
|
||||||
|
if (w && typeof w.showDialog === "function") _acWin = w
|
||||||
|
} catch (e) {
|
||||||
|
debugprintln("command.js > autocomplete: wintex unavailable: " + e)
|
||||||
|
}
|
||||||
|
return _acWin
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazily-resolved synopsis module (TSF loader/completion resolver). Held for
|
||||||
|
// the whole session so its in-memory cache survives across keystrokes.
|
||||||
|
// undefined = not probed yet, null = unavailable.
|
||||||
|
let _acSyn = undefined
|
||||||
|
function getSynopsisMod() {
|
||||||
|
if (_acSyn !== undefined) return _acSyn
|
||||||
|
_acSyn = null
|
||||||
|
try {
|
||||||
|
let m = require("synopsis") // resolved through INCLPATH (\tvdos\include\synopsis.mjs)
|
||||||
|
if (m && typeof m.getCompletion === "function") _acSyn = m
|
||||||
|
} catch (e) {
|
||||||
|
debugprintln("command.js > autocomplete: synopsis unavailable: " + e)
|
||||||
|
}
|
||||||
|
return _acSyn
|
||||||
|
}
|
||||||
|
|
||||||
|
// List a directory's entries, swallowing any IO error.
|
||||||
|
function _acListDir(fullPath) {
|
||||||
|
try {
|
||||||
|
let f = files.open(fullPath)
|
||||||
|
if (!f.exists || !f.isDirectory) return []
|
||||||
|
return f.list() || []
|
||||||
|
} catch (e) { return [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip a trailing PATHEXT extension so command names show without ".js" etc.
|
||||||
|
function _acStripExt(name) {
|
||||||
|
let lower = name.toLowerCase()
|
||||||
|
let exts = (_TVDOS.variables.PATHEXT || "").split(';').filter(function(e){ return e.length > 0 })
|
||||||
|
for (let i = 0; i < exts.length; i++) {
|
||||||
|
let e = exts[i].toLowerCase()
|
||||||
|
if (lower.endsWith(e)) return name.substring(0, name.length - e.length)
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Candidates for the command position (first word, no path separators):
|
||||||
|
// shell built-ins + runnable files found along the current dir, drive root and PATH.
|
||||||
|
function _acCommandCandidates(prefix) {
|
||||||
|
let lower = prefix.toLowerCase()
|
||||||
|
let seen = {}
|
||||||
|
let out = []
|
||||||
|
function add(name) {
|
||||||
|
let k = name.toLowerCase()
|
||||||
|
if (seen[k]) return
|
||||||
|
seen[k] = true
|
||||||
|
out.push({ label: name, value: name + ' ', isDir: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
// shell built-ins (and their aliases)
|
||||||
|
Object.keys(shell.coreutils).forEach(function(k) {
|
||||||
|
if (k.toLowerCase().startsWith(lower)) add(k)
|
||||||
|
})
|
||||||
|
|
||||||
|
// runnable files: search the same places shell.execute does, in the same order
|
||||||
|
let exts = (_TVDOS.variables.PATHEXT || "").split(';')
|
||||||
|
.filter(function(e){ return e.length > 0 }).map(function(e){ return e.toLowerCase() })
|
||||||
|
let dirFulls = [shell.resolvePathInput('.').full] // current directory first
|
||||||
|
_TVDOS.getPath().forEach(function(d) {
|
||||||
|
dirFulls.push((d === '' || d === undefined) ? `${CURRENT_DRIVE}:\\` : shell.resolvePathInput(d).full)
|
||||||
|
})
|
||||||
|
dirFulls.forEach(function(full) {
|
||||||
|
_acListDir(full).forEach(function(it) {
|
||||||
|
if (it.isDirectory) return
|
||||||
|
let nameLower = (it.name || '').toLowerCase()
|
||||||
|
if (!exts.some(function(e){ return nameLower.endsWith(e) })) return // only runnables
|
||||||
|
let stripped = _acStripExt(it.name)
|
||||||
|
if (stripped.toLowerCase().startsWith(lower)) add(stripped)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Candidates for a path argument. The word may carry a directory prefix
|
||||||
|
// (kept verbatim) and a partial basename that we match against the directory.
|
||||||
|
function _acPathCandidates(word) {
|
||||||
|
let sepIdx = Math.max(word.lastIndexOf('\\'), word.lastIndexOf('/'))
|
||||||
|
let dirPart, basePart, listArg
|
||||||
|
if (sepIdx >= 0) {
|
||||||
|
dirPart = word.substring(0, sepIdx + 1) // includes the trailing separator
|
||||||
|
basePart = word.substring(sepIdx + 1)
|
||||||
|
listArg = dirPart
|
||||||
|
} else {
|
||||||
|
dirPart = ''
|
||||||
|
basePart = word
|
||||||
|
listArg = '.'
|
||||||
|
}
|
||||||
|
let resolved = shell.resolvePathInput(listArg)
|
||||||
|
if (resolved === undefined) return []
|
||||||
|
let sep = (dirPart.length > 0 && dirPart.charAt(dirPart.length - 1) === '/') ? '/' : '\\'
|
||||||
|
let lower = basePart.toLowerCase()
|
||||||
|
let out = []
|
||||||
|
_acListDir(resolved.full).forEach(function(it) {
|
||||||
|
let name = it.name || ''
|
||||||
|
if (!name.toLowerCase().startsWith(lower)) return
|
||||||
|
out.push({
|
||||||
|
// directories get a trailing separator so completion can continue into them;
|
||||||
|
// files get a trailing space so the next argument can be typed straight away.
|
||||||
|
label: name + (it.isDirectory ? '\\' : ''),
|
||||||
|
value: dirPart + name + (it.isDirectory ? sep : ' '),
|
||||||
|
isDir: it.isDirectory
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Candidates for an argument (not the command word). Consults the command's
|
||||||
|
// TSF synopsis (via synopsis.mjs) for option flags, enum/list values and
|
||||||
|
// subcommand names, and merges in filesystem entries when the synopsis says the
|
||||||
|
// slot expects a path/file/directory. Falls back to plain path completion when
|
||||||
|
// no synopsis exists, so behaviour is unchanged for commands without one.
|
||||||
|
function _acArgCandidates(prefix, word) {
|
||||||
|
let syn = getSynopsisMod()
|
||||||
|
if (syn) {
|
||||||
|
try {
|
||||||
|
let toks = prefix.trim().split(/\s+/)
|
||||||
|
let cmd = toks[0]
|
||||||
|
let argToks = toks.slice(1)
|
||||||
|
let r = syn.getCompletion(cmd, argToks, word)
|
||||||
|
if (r && r.ok) {
|
||||||
|
let out = (r.candidates || []).slice()
|
||||||
|
if (r.filesystem) {
|
||||||
|
_acPathCandidates(word).forEach(function(c) {
|
||||||
|
if (r.filesystem === 'directory' && !c.isDir) return // dirs only
|
||||||
|
out.push(c)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// de-dupe by the text that would be inserted
|
||||||
|
let seen = {}, dedup = []
|
||||||
|
out.forEach(function(c) { if (seen[c.value]) return; seen[c.value] = true; dedup.push(c) })
|
||||||
|
return dedup
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugprintln("command.js > _acArgCandidates: " + e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _acPathCandidates(word)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Work out what is being completed at `caret` within `line`.
|
||||||
|
// Returns { wordStart, word, candidates } (candidates sorted by label).
|
||||||
|
function computeCompletion(line, caret) {
|
||||||
|
let wordStart = caret
|
||||||
|
while (wordStart > 0 && line.charAt(wordStart - 1) !== ' ') wordStart -= 1
|
||||||
|
let word = line.substring(wordStart, caret)
|
||||||
|
let prefix = line.substring(0, wordStart)
|
||||||
|
let isFirstWord = (prefix.trim().length === 0)
|
||||||
|
let hasPathSep = (word.indexOf('\\') >= 0 || word.indexOf('/') >= 0 || word.indexOf(':') >= 0)
|
||||||
|
let candidates
|
||||||
|
if (isFirstWord)
|
||||||
|
candidates = hasPathSep ? _acPathCandidates(word) : _acCommandCandidates(word)
|
||||||
|
else
|
||||||
|
candidates = _acArgCandidates(prefix, word)
|
||||||
|
candidates.sort(function(a, b) { return (a.label < b.label) ? -1 : (a.label > b.label) ? 1 : 0 })
|
||||||
|
return { wordStart: wordStart, word: word, candidates: candidates }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- text-plane snapshot/restore (so the popup leaves no artefacts) ---------
|
||||||
|
// In a vtmgr pane the shimmed con/print draw into the pane buffer
|
||||||
|
// (globalThis.VT_TEXT_PLANE, forward layout); on the physical console they
|
||||||
|
// draw into the GPU text area (mapped at getGpuMemBase()-253950). vaddr(0) is
|
||||||
|
// that base in either case; sys.memcpy reads/writes it forward-native.
|
||||||
|
// NOTE: 7681, not the full 7682-byte text area: relPtrInDev() bounds-checks
|
||||||
|
// `from+len` inclusively, so the final byte (bottom-right char cell, never
|
||||||
|
// touched by a centred popup) is unreachable by a single memcpy.
|
||||||
|
const _AC_TEXTAREA_BYTES = 7681
|
||||||
|
let _acTextBase = null
|
||||||
|
let _acScratchPtr = 0
|
||||||
|
function _acTextAreaBase() {
|
||||||
|
if (_acTextBase === null) {
|
||||||
|
_acTextBase = (typeof globalThis.VT_TEXT_PLANE !== 'undefined')
|
||||||
|
? globalThis.VT_TEXT_PLANE
|
||||||
|
: (graphics.getGpuMemBase() - 253950)
|
||||||
|
}
|
||||||
|
return _acTextBase
|
||||||
|
}
|
||||||
|
function _acSnapshotScreen() {
|
||||||
|
if (_acScratchPtr === 0) _acScratchPtr = sys.malloc(_AC_TEXTAREA_BYTES)
|
||||||
|
sys.memcpy(_acTextAreaBase(), _acScratchPtr, _AC_TEXTAREA_BYTES)
|
||||||
|
}
|
||||||
|
function _acRestoreScreen() {
|
||||||
|
if (_acScratchPtr === 0) return
|
||||||
|
sys.memcpy(_acScratchPtr, _acTextAreaBase(), _AC_TEXTAREA_BYTES)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal popup of candidates. Returns the chosen item, or null if discarded.
|
||||||
|
function _acShowPopup(win, candidates) {
|
||||||
|
let res = win.showDialog({
|
||||||
|
title: `Complete (${candidates.length})`,
|
||||||
|
list: {
|
||||||
|
items: candidates,
|
||||||
|
height: Math.min(12, candidates.length),
|
||||||
|
onActivate: function(item, idx, key) { return 'select' }
|
||||||
|
},
|
||||||
|
buttons: [{ label: 'Cancel', action: 'cancel' }]
|
||||||
|
})
|
||||||
|
if (res && res.action === 'select' && res.listItem) return res.listItem
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// 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) {
|
if (exec_args[1] !== undefined) {
|
||||||
// only meaningful switches would be either -c or -k anyway
|
// only meaningful switches would be either -c or -k anyway
|
||||||
@@ -928,23 +1270,133 @@ if (goInteractive) {
|
|||||||
print_prompt_text()
|
print_prompt_text()
|
||||||
|
|
||||||
var cmdbuf = ""
|
var cmdbuf = ""
|
||||||
|
var caret = 0 // insertion point within cmdbuf, 0..cmdbuf.length
|
||||||
|
|
||||||
|
// Self-contained line editor with a movable caret (so command.js does
|
||||||
|
// NOT depend on wintex being installed). The prompt has just been
|
||||||
|
// printed, so the current cursor marks where the editable text begins.
|
||||||
|
// We track that anchor and rebuild the on-screen line from it, decoding
|
||||||
|
// line-wrap ourselves so the maths holds in both the physical console
|
||||||
|
// and a vtmgr pane (whose con.move CLAMPS x instead of wrapping it).
|
||||||
|
let [baseY, baseX] = con.getyx() // 1-based
|
||||||
|
let termCols = con.getmaxyx()[1]
|
||||||
|
|
||||||
|
// absolute (y,x) on screen for caret index `idx`
|
||||||
|
function caretPos(idx) {
|
||||||
|
let abs = (baseX - 1) + idx
|
||||||
|
return [baseY + ((abs / termCols) | 0), (abs % termCols) + 1]
|
||||||
|
}
|
||||||
|
function gotoCaret() {
|
||||||
|
let [cy, cx] = caretPos(caret)
|
||||||
|
con.move(cy, cx)
|
||||||
|
}
|
||||||
|
// reprint cmdbuf from index `from` to the end, optionally padding with
|
||||||
|
// `clearTrail` blanks to wipe characters left over by a now-shorter
|
||||||
|
// line, then park the hardware cursor back on the caret.
|
||||||
|
function refresh(from, clearTrail) {
|
||||||
|
let [py, px] = caretPos(from)
|
||||||
|
con.move(py, px)
|
||||||
|
print(cmdbuf.substring(from))
|
||||||
|
for (let i = 0; i < clearTrail; i++) print(" ")
|
||||||
|
gotoCaret()
|
||||||
|
}
|
||||||
|
// replace the whole buffer (used by history recall)
|
||||||
|
function setBuf(next) {
|
||||||
|
let oldLen = cmdbuf.length
|
||||||
|
cmdbuf = next
|
||||||
|
caret = cmdbuf.length
|
||||||
|
refresh(0, Math.max(0, oldLen - cmdbuf.length))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the word [wordStart, caret) with `value`, keeping any text to
|
||||||
|
// the right of the caret, then reprint the line from `wordStart`.
|
||||||
|
function applyCompletion(wordStart, value) {
|
||||||
|
let oldLen = cmdbuf.length
|
||||||
|
cmdbuf = cmdbuf.substring(0, wordStart) + value + cmdbuf.substring(caret)
|
||||||
|
caret = wordStart + value.length
|
||||||
|
con.color_pair(shell.usrcfg.textCol, 255)
|
||||||
|
refresh(wordStart, Math.max(0, oldLen - cmdbuf.length))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TAB handler. No-op unless fancy mode is on and wintex is installed.
|
||||||
|
function tryAutocomplete() {
|
||||||
|
if (!goFancy) return
|
||||||
|
let win = getAutocompleteWin()
|
||||||
|
if (!win) return
|
||||||
|
|
||||||
|
let comp = computeCompletion(cmdbuf, caret)
|
||||||
|
let cands = comp.candidates
|
||||||
|
if (cands.length === 0) return
|
||||||
|
if (cands.length === 1) { applyCompletion(comp.wordStart, cands[0].value); return }
|
||||||
|
|
||||||
|
_acSnapshotScreen()
|
||||||
|
let chosen = _acShowPopup(win, cands)
|
||||||
|
_acRestoreScreen()
|
||||||
|
|
||||||
|
// The popup drives input through input.withEvent (physical held-key
|
||||||
|
// state), which bypasses the buffer con.getch reads. Inside a vtmgr
|
||||||
|
// pane the dispatcher keeps draining physical keystrokes into this
|
||||||
|
// pane's input ring the whole time the popup is open, so the navigation
|
||||||
|
// keys (and the closing Enter) would otherwise surface as phantom input
|
||||||
|
// afterwards. Flush them. (On the physical console readKey self-clears,
|
||||||
|
// so this is harmless there.)
|
||||||
|
con.resetkeybuf()
|
||||||
|
|
||||||
|
// The popup hid the caret and clobbered colours; restore the prompt
|
||||||
|
// editing state. The screen content is already back from the snapshot.
|
||||||
|
con.curs_set(1)
|
||||||
|
con.color_pair(shell.usrcfg.textCol, 255)
|
||||||
|
gotoCaret()
|
||||||
|
|
||||||
|
if (chosen) applyCompletion(comp.wordStart, chosen.value)
|
||||||
|
}
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
let key = con.getch()
|
let key = con.getch()
|
||||||
|
|
||||||
// printable chars
|
// printable chars
|
||||||
if (key >= 32 && key <= 126) {
|
if (key >= 32 && key <= 126) {
|
||||||
var s = String.fromCharCode(key)
|
let s = String.fromCharCode(key)
|
||||||
cmdbuf += s
|
let atEnd = (caret === cmdbuf.length)
|
||||||
print(s)
|
cmdbuf = cmdbuf.substring(0, caret) + s + cmdbuf.substring(caret)
|
||||||
|
caret += 1
|
||||||
|
if (atEnd) print(s) // fast path: simple append
|
||||||
|
else refresh(caret - 1, 0)
|
||||||
}
|
}
|
||||||
// backspace
|
// TAB: autocomplete (fancy mode + wintex only; otherwise a no-op)
|
||||||
else if (key === con.KEY_BACKSPACE && cmdbuf.length > 0) {
|
else if (key === con.KEY_TAB) {
|
||||||
cmdbuf = cmdbuf.substring(0, cmdbuf.length - 1)
|
tryAutocomplete()
|
||||||
print(String.fromCharCode(key))
|
}
|
||||||
|
// backspace: delete the char to the left of the caret
|
||||||
|
else if (key === con.KEY_BACKSPACE && caret > 0) {
|
||||||
|
cmdbuf = cmdbuf.substring(0, caret - 1) + cmdbuf.substring(caret)
|
||||||
|
caret -= 1
|
||||||
|
refresh(caret, 1)
|
||||||
|
}
|
||||||
|
// forward delete: delete the char under the caret
|
||||||
|
else if (key === con.KEY_DELETE && caret < cmdbuf.length) {
|
||||||
|
cmdbuf = cmdbuf.substring(0, caret) + cmdbuf.substring(caret + 1)
|
||||||
|
refresh(caret, 1)
|
||||||
|
}
|
||||||
|
// caret left
|
||||||
|
else if (key === con.KEY_LEFT) {
|
||||||
|
if (caret > 0) { caret -= 1; gotoCaret() }
|
||||||
|
}
|
||||||
|
// caret right
|
||||||
|
else if (key === con.KEY_RIGHT) {
|
||||||
|
if (caret < cmdbuf.length) { caret += 1; gotoCaret() }
|
||||||
|
}
|
||||||
|
// jump to start of line
|
||||||
|
else if (key === con.KEY_HOME) {
|
||||||
|
caret = 0; gotoCaret()
|
||||||
|
}
|
||||||
|
// jump to end of line
|
||||||
|
else if (key === con.KEY_END) {
|
||||||
|
caret = cmdbuf.length; gotoCaret()
|
||||||
}
|
}
|
||||||
// enter
|
// enter
|
||||||
else if (key === 10 || key === con.KEY_RETURN) {
|
else if (key === 10 || key === con.KEY_RETURN) {
|
||||||
|
caret = cmdbuf.length; gotoCaret()
|
||||||
println()
|
println()
|
||||||
|
|
||||||
errorlevel = shell.execute(cmdbuf)
|
errorlevel = shell.execute(cmdbuf)
|
||||||
@@ -960,32 +1412,17 @@ if (goInteractive) {
|
|||||||
// up arrow
|
// up arrow
|
||||||
else if (key === con.KEY_UP && cmdHistory.length > 0 && cmdHistoryScroll < cmdHistory.length) {
|
else if (key === con.KEY_UP && cmdHistory.length > 0 && cmdHistoryScroll < cmdHistory.length) {
|
||||||
cmdHistoryScroll += 1
|
cmdHistoryScroll += 1
|
||||||
|
setBuf(cmdHistory[cmdHistory.length - cmdHistoryScroll])
|
||||||
// back the cursor in order to type new cmd
|
|
||||||
var x = 0
|
|
||||||
for (x = 0; x < cmdbuf.length; x++) print(String.fromCharCode(8))
|
|
||||||
cmdbuf = cmdHistory[cmdHistory.length - cmdHistoryScroll]
|
|
||||||
// re-type the new command
|
|
||||||
print(cmdbuf)
|
|
||||||
|
|
||||||
}
|
}
|
||||||
// down arrow
|
// down arrow
|
||||||
else if (key === con.KEY_DOWN) {
|
else if (key === con.KEY_DOWN) {
|
||||||
if (cmdHistoryScroll > 0) {
|
if (cmdHistoryScroll > 1) {
|
||||||
// back the cursor in order to type new cmd
|
|
||||||
var x = 0
|
|
||||||
for (x = 0; x < cmdbuf.length; x++) print(String.fromCharCode(8))
|
|
||||||
cmdbuf = cmdHistory[cmdHistory.length - cmdHistoryScroll]
|
|
||||||
// re-type the new command
|
|
||||||
print(cmdbuf)
|
|
||||||
|
|
||||||
cmdHistoryScroll -= 1
|
cmdHistoryScroll -= 1
|
||||||
|
setBuf(cmdHistory[cmdHistory.length - cmdHistoryScroll])
|
||||||
}
|
}
|
||||||
else {
|
else if (cmdHistoryScroll === 1) {
|
||||||
// back the cursor in order to type new cmd
|
cmdHistoryScroll = 0
|
||||||
var x = 0
|
setBuf("")
|
||||||
for (x = 0; x < cmdbuf.length; x++) print(String.fromCharCode(8))
|
|
||||||
cmdbuf = ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
7
assets/disk0/tvdos/bin/drives.js.synopsis
Normal file
7
assets/disk0/tvdos/bin/drives.js.synopsis
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "drives",
|
||||||
|
"summary": "List connected and mounted disk drives",
|
||||||
|
"symbols": {},
|
||||||
|
"synopsis": { "type": "sequence", "children": [] }
|
||||||
|
}
|
||||||
12
assets/disk0/tvdos/bin/edit.js.synopsis
Normal file
12
assets/disk0/tvdos/bin/edit.js.synopsis
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "edit",
|
||||||
|
"summary": "Full-screen text editor",
|
||||||
|
"symbols": {
|
||||||
|
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "File to edit; a new buffer when omitted" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "optional",
|
||||||
|
"child": { "type": "reference", "symbol": "file" }
|
||||||
|
}
|
||||||
|
}
|
||||||
9
assets/disk0/tvdos/bin/geturl.js.synopsis
Normal file
9
assets/disk0/tvdos/bin/geturl.js.synopsis
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "geturl",
|
||||||
|
"summary": "Fetch a URL and print the response",
|
||||||
|
"symbols": {
|
||||||
|
"url": { "kind": "positional", "type": "url", "name": "URL", "summary": "Address to fetch" }
|
||||||
|
},
|
||||||
|
"synopsis": { "type": "reference", "symbol": "url" }
|
||||||
|
}
|
||||||
18
assets/disk0/tvdos/bin/gzip.js.synopsis
Normal file
18
assets/disk0/tvdos/bin/gzip.js.synopsis
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "gzip",
|
||||||
|
"summary": "Compress or decompress a file (Zstd-backed)",
|
||||||
|
"symbols": {
|
||||||
|
"decompress": { "kind": "option", "short": "-d", "summary": "Decompress instead of compress" },
|
||||||
|
"stdout": { "kind": "option", "short": "-c", "summary": "Write to the pipe instead of a file" },
|
||||||
|
"options": { "kind": "group", "summary": "Options", "members": ["decompress", "stdout"] },
|
||||||
|
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "File to process" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "sequence",
|
||||||
|
"children": [
|
||||||
|
{ "type": "repeat", "child": { "type": "reference", "symbol": "options" } },
|
||||||
|
{ "type": "reference", "symbol": "file" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
1
assets/disk0/tvdos/bin/help.alias
Normal file
1
assets/disk0/tvdos/bin/help.alias
Normal file
@@ -0,0 +1 @@
|
|||||||
|
synopsis $0
|
||||||
12
assets/disk0/tvdos/bin/hexdump.js.synopsis
Normal file
12
assets/disk0/tvdos/bin/hexdump.js.synopsis
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "hexdump",
|
||||||
|
"summary": "Print a file as a hexadecimal dump",
|
||||||
|
"symbols": {
|
||||||
|
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "File to dump; reads from the pipe when omitted" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "optional",
|
||||||
|
"child": { "type": "reference", "symbol": "file" }
|
||||||
|
}
|
||||||
|
}
|
||||||
1
assets/disk0/tvdos/bin/hop.alias
Normal file
1
assets/disk0/tvdos/bin/hop.alias
Normal file
@@ -0,0 +1 @@
|
|||||||
|
hopper $0
|
||||||
File diff suppressed because it is too large
Load Diff
81
assets/disk0/tvdos/bin/hopper.js.synopsis
Normal file
81
assets/disk0/tvdos/bin/hopper.js.synopsis
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "hopper",
|
||||||
|
"summary": "Package manager for TVDOS",
|
||||||
|
"description": "Hopper resolves package dependencies across the installed set (system packages shipped with TVDOS plus user packages under A:/hopper) and any remote mirrors listed in A:/tvdos/hopper/mirrors.list, then installs, upgrades, downgrades or removes user packages. System packages are read-only: install and remove refuse to touch them. Versions are strict SemVer (MAJOR.MINOR.PATCH); constraints support *, X.*, X.Y.*, exact, ^, ~ and >=/>/<=/< operators, comma-separated for AND.",
|
||||||
|
"symbols": {
|
||||||
|
"search": { "kind": "subcommand", "name": "search", "summary": "Search installed packages and remote mirrors (alias: se)" },
|
||||||
|
"install": { "kind": "subcommand", "name": "install", "summary": "Resolve dependencies and install a package (alias: in)" },
|
||||||
|
"upgrade": { "kind": "subcommand", "name": "upgrade", "summary": "Upgrade packages to the latest available version; all user packages when none named (alias: up)" },
|
||||||
|
"remove": { "kind": "subcommand", "name": "remove", "summary": "Remove a user-installed package (alias: rm)" },
|
||||||
|
|
||||||
|
"provides": { "kind": "option", "long": "--provides", "summary": "Match against the HopperProvides field instead of the name" },
|
||||||
|
"requires": { "kind": "option", "long": "--requires", "summary": "Match against the HopperRequires field instead of the name" },
|
||||||
|
"description": { "kind": "option", "long": "--description", "summary": "Match against the package description instead of the name" },
|
||||||
|
"author": { "kind": "option", "long": "--author", "summary": "Match against the package author instead of the name" },
|
||||||
|
"searchFields": {
|
||||||
|
"kind": "group",
|
||||||
|
"summary": "Search-field selectors",
|
||||||
|
"members": ["provides", "requires", "description", "author"]
|
||||||
|
},
|
||||||
|
|
||||||
|
"version": {
|
||||||
|
"kind": "option",
|
||||||
|
"short": "-v",
|
||||||
|
"summary": "Install a specific package version or range",
|
||||||
|
"value": {
|
||||||
|
"name": "VERSION",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"summary": "Package version as shown by search, or a constraint, e.g. 1.2.0, ^1.2.0, ~1.2, 2.*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"query": { "kind": "positional", "type": "string", "name": "QUERY", "summary": "Substring matched against the package name (or the selected field)" },
|
||||||
|
"pkgInstall": { "kind": "positional", "type": "string", "name": "PACKAGE", "summary": "Name of the package (or virtual capability) to install" },
|
||||||
|
"pkgUpgrade": { "kind": "positional", "type": "string", "name": "PACKAGE", "summary": "Package(s) to upgrade; upgrades every user package when omitted" },
|
||||||
|
"pkgRemove": { "kind": "positional", "type": "string", "name": "PACKAGE", "summary": "Name of the user-installed package to remove" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "choice",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "sequence",
|
||||||
|
"children": [
|
||||||
|
{ "type": "reference", "symbol": "search" },
|
||||||
|
{ "type": "repeat", "child": { "type": "reference", "symbol": "searchFields" } },
|
||||||
|
{ "type": "reference", "symbol": "query" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "sequence",
|
||||||
|
"children": [
|
||||||
|
{ "type": "reference", "symbol": "install" },
|
||||||
|
{ "type": "reference", "symbol": "pkgInstall" },
|
||||||
|
{ "type": "optional", "child": { "type": "reference", "symbol": "version" } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "sequence",
|
||||||
|
"children": [
|
||||||
|
{ "type": "reference", "symbol": "upgrade" },
|
||||||
|
{ "type": "repeat", "child": { "type": "reference", "symbol": "pkgUpgrade" } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "sequence",
|
||||||
|
"children": [
|
||||||
|
{ "type": "reference", "symbol": "remove" },
|
||||||
|
{ "type": "reference", "symbol": "pkgRemove" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"constraints": [
|
||||||
|
{
|
||||||
|
"type": "cardinality",
|
||||||
|
"symbols": ["provides", "requires", "description", "author"],
|
||||||
|
"maximum": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
12
assets/disk0/tvdos/bin/less.js.synopsis
Normal file
12
assets/disk0/tvdos/bin/less.js.synopsis
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "less",
|
||||||
|
"summary": "View text a screen at a time",
|
||||||
|
"symbols": {
|
||||||
|
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "File to view; reads from the pipe when omitted" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "optional",
|
||||||
|
"child": { "type": "reference", "symbol": "file" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,10 @@ Uint16 Encoding
|
|||||||
10 00 : UTF-8
|
10 00 : UTF-8
|
||||||
10 01 : UTF-16BE
|
10 01 : UTF-16BE
|
||||||
10 02 : UTF-16LE
|
10 02 : UTF-16LE
|
||||||
Byte[5] Padding
|
Byte Flags
|
||||||
|
0b 0000 000r
|
||||||
|
r: path is relative
|
||||||
|
Bytes[4] Reserved
|
||||||
|
|
||||||
# FileBlocks
|
# FileBlocks
|
||||||
Uint8 File type (only 1 is used)
|
Uint8 File type (only 1 is used)
|
||||||
@@ -28,27 +31,36 @@ instead of compressing individual files)
|
|||||||
|
|
||||||
function printUsage() {
|
function printUsage() {
|
||||||
println(`Collects files under a directory into a single archive.
|
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:
|
To collect a directory into myarchive.lfs:
|
||||||
lfs -c myarchive.lfs path\\to\\directory
|
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:
|
To extract an archive to path\\to\\my\\files:
|
||||||
lfs -x myarchive.lfs path\\to\\my\\files
|
lfs -x myarchive.lfs path\\to\\my\\files
|
||||||
To list the collected files:
|
To list the collected files:
|
||||||
lfs -t myarchive.lfs`)
|
lfs -t myarchive.lfs`)
|
||||||
}
|
}
|
||||||
|
|
||||||
let option = exec_args[1]
|
let option = undefined
|
||||||
const lfsPath = exec_args[2]
|
let useRelative = false
|
||||||
const dirPath = exec_args[3]
|
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 != "-T" && dirPath === undefined)) {
|
||||||
if (option === undefined || lfsPath === undefined || option.toUpperCase() != "-T" && dirPath === undefined) {
|
|
||||||
printUsage()
|
printUsage()
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
option = option.toUpperCase()
|
|
||||||
|
|
||||||
|
|
||||||
function recurseDir(file, action) {
|
function recurseDir(file, action) {
|
||||||
if (!file.isDirectory) {
|
if (!file.isDirectory) {
|
||||||
@@ -76,13 +88,14 @@ if ("-C" == option) {
|
|||||||
return 1
|
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
|
const rootDirPathLen = rootDir.fullPath.length
|
||||||
|
|
||||||
recurseDir(rootDir, file=>{
|
recurseDir(rootDir, file=>{
|
||||||
let f = files.open(file.fullPath)
|
let f = files.open(file.fullPath)
|
||||||
let flen = f.size
|
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
|
let plen = fname.length
|
||||||
|
|
||||||
out += "\x01" + String.fromCharCode(
|
out += "\x01" + String.fromCharCode(
|
||||||
@@ -116,6 +129,8 @@ else if ("-T" == option || "-X" == option) {
|
|||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const archiveRelative = (bytes.charCodeAt(11) & 0x01) !== 0
|
||||||
|
|
||||||
if ("-X" == option && !rootDir.exists) {
|
if ("-X" == option && !rootDir.exists) {
|
||||||
rootDir.mkDir()
|
rootDir.mkDir()
|
||||||
}
|
}
|
||||||
@@ -132,9 +147,12 @@ else if ("-T" == option || "-X" == option) {
|
|||||||
|
|
||||||
if ("-X" == option) {
|
if ("-X" == option) {
|
||||||
let filebytes = bytes.substring(curs, curs + filelen)
|
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.mkFile()
|
||||||
outfile.swrite(filebytes)
|
outfile.swrite(filebytes)
|
||||||
}
|
}
|
||||||
|
|||||||
34
assets/disk0/tvdos/bin/lfs.js.synopsis
Normal file
34
assets/disk0/tvdos/bin/lfs.js.synopsis
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "lfs",
|
||||||
|
"summary": "Create, extract or list a Linear File Strip (.lfs) archive",
|
||||||
|
"description": "Bundles a directory tree into a single TVDOS Linear File Strip archive, or unpacks one. Exactly one mode must be given: -c creates ARCHIVE from PATH, -x extracts ARCHIVE into PATH, and -t lists the files in ARCHIVE (PATH is not used). Individual files are stored uncompressed; gzip the whole .lfs to compress it.",
|
||||||
|
"symbols": {
|
||||||
|
"create": { "kind": "option", "short": "-c", "summary": "Create an archive from a directory" },
|
||||||
|
"extract": { "kind": "option", "short": "-x", "summary": "Extract an archive into a directory" },
|
||||||
|
"list": { "kind": "option", "short": "-t", "summary": "List the files stored in an archive" },
|
||||||
|
"relative": { "kind": "option", "short": "-r", "summary": "Store paths relative to the source directory (with -c)" },
|
||||||
|
"archive": { "kind": "positional", "type": "file", "name": "ARCHIVE", "summary": "The .lfs archive file" },
|
||||||
|
"path": { "kind": "positional", "type": "directory", "name": "PATH", "summary": "Source directory (-c) or destination directory (-x); unused for -t" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "sequence",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "choice",
|
||||||
|
"children": [
|
||||||
|
{ "type": "reference", "symbol": "create" },
|
||||||
|
{ "type": "reference", "symbol": "extract" },
|
||||||
|
{ "type": "reference", "symbol": "list" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ "type": "optional", "child": { "type": "reference", "symbol": "relative" } },
|
||||||
|
{ "type": "reference", "symbol": "archive" },
|
||||||
|
{ "type": "optional", "child": { "type": "reference", "symbol": "path" } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"constraints": [
|
||||||
|
{ "type": "cardinality", "symbols": ["create", "extract", "list"], "minimum": 1, "maximum": 1 },
|
||||||
|
{ "type": "requires", "subject": "relative", "targets": ["create"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
9
assets/disk0/tvdos/bin/movprobe.js.synopsis
Normal file
9
assets/disk0/tvdos/bin/movprobe.js.synopsis
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "movprobe",
|
||||||
|
"summary": "Print metadata about a movie file",
|
||||||
|
"symbols": {
|
||||||
|
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "Movie file to inspect" }
|
||||||
|
},
|
||||||
|
"synopsis": { "type": "reference", "symbol": "file" }
|
||||||
|
}
|
||||||
360
assets/disk0/tvdos/bin/playmov.js
Normal file
360
assets/disk0/tvdos/bin/playmov.js
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
// playmov — all-in-one movie player (MOV/iPF, TEV, TAV, TAP).
|
||||||
|
//
|
||||||
|
// Consolidates playmv1 / playtev / playtav behind one decode library
|
||||||
|
// (mediadec.mjs) and one simple pipeline:
|
||||||
|
//
|
||||||
|
// loop:
|
||||||
|
// read input (quit / pause / seek / volume / cue / ASCII-toggle)
|
||||||
|
// [backend] dec.step() -> decode the next due frame into a RAM RGB888 frame
|
||||||
|
// [player] hold the frame
|
||||||
|
// [postprocessor] subtitle state resolved by the library
|
||||||
|
// [draw] graphics: dec.blit() (upload RAM frame to adapter) + dec.bias()
|
||||||
|
// ASCII: dec.sampleGray + aa.mjs straight off the RAM frame (no upload)
|
||||||
|
// then subtitle overlay + playgui chrome
|
||||||
|
//
|
||||||
|
// Usage: playmov FILE [-i] [-ascii] [-colour] [-deblock] [-boundaryaware]
|
||||||
|
// [-deinterlace=yadif|bwdif] [-debug-mv]
|
||||||
|
// -i interactive (controls + on-screen chrome)
|
||||||
|
// -ascii start in ASCII-render mode (proves the framebuffer flow; aa.mjs)
|
||||||
|
// -colour colourise ASCII glyphs from the video (implies -ascii); -color alias
|
||||||
|
// (others forwarded to the TEV backend, matching playtev)
|
||||||
|
// Controls: Bksp quit | Space pause | Left/Right seek | Up/Down volume
|
||||||
|
// PgUp/PgDn cue prev/next | A toggle ASCII | C toggle colour
|
||||||
|
|
||||||
|
const mediadec = require("mediadec")
|
||||||
|
const gui = require("playgui")
|
||||||
|
const K = require("keysym")
|
||||||
|
|
||||||
|
// aa.mjs (the ASCII renderer) is OPTIONAL. If it isn't installed, playmov still
|
||||||
|
// plays everything normally; ASCII mode just isn't available (-ascii is ignored
|
||||||
|
// and the A key is inert). require() throws when the module is missing, so guard it.
|
||||||
|
let aa = null
|
||||||
|
try { aa = require("aa") } catch (e) { aa = null } // hopper/include/aa.mjs
|
||||||
|
|
||||||
|
const AA_FONT_PATH = "A:/tvdos/tsvm.chr"
|
||||||
|
const VOL_STEP = 16
|
||||||
|
|
||||||
|
// Text-plane palette indices: 0 = GUI background (translucent black), 240 = pure
|
||||||
|
// opaque black, 255 = transparent (GraphicsAdapter: "palette 255 is always
|
||||||
|
// transparent"). aa.mjs paints cell backgrounds with 255, so over live graphics
|
||||||
|
// the picture bleeds through the ASCII; we force opaque 240 instead.
|
||||||
|
const COL_TRANSPARENT = 255
|
||||||
|
const COL_PURE_BLACK = 240
|
||||||
|
const GUI_BG = 0
|
||||||
|
|
||||||
|
// Text fore/back-plane addressing (mirrors aa.mjs _TA_FORE / _TA_BACK / _TA_BASE),
|
||||||
|
// VT-aware.
|
||||||
|
const TXT_FORE_OFF = 2
|
||||||
|
const TXT_BACK_OFF = 2562
|
||||||
|
const TXT_AREA_BASE = 253950
|
||||||
|
const AA_W = 80, AA_H = 32
|
||||||
|
const asciiBackFill = new Uint8Array(AA_W * AA_H).fill(COL_PURE_BLACK)
|
||||||
|
|
||||||
|
// Resolve the address of text-area byte `off` for the current environment
|
||||||
|
// (VT pane: forward from VT_TEXT_PLANE; physical: backward from the GPU base),
|
||||||
|
// exactly as aa.mjs's _va() does, so writes land in the same plane aa.flush uses.
|
||||||
|
function txtAddr(off) {
|
||||||
|
if (typeof globalThis.VT_TEXT_PLANE !== 'undefined')
|
||||||
|
return globalThis.VT_TEXT_PLANE + off
|
||||||
|
return graphics.getGpuMemBase() - TXT_AREA_BASE - off
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overwrite every text cell's background with opaque pure-black (240), so ASCII
|
||||||
|
// glyphs sit on solid black instead of aa.mjs's transparent (255) cells.
|
||||||
|
function paintAsciiBgOpaque() {
|
||||||
|
sys.pokeBytes(txtAddr(TXT_BACK_OFF), asciiBackFill, asciiBackFill.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Colour postprocessor (-colour) ───────────────────────────────────────────
|
||||||
|
// AAlib chooses each glyph from brightness; colour mode additionally tints the
|
||||||
|
// glyph's FOREGROUND (never the background) with the nearest opaque colour of
|
||||||
|
// the TSVM 256-palette, sampled from the video's RGB plane.
|
||||||
|
//
|
||||||
|
// That palette is a *separable* 6×8×5 RGB cube (indices 0–239, white corner at
|
||||||
|
// 239) plus a 15-step grey ramp (indices 240–254 = 0,17,…,238; index 255 is
|
||||||
|
// always transparent and cube index 0 is translucent, so both are excluded as
|
||||||
|
// ink). Because the cube is separable, its nearest entry is just the independent
|
||||||
|
// nearest level per channel; the global nearest opaque colour is then whichever
|
||||||
|
// of {best cube, best grey} is closer — all via small precomputed LUTs, O(1)/cell.
|
||||||
|
const CUBE_R = [0, 51, 102, 153, 204, 255]
|
||||||
|
const CUBE_G = [0, 34, 68, 102, 153, 187, 221, 255]
|
||||||
|
const CUBE_B = [0, 68, 136, 187, 255]
|
||||||
|
|
||||||
|
let _rNear = null, _gNear = null, _bNear = null // 0–255 value → cube level index
|
||||||
|
let _greyIdx = null, _greyVal = null // 0–255 mean → grey palette idx / value
|
||||||
|
const colourBuf = new Uint8Array(AA_W * AA_H * 3) // sampled R,G,B per cell
|
||||||
|
const foreBuf = new Uint8Array(AA_W * AA_H) // resolved palette ink per cell
|
||||||
|
|
||||||
|
function _nearestLevel(levels) {
|
||||||
|
const lut = new Uint8Array(256)
|
||||||
|
for (let v = 0; v < 256; v++) {
|
||||||
|
let best = 0, bestD = 1e9
|
||||||
|
for (let k = 0; k < levels.length; k++) {
|
||||||
|
const d = Math.abs(v - levels[k])
|
||||||
|
if (d < bestD) { bestD = d; best = k }
|
||||||
|
}
|
||||||
|
lut[v] = best
|
||||||
|
}
|
||||||
|
return lut
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureColourLuts() {
|
||||||
|
if (_rNear) return
|
||||||
|
_rNear = _nearestLevel(CUBE_R)
|
||||||
|
_gNear = _nearestLevel(CUBE_G)
|
||||||
|
_bNear = _nearestLevel(CUBE_B)
|
||||||
|
// Grey-ramp candidates: palette idx 240+k holds grey value 17·k, k = 0..14
|
||||||
|
// (idx 240 = black … 254 = 238; idx 255 is transparent, so it is excluded).
|
||||||
|
const gv = [], gi = []
|
||||||
|
for (let k = 0; k < 15; k++) { gv.push(17 * k); gi.push(240 + k) }
|
||||||
|
_greyIdx = new Uint8Array(256)
|
||||||
|
_greyVal = new Uint8Array(256)
|
||||||
|
for (let m = 0; m < 256; m++) {
|
||||||
|
let best = 0, bestD = 1e9
|
||||||
|
for (let k = 0; k < gv.length; k++) {
|
||||||
|
const d = Math.abs(m - gv[k])
|
||||||
|
if (d < bestD) { bestD = d; best = k }
|
||||||
|
}
|
||||||
|
_greyIdx[m] = gi[best]; _greyVal[m] = gv[best]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestPaletteIndex(r, g, b) {
|
||||||
|
const ri = _rNear[r], gi = _gNear[g], bi = _bNear[b]
|
||||||
|
const cr = CUBE_R[ri], cg = CUBE_G[gi], cb = CUBE_B[bi]
|
||||||
|
const dCube = (r - cr) * (r - cr) + (g - cg) * (g - cg) + (b - cb) * (b - cb)
|
||||||
|
// Nearest grey level sits at the rounded mean of the channels (the vertex of
|
||||||
|
// the achromatic-distance parabola); rounding — not flooring — makes the
|
||||||
|
// {cube vs grey} pick the exact global nearest opaque palette entry.
|
||||||
|
const m = ((r + g + b) / 3 + 0.5) | 0
|
||||||
|
const gvv = _greyVal[m]
|
||||||
|
const dGrey = (r - gvv) * (r - gvv) + (g - gvv) * (g - gvv) + (b - gvv) * (b - gvv)
|
||||||
|
// Prefer grey on ties (so near-black resolves to opaque grey idx 240, not the
|
||||||
|
// translucent cube corner); `|| 240` is a belt-and-braces guard for idx 0.
|
||||||
|
const cubeIdx = ri * 40 + gi * 5 + bi
|
||||||
|
return (dGrey <= dCube) ? _greyIdx[m] : (cubeIdx || 240)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample the frame's colour per cell, map to nearest palette ink, and write the
|
||||||
|
// foreground plane (over what aa.flush wrote). Background is left to
|
||||||
|
// paintAsciiBgOpaque(); only the FG is colourised, per spec.
|
||||||
|
function applyColourFore(dec) {
|
||||||
|
dec.sampleColour(colourBuf, AA_W, AA_H)
|
||||||
|
for (let i = 0, n = AA_W * AA_H; i < n; i++)
|
||||||
|
foreBuf[i] = nearestPaletteIndex(colourBuf[i * 3], colourBuf[i * 3 + 1], colourBuf[i * 3 + 2])
|
||||||
|
sys.pokeBytes(txtAddr(TXT_FORE_OFF), foreBuf, foreBuf.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Parse args ───────────────────────────────────────────────────────────────
|
||||||
|
let interactive = false
|
||||||
|
let asciiMode = false
|
||||||
|
let colourMode = false
|
||||||
|
const decOpts = { interactive: false, deinterlaceAlgorithm: "yadif" }
|
||||||
|
|
||||||
|
for (let i = 2; i < exec_args.length; i++) {
|
||||||
|
const arg = ("" + exec_args[i]).toLowerCase()
|
||||||
|
if (arg === "-i") { interactive = true; decOpts.interactive = true }
|
||||||
|
else if (arg === "-ascii") asciiMode = true
|
||||||
|
else if (arg === "-colour" || arg === "-color") { asciiMode = true; colourMode = true }
|
||||||
|
else if (arg === "-debug-mv") decOpts.debugMotionVectors = true
|
||||||
|
else if (arg === "-deblock") decOpts.enableDeblocking = true
|
||||||
|
else if (arg === "-boundaryaware") decOpts.enableBoundaryAwareDecoding = true
|
||||||
|
else if (arg.startsWith("-deinterlace=")) decOpts.deinterlaceAlgorithm = arg.substring(13)
|
||||||
|
else if (arg.startsWith("--filter-film-grain")) {
|
||||||
|
const parts = arg.split(/[=\s]/)
|
||||||
|
if (parts.length > 1) { const lv = parseInt(parts[1]); if (!isNaN(lv)) decOpts.filmGrainLevel = lv }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graceful degradation: ASCII (and therefore colour) mode needs aa.mjs.
|
||||||
|
if (asciiMode && !aa) {
|
||||||
|
serial.println("playmov: aa.mjs not found; ASCII mode unavailable, -ascii/-colour ignored")
|
||||||
|
asciiMode = false
|
||||||
|
colourMode = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!exec_args[1]) { printerrln("usage: playmov FILE [-i] [-ascii] [-colour] [options]"); return 1 }
|
||||||
|
const fullPath = _G.shell.resolvePathInput(exec_args[1]).full
|
||||||
|
|
||||||
|
// ── ASCII-render state (aa.mjs) — lazily initialised on first use ────────────
|
||||||
|
let aaCtx = null
|
||||||
|
let aaParams = null
|
||||||
|
function ensureAscii() {
|
||||||
|
if (aaCtx) return
|
||||||
|
const font = aa.loadChrFontROM(AA_FONT_PATH)
|
||||||
|
aaCtx = aa.init(AA_W, AA_H, { font: font })
|
||||||
|
aaParams = aa.getrenderparams()
|
||||||
|
aaParams.dither = aa.AA_FLOYD_S
|
||||||
|
ensureColourLuts() // cheap; keeps the C-key colour toggle ready
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Open ─────────────────────────────────────────────────────────────────────
|
||||||
|
let [cy, cx] = con.getyx()
|
||||||
|
let errorlevel = 0
|
||||||
|
let dec = null
|
||||||
|
let stage = "open" // breadcrumb for the error log
|
||||||
|
|
||||||
|
try {
|
||||||
|
dec = mediadec.open(fullPath, decOpts)
|
||||||
|
const info = dec.info
|
||||||
|
|
||||||
|
// NB: palette 0 is translucent black by default — exactly what the playgui
|
||||||
|
// chrome (bg colour 0) wants — so we never redefine it. (Backends must not
|
||||||
|
// either, or the chrome turns opaque for the next file played.)
|
||||||
|
|
||||||
|
if (info.isStill) { con.move(1, 1); println("Push and hold Backspace to exit") }
|
||||||
|
|
||||||
|
let startNs = 0
|
||||||
|
let lastKey = 0
|
||||||
|
let quit = false
|
||||||
|
|
||||||
|
// Build the playgui status object for the on-screen chrome.
|
||||||
|
function status() {
|
||||||
|
const usingCues = dec.cues && dec.cues.length > 0
|
||||||
|
const akku = startNs ? (sys.nanoTime() - startNs) / 1000000000.0 : 0.0001
|
||||||
|
return {
|
||||||
|
fps: info.fps,
|
||||||
|
videoRate: dec.videoRate | 0,
|
||||||
|
frameCount: dec.frameCount,
|
||||||
|
totalFrames: info.totalFrames,
|
||||||
|
frameMode: dec.frameMode,
|
||||||
|
qY: dec.qY || 0, qCo: dec.qCo || 0, qCg: dec.qCg || 0,
|
||||||
|
akku: akku,
|
||||||
|
fileName: usingCues ? dec.cues[dec.currentCueIndex].name : fullPath,
|
||||||
|
fileOrd: usingCues ? (dec.currentCueIndex + 1) : (dec.currentFileIndex || 1),
|
||||||
|
resolution: `${info.width}x${info.height}${info.isInterlaced ? 'i' : ''}`,
|
||||||
|
colourSpace: info.colourSpace,
|
||||||
|
currentStatus: dec.isPaused() ? 2 : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entering ASCII: clear the text plane; the pixel framebuffer is left as-is and
|
||||||
|
// simply covered each frame by solid-black (240) text cells (see draw()).
|
||||||
|
// Bias lighting is pinned to pure black ONCE here and not updated again while
|
||||||
|
// in ASCII (draw() skips the bias stage), so the backdrop stays steady.
|
||||||
|
function enterAsciiVisual() {
|
||||||
|
ensureAscii()
|
||||||
|
graphics.setBackground(0, 0, 0)
|
||||||
|
graphics.clearPixelsAll(0, 0, 0, 0)
|
||||||
|
con.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leaving ASCII: fill the viewing area with transparency (255), NOT the GUI's
|
||||||
|
// translucent-black (colour 0), so the resumed video shows through cleanly.
|
||||||
|
function exitAsciiVisual() {
|
||||||
|
con.color_pair(COL_TRANSPARENT, COL_TRANSPARENT)
|
||||||
|
con.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAscii() {
|
||||||
|
asciiMode = !asciiMode
|
||||||
|
if (asciiMode) enterAsciiVisual()
|
||||||
|
else exitAsciiVisual()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Colour only affects the foreground plane and is re-applied every drawn
|
||||||
|
// frame, so toggling it just flips the flag; the next flush+draw reverts the
|
||||||
|
// ink to aa.mjs's grey when off. Ensure the LUTs exist if A was never pressed.
|
||||||
|
function toggleColour() {
|
||||||
|
if (!aaCtx) ensureColourLuts()
|
||||||
|
colourMode = !colourMode
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Input ─────────────────────────────────────────────────────────────────
|
||||||
|
// Bksp is hold-to-quit (like the old players); everything else is edge-
|
||||||
|
// triggered so a held key fires once. Quit + ASCII/colour toggles work even
|
||||||
|
// without -i; the rest of the transport is interactive-only.
|
||||||
|
function readInput() {
|
||||||
|
sys.poke(-40, 1)
|
||||||
|
const key = sys.peek(-41)
|
||||||
|
if (key == K.BACKSPACE) { quit = true; return }
|
||||||
|
if (key && key !== lastKey) {
|
||||||
|
if (key == K.A) { if (aa) toggleAscii() } // inert when aa.mjs is absent
|
||||||
|
else if (key == K.C) { if (aa) toggleColour() } // colour shows only while in ASCII
|
||||||
|
else if (interactive) {
|
||||||
|
switch (key) {
|
||||||
|
case K.SPACE: dec.pause(!dec.isPaused()); break
|
||||||
|
case K.LEFT: dec.seekSeconds(-5.5); break
|
||||||
|
case K.RIGHT: dec.seekSeconds(5.0); break
|
||||||
|
case K.UP: dec.setVolume(dec.getVolume() + VOL_STEP); break
|
||||||
|
case K.DOWN: dec.setVolume(dec.getVolume() - VOL_STEP); break
|
||||||
|
case K.PAGE_UP: dec.cue(-1); break
|
||||||
|
case K.PAGE_DOWN: dec.cue(1); break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastKey = key
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Draw a decoded frame: RAM frame -> screen / ASCII -> overlays -> chrome ─
|
||||||
|
function draw() {
|
||||||
|
if (asciiMode) {
|
||||||
|
// The decoded frame already sits in RAM (TEV/TAV) or on the display
|
||||||
|
// planes (iPF), so sample it WITHOUT uploading to the video adapter,
|
||||||
|
// then cover the picture with solid-black (240) text cells (cheaper
|
||||||
|
// than clearing the pixel planes).
|
||||||
|
dec.sampleGray(aaCtx.imagebuffer, aaCtx.imgW, aaCtx.imgH)
|
||||||
|
aa.render(aaCtx, aaParams)
|
||||||
|
aa.flush(aaCtx)
|
||||||
|
if (colourMode) applyColourFore(dec) // recolour the FG plane from the video's RGB
|
||||||
|
paintAsciiBgOpaque() // cover with opaque 240 (not transparent 255)
|
||||||
|
} else {
|
||||||
|
dec.blit() // upload the RAM frame to the video adapter
|
||||||
|
dec.bias() // bias lighting (player-owned stage; graphics only)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Postprocessor output: subtitle overlay (text plane, on top of the frame).
|
||||||
|
if (asciiMode) {
|
||||||
|
// aa.flush rewrote the whole text plane, so redraw the subtitle each frame.
|
||||||
|
if (dec.subtitle.visible) gui.displaySubtitle(dec.subtitle.text, dec.subtitle.useUnicode, dec.subtitle.position)
|
||||||
|
dec.subtitle.dirty = false
|
||||||
|
} else if (dec.subtitle.dirty) {
|
||||||
|
gui.clearSubtitleArea()
|
||||||
|
if (dec.subtitle.visible) gui.displaySubtitle(dec.subtitle.text, dec.subtitle.useUnicode, dec.subtitle.position)
|
||||||
|
dec.subtitle.dirty = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interactive) { gui.printBottomBar(status()); gui.printTopBar(status(), 1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start in ASCII if requested (-ascii). Done here, after the helpers above are
|
||||||
|
// defined, since they are block-scoped function declarations.
|
||||||
|
if (asciiMode) enterAsciiVisual()
|
||||||
|
|
||||||
|
// ── Main loop ───────────────────────────────────────────────────────────
|
||||||
|
while (!quit) {
|
||||||
|
stage = "input"; readInput()
|
||||||
|
if (quit) break
|
||||||
|
|
||||||
|
stage = "step"
|
||||||
|
const ev = dec.step()
|
||||||
|
if (ev.type === 'eof') break
|
||||||
|
if (ev.type === 'error') { errorlevel = 1; break }
|
||||||
|
if (ev.type === 'frame') {
|
||||||
|
if (!startNs) startNs = sys.nanoTime()
|
||||||
|
stage = "draw"; draw()
|
||||||
|
} else {
|
||||||
|
// 'idle' or 'newfile' — nothing to draw this turn.
|
||||||
|
sys.sleep(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
// Log to serial too (persists in the console log next to errorlevel) and
|
||||||
|
// keep it on screen — con.clear() in finally only runs on success.
|
||||||
|
serial.printerr("playmov failed at stage [" + stage + "]: " + e)
|
||||||
|
if (e && e.message) serial.println(" message: " + e.message)
|
||||||
|
if (e && e.stack) serial.println(" stack: " + e.stack)
|
||||||
|
if (e && e.printStackTrace) e.printStackTrace()
|
||||||
|
printerrln(e)
|
||||||
|
errorlevel = 1
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if (dec) dec.close()
|
||||||
|
if (aa && aaCtx) aa.close(aaCtx)
|
||||||
|
if (errorlevel === 0) con.clear()
|
||||||
|
con.curs_set(1)
|
||||||
|
con.move(cy, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return errorlevel
|
||||||
38
assets/disk0/tvdos/bin/playmov.js.synopsis
Normal file
38
assets/disk0/tvdos/bin/playmov.js.synopsis
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "playmov",
|
||||||
|
"summary": "Play a movie file (MOV/iPF, TEV, TAV or TAP)",
|
||||||
|
"symbols": {
|
||||||
|
"interactive": { "kind": "option", "short": "-i", "summary": "Interactive mode (controls + on-screen info)" },
|
||||||
|
"ascii": { "kind": "option", "long": "-ascii", "summary": "Start in ASCII-render mode" },
|
||||||
|
"colour": { "kind": "option", "long": "-colour", "summary": "Colourise ASCII glyphs from the video (implies -ascii); -color alias" },
|
||||||
|
"deblock": { "kind": "option", "long": "-deblock", "summary": "TEV: enable deblocking filter" },
|
||||||
|
"boundaryAware": { "kind": "option", "long": "-boundaryaware", "summary": "TEV: boundary-aware decoding" },
|
||||||
|
"debugMv": { "kind": "option", "long": "-debug-mv", "summary": "TEV: show motion-vector debug overlay" },
|
||||||
|
"deinterlace": {
|
||||||
|
"kind": "option",
|
||||||
|
"long": "-deinterlace",
|
||||||
|
"summary": "TEV: deinterlacing algorithm",
|
||||||
|
"value": { "name": "ALGO", "type": "enum", "values": ["yadif", "bwdif"], "required": true, "summary": "Deinterlacer" }
|
||||||
|
},
|
||||||
|
"filmGrain": {
|
||||||
|
"kind": "option",
|
||||||
|
"long": "--filter-film-grain",
|
||||||
|
"summary": "TAV: apply a film-grain filter",
|
||||||
|
"value": { "name": "LEVEL", "type": "integer", "required": false, "summary": "Grain intensity" }
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"kind": "group",
|
||||||
|
"summary": "Options",
|
||||||
|
"members": ["interactive", "ascii", "colour", "deblock", "boundaryAware", "debugMv", "deinterlace", "filmGrain"]
|
||||||
|
},
|
||||||
|
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "Movie file to play" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "sequence",
|
||||||
|
"children": [
|
||||||
|
{ "type": "reference", "symbol": "file" },
|
||||||
|
{ "type": "repeat", "child": { "type": "reference", "symbol": "options" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,209 +1,126 @@
|
|||||||
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
|
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 MP2_CHANNELMODES = ["Stereo", "Joint", "Dual", "Mono"]
|
||||||
|
|
||||||
const pcm = require("pcm")
|
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) }
|
function printdbg(s) { if (0) serial.println(s) }
|
||||||
|
|
||||||
|
|
||||||
class SequentialFileBuffer {
|
class SequentialFileBuffer {
|
||||||
|
|
||||||
constructor(path, offset, length) {
|
constructor(path, offset, length) {
|
||||||
if (Array.isArray(path)) throw Error("arg #1 is path(string), not array")
|
if (Array.isArray(path)) throw Error("arg #1 is path(string), not array")
|
||||||
|
|
||||||
this.path = path
|
this.path = path
|
||||||
this.file = files.open(path)
|
this.file = files.open(path)
|
||||||
|
|
||||||
this.offset = offset || 0
|
this.offset = offset || 0
|
||||||
this.originalOffset = offset
|
this.originalOffset = offset
|
||||||
this.length = length || this.file.size
|
this.length = length || this.file.size
|
||||||
|
|
||||||
this.seq = require("seqread")
|
this.seq = require("seqread")
|
||||||
this.seq.prepare(path)
|
this.seq.prepare(path)
|
||||||
}
|
}
|
||||||
|
readBytes(size, ptr) { return this.seq.readBytes(size, ptr) }
|
||||||
readBytes(size, ptr) {
|
get fileHeader() { return this.seq.fileHeader }
|
||||||
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()
|
|
||||||
}*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filebuf = new SequentialFileBuffer(_G.shell.resolvePathInput(exec_args[1]).full)
|
||||||
|
const FILE_SIZE = filebuf.length
|
||||||
|
const FRAME_SIZE = audio.mp2GetInitialFrameSize(filebuf.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 MEDIA_BITRATE = MP2_BITRATES[filebuf.fileHeader[2] >>> 4]
|
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
|
let decodedLength = 0
|
||||||
|
|
||||||
|
const bufRealTimeLen = 36 // one MP2 frame at 32 kHz ≈ 36 ms
|
||||||
|
|
||||||
//serial.println(`Frame size: ${FRAME_SIZE}`)
|
// Occupy the first idle playhead rather than always grabbing #0, so playback
|
||||||
|
// doesn't cut off audio already running on another playhead. Falls back to #0
|
||||||
|
// when all four are busy.
|
||||||
con.curs_set(0)
|
const PLAYHEAD = audio.getFreePlayhead(0)
|
||||||
let [__, CONSOLE_WIDTH] = con.getmaxyx()
|
audio.resetParams(PLAYHEAD)
|
||||||
if (interactive) {
|
audio.purgeQueue(PLAYHEAD)
|
||||||
let [cy, cx] = con.getyx()
|
audio.setPcmMode(PLAYHEAD)
|
||||||
// file name
|
audio.setPcmQueueCapacityIndex(PLAYHEAD, 2)
|
||||||
con.mvaddch(cy, 1)
|
const QUEUE_MAX = audio.getPcmQueueCapacity(PLAYHEAD)
|
||||||
con.prnch(0xC9);con.prnch(0xCD);con.prnch(0xB5)
|
audio.setMasterVolume(PLAYHEAD, 255)
|
||||||
print(filebuf.file.name)
|
audio.play(PLAYHEAD)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
audio.resetParams(0)
|
|
||||||
audio.purgeQueue(0)
|
|
||||||
audio.setPcmMode(0)
|
|
||||||
audio.setPcmQueueCapacityIndex(0, 2) // queue size is now 8
|
|
||||||
const QUEUE_MAX = audio.getPcmQueueCapacity(0)
|
|
||||||
audio.setMasterVolume(0, 255)
|
|
||||||
audio.play(0)
|
|
||||||
|
|
||||||
|
|
||||||
//let mp2context = audio.mp2Init()
|
|
||||||
audio.mp2Init()
|
audio.mp2Init()
|
||||||
|
|
||||||
// decode frame
|
function bytesToSec(i) { return i / (FRAME_SIZE * 1000 / bufRealTimeLen) }
|
||||||
let t1 = sys.nanoTime()
|
|
||||||
let bufRealTimeLen = 36
|
if (interactive) {
|
||||||
|
const tag = "MP2"
|
||||||
|
const title = `${filebuf.file.name} ${MEDIA_CHANNEL} ${MEDIA_BITRATE}kbps`
|
||||||
|
gui.audioInit({ title, tag })
|
||||||
|
}
|
||||||
|
|
||||||
let stopPlay = false
|
let stopPlay = false
|
||||||
let errorlevel = 0
|
let errorlevel = 0
|
||||||
try {
|
try {
|
||||||
while (bytes_left > 0 && !stopPlay) {
|
while (bytes_left > 0 && !stopPlay) {
|
||||||
|
if (interactive && gui.audioIsExitRequested()) { stopPlay = true; break }
|
||||||
if (interactive) {
|
|
||||||
sys.poke(-40, 1)
|
|
||||||
if (sys.peek(-41) == 67) {
|
|
||||||
stopPlay = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
printPlayBar()
|
|
||||||
|
|
||||||
|
|
||||||
filebuf.readBytes(FRAME_SIZE, SND_BASE_ADDR - 2368)
|
filebuf.readBytes(FRAME_SIZE, SND_BASE_ADDR - 2368)
|
||||||
audio.mp2Decode()
|
audio.mp2Decode()
|
||||||
|
|
||||||
if (audio.getPosition(0) >= QUEUE_MAX) {
|
// After decode, 1152 PCMu8 stereo samples sit in mediaDecodedBin
|
||||||
while (audio.getPosition(0) >= (QUEUE_MAX >>> 1)) {
|
// (MMIO). Bounce them through RAM so single-byte peek in the
|
||||||
printdbg(`Queue full, waiting until the queue has some space (${audio.getPosition(0)}/${QUEUE_MAX})`)
|
// 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(PLAYHEAD) >= QUEUE_MAX) {
|
||||||
|
while (audio.getPosition(PLAYHEAD) >= (QUEUE_MAX >>> 1)) {
|
||||||
|
if (interactive) gui.audioRender()
|
||||||
sys.sleep(bufRealTimeLen)
|
sys.sleep(bufRealTimeLen)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
audio.mp2UploadDecoded(0)
|
audio.mp2UploadDecoded(0)
|
||||||
|
|
||||||
|
if (interactive) {
|
||||||
|
gui.audioSetProgress(decodedLength / FILE_SIZE,
|
||||||
|
bytesToSec(decodedLength), bytesToSec(FILE_SIZE))
|
||||||
|
gui.audioRender()
|
||||||
|
}
|
||||||
sys.sleep(10)
|
sys.sleep(10)
|
||||||
|
|
||||||
|
bytes_left -= FRAME_SIZE
|
||||||
|
|
||||||
bytes_left -= FRAME_SIZE
|
|
||||||
decodedLength += FRAME_SIZE
|
decodedLength += FRAME_SIZE
|
||||||
}
|
}
|
||||||
}
|
} catch (e) {
|
||||||
catch (e) {
|
|
||||||
printerrln(e)
|
printerrln(e)
|
||||||
errorlevel = 1
|
errorlevel = 1
|
||||||
}
|
} finally {
|
||||||
finally {
|
if (interactive) {
|
||||||
|
if (mp2VisScratch) sys.free(mp2VisScratch)
|
||||||
|
gui.audioClose()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return errorlevel
|
return errorlevel
|
||||||
17
assets/disk0/tvdos/bin/playmp2.js.synopsis
Normal file
17
assets/disk0/tvdos/bin/playmp2.js.synopsis
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "playmp2",
|
||||||
|
"summary": "Play an MP2 audio file",
|
||||||
|
"symbols": {
|
||||||
|
"interactive": { "kind": "option", "short": "-i", "summary": "Interactive mode (visualiser)" },
|
||||||
|
"options": { "kind": "group", "summary": "Options", "members": ["interactive"] },
|
||||||
|
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "MP2 file to play" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "sequence",
|
||||||
|
"children": [
|
||||||
|
{ "type": "reference", "symbol": "file" },
|
||||||
|
{ "type": "repeat", "child": { "type": "reference", "symbol": "options" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,10 +97,14 @@ let startTime = sys.nanoTime()
|
|||||||
let framesRead = 0
|
let framesRead = 0
|
||||||
let audioFired = false
|
let audioFired = false
|
||||||
|
|
||||||
audio.resetParams(0)
|
// Occupy the first idle playhead rather than always grabbing #0, so playback
|
||||||
audio.purgeQueue(0)
|
// doesn't cut off audio already running on another playhead. Falls back to #0
|
||||||
audio.setPcmMode(0)
|
// when all four are busy.
|
||||||
audio.setMasterVolume(0, 255)
|
const PLAYHEAD = audio.getFreePlayhead(0)
|
||||||
|
audio.resetParams(PLAYHEAD)
|
||||||
|
audio.purgeQueue(PLAYHEAD)
|
||||||
|
audio.setPcmMode(PLAYHEAD)
|
||||||
|
audio.setMasterVolume(PLAYHEAD, 255)
|
||||||
|
|
||||||
function s16StTou8St(inPtrL, inPtrR, outPtr, length) {
|
function s16StTou8St(inPtrL, inPtrR, outPtr, length) {
|
||||||
for (let k = 0; k < length; k+=2) {
|
for (let k = 0; k < length; k+=2) {
|
||||||
@@ -204,7 +208,7 @@ while (!stopPlay && seqread.getReadCount() < FILE_LENGTH) {
|
|||||||
|
|
||||||
// defer audio playback until a first frame is sent
|
// defer audio playback until a first frame is sent
|
||||||
if (!audioFired) {
|
if (!audioFired) {
|
||||||
audio.play(0)
|
audio.play(PLAYHEAD)
|
||||||
audioFired = true
|
audioFired = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,7 +267,7 @@ while (!stopPlay && seqread.getReadCount() < FILE_LENGTH) {
|
|||||||
|
|
||||||
// defer audio playback until a first frame is sent
|
// defer audio playback until a first frame is sent
|
||||||
if (!audioFired) {
|
if (!audioFired) {
|
||||||
audio.play(0)
|
audio.play(PLAYHEAD)
|
||||||
audioFired = true
|
audioFired = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,9 +330,9 @@ while (!stopPlay && seqread.getReadCount() < FILE_LENGTH) {
|
|||||||
// RAW PCM packets (decode on the fly)
|
// RAW PCM packets (decode on the fly)
|
||||||
else if (packetType == 0x1000 || packetType == 0x1001) {
|
else if (packetType == 0x1000 || packetType == 0x1001) {
|
||||||
let frame = seqread.readBytes(readLength)
|
let frame = seqread.readBytes(readLength)
|
||||||
audio.putPcmDataByPtr(0, frame, readLength, 0)
|
audio.putPcmDataByPtr(PLAYHEAD, frame, readLength, 0)
|
||||||
audio.setSampleUploadLength(0, readLength)
|
audio.setSampleUploadLength(PLAYHEAD, readLength)
|
||||||
audio.startSampleUpload(0)
|
audio.startSampleUpload(PLAYHEAD)
|
||||||
sys.free(frame)
|
sys.free(frame)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@@ -382,14 +386,14 @@ finally {
|
|||||||
if (AUDIO_QUEUE_BYTES > 0 && AUDIO_QUEUE_LENGTH > 1) {
|
if (AUDIO_QUEUE_BYTES > 0 && AUDIO_QUEUE_LENGTH > 1) {
|
||||||
|
|
||||||
}
|
}
|
||||||
//audio.stop(0)
|
//audio.stop(PLAYHEAD)
|
||||||
|
|
||||||
let timeTook = (endTime - startTime) / 1000000000.0
|
let timeTook = (endTime - startTime) / 1000000000.0
|
||||||
|
|
||||||
//println(`Actual FPS: ${framesRendered / timeTook}`)
|
//println(`Actual FPS: ${framesRendered / timeTook}`)
|
||||||
|
|
||||||
audio.stop(0)
|
audio.stop(PLAYHEAD)
|
||||||
audio.purgeQueue(0)
|
audio.purgeQueue(PLAYHEAD)
|
||||||
|
|
||||||
if (interactive) {
|
if (interactive) {
|
||||||
con.clear()
|
con.clear()
|
||||||
|
|||||||
@@ -1,196 +1,85 @@
|
|||||||
// usage: playpcm audiofile.pcm [/i]
|
// playpcm — raw PCMu8 stereo player with the shared playgui visualiser.
|
||||||
let fileeeee = files.open(_G.shell.resolvePathInput(exec_args[1]).full)
|
// Usage: playpcm <file.pcm> [-i]
|
||||||
let filename = fileeeee.fullPath
|
|
||||||
function printdbg(s) { if (0) serial.println(s) }
|
|
||||||
|
|
||||||
const interactive = exec_args[2] && exec_args[2].toLowerCase() == "-i"
|
const fileHandle = files.open(_G.shell.resolvePathInput(exec_args[1]).full)
|
||||||
const pcm = require("pcm")
|
const filePath = fileHandle.fullPath
|
||||||
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 interactive = exec_args[2] && exec_args[2].toLowerCase() === "-i"
|
||||||
|
const pcm = require("pcm")
|
||||||
const seqread = require("seqread")
|
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 BLOCK_SIZE = 4096
|
||||||
let INFILE_BLOCK_SIZE = BLOCK_SIZE
|
const INFILE_BLOCK_SIZE = BLOCK_SIZE
|
||||||
const QUEUE_MAX = 8 // according to the spec
|
const QUEUE_MAX = 8
|
||||||
|
|
||||||
let nChannels = 2
|
const samplingRate = pcm.HW_SAMPLING_RATE
|
||||||
let samplingRate = pcm.HW_SAMPLING_RATE;
|
const byterate = 2 * samplingRate
|
||||||
let blockSize = 2;
|
|
||||||
let bitsPerSample = 8;
|
|
||||||
let byterate = 2*samplingRate;
|
|
||||||
let comments = {};
|
|
||||||
let readPtr = undefined
|
|
||||||
let decodePtr = undefined
|
|
||||||
|
|
||||||
function bytesToSec(i) {
|
function bytesToSec(i) { return i / byterate }
|
||||||
return i / byterate
|
|
||||||
}
|
seqread.prepare(filePath)
|
||||||
function secToReadable(n) {
|
|
||||||
let mins = ''+((n/60)|0)
|
const readPtr = sys.malloc(BLOCK_SIZE)
|
||||||
let secs = ''+(n % 60)
|
// Occupy the first idle playhead rather than always grabbing #0, so playback
|
||||||
return `${mins.padStart(2,'0')}:${secs.padStart(2,'0')}`
|
// doesn't cut off audio already running on another playhead. Falls back to #0
|
||||||
|
// when all four are busy.
|
||||||
|
const PLAYHEAD = audio.getFreePlayhead(0)
|
||||||
|
audio.resetParams(PLAYHEAD)
|
||||||
|
audio.purgeQueue(PLAYHEAD)
|
||||||
|
audio.setPcmMode(PLAYHEAD)
|
||||||
|
audio.setMasterVolume(PLAYHEAD, 255)
|
||||||
|
|
||||||
|
if (interactive) {
|
||||||
|
gui.audioInit({
|
||||||
|
title: `${fileHandle.name} Raw PCM 32kHz Stereo`,
|
||||||
|
tag: "PCM"
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
let stopPlay = false
|
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)
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let errorlevel = 0
|
let errorlevel = 0
|
||||||
|
let readLength = 1
|
||||||
try {
|
try {
|
||||||
while (!stopPlay && seqread.getReadCount() < FILE_SIZE && readLength > 0) {
|
while (!stopPlay && seqread.getReadCount() < FILE_SIZE && readLength > 0) {
|
||||||
if (interactive) {
|
if (interactive && gui.audioIsExitRequested()) { stopPlay = true; break }
|
||||||
sys.poke(-40, 1)
|
|
||||||
if (sys.peek(-41) == 67) {
|
|
||||||
stopPlay = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const queueSize = audio.getPosition(PLAYHEAD)
|
||||||
|
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)
|
seqread.readBytes(readLength, readPtr)
|
||||||
if (queueSize <= 1) {
|
|
||||||
|
|
||||||
printPlayBar()
|
// Raw PCMu8 stereo — sampleCount = bytes / 2.
|
||||||
|
if (interactive) gui.audioFeedPcm(readPtr, readLength >> 1)
|
||||||
|
|
||||||
// upload four samples for lag-safely
|
audio.putPcmDataByPtr(PLAYHEAD, readPtr, readLength, 0)
|
||||||
for (let repeat = QUEUE_MAX - queueSize; repeat > 0; repeat--) {
|
audio.setSampleUploadLength(PLAYHEAD, readLength)
|
||||||
let remainingBytes = FILE_SIZE - seqread.getReadCount()
|
audio.startSampleUpload(PLAYHEAD)
|
||||||
|
|
||||||
readLength = (remainingBytes < INFILE_BLOCK_SIZE) ? remainingBytes : INFILE_BLOCK_SIZE
|
if (repeat > 1) sys.sleep(10)
|
||||||
if (readLength <= 0) {
|
|
||||||
printdbg(`readLength = ${readLength}`)
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
audio.play(PLAYHEAD)
|
||||||
printdbg(`offset: ${seqread.getReadCount()}/${FILE_SIZE}; readLength: ${readLength}`)
|
|
||||||
|
|
||||||
seqread.readBytes(readLength, readPtr)
|
|
||||||
|
|
||||||
audio.putPcmDataByPtr(0, readPtr, readLength, 0)
|
|
||||||
audio.setSampleUploadLength(0, readLength)
|
|
||||||
audio.startSampleUpload(0)
|
|
||||||
|
|
||||||
|
|
||||||
if (repeat > 1) sys.sleep(10)
|
|
||||||
|
|
||||||
printPlayBar()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
audio.play(0)
|
if (interactive) {
|
||||||
|
const cur = seqread.getReadCount()
|
||||||
|
gui.audioSetProgress(cur / FILE_SIZE, bytesToSec(cur), bytesToSec(FILE_SIZE))
|
||||||
|
gui.audioRender()
|
||||||
|
}
|
||||||
|
sys.sleep(10)
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
let remainingBytes = FILE_SIZE - seqread.getReadCount()
|
|
||||||
printdbg(`readLength = ${readLength}; remainingBytes2 = ${remainingBytes}; seqread.getReadCount() = ${seqread.getReadCount()};`)
|
|
||||||
|
|
||||||
|
|
||||||
sys.sleep(10)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (e) {
|
|
||||||
printerrln(e)
|
printerrln(e)
|
||||||
errorlevel = 1
|
errorlevel = 1
|
||||||
}
|
} finally {
|
||||||
finally {
|
|
||||||
//audio.stop(0)
|
|
||||||
if (readPtr !== undefined) sys.free(readPtr)
|
if (readPtr !== undefined) sys.free(readPtr)
|
||||||
if (decodePtr !== undefined) sys.free(decodePtr)
|
if (interactive) gui.audioClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
return errorlevel
|
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_BASE_ADDR = audio.getBaseAddr()
|
||||||
const SND_MEM_ADDR = audio.getMemAddr()
|
const SND_MEM_ADDR = audio.getMemAddr()
|
||||||
const TAD_INPUT_ADDR = SND_MEM_ADDR - 262144 // TAD input buffer (matches TAV packet 0x24)
|
// tadInputBin at offset 917504, tadDecodedBin at 983040. Both addressed via
|
||||||
const TAD_DECODED_ADDR = SND_MEM_ADDR - 262144 + 65536 // TAD decoded buffer
|
// 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
|
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") {
|
||||||
if (!exec_args[1] || exec_args[1] == "-h" || exec_args[1] == "--help") {
|
serial.println("Usage: playtad <file.tad> [-i | -d]")
|
||||||
serial.println("Usage: playtad <file.tad> [-i | -d] [quality]")
|
serial.println(" -i Interactive mode (visualiser + progress bar)")
|
||||||
serial.println(" -i Interactive mode (progress bar, press Backspace to exit)")
|
serial.println(" -d Dump first three chunks for debugging")
|
||||||
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")
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
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 dumpCoeffs = exec_args[2] && exec_args[2].toLowerCase() === "-d"
|
||||||
const dumpCoeffs = exec_args[2] && exec_args[2].toLowerCase() == "-d"
|
const gui = interactive ? require("playgui") : null
|
||||||
|
|
||||||
function printdbg(s) { if (0) serial.println(s) }
|
|
||||||
|
|
||||||
|
|
||||||
class SequentialFileBuffer {
|
class SequentialFileBuffer {
|
||||||
|
constructor(path) {
|
||||||
constructor(path, offset, length) {
|
|
||||||
if (Array.isArray(path)) throw Error("arg #1 is path(string), not array")
|
if (Array.isArray(path)) throw Error("arg #1 is path(string), not array")
|
||||||
|
|
||||||
this.path = path
|
this.path = path
|
||||||
this.file = files.open(path)
|
this.file = files.open(path)
|
||||||
|
this.length = this.file.size
|
||||||
this.offset = offset || 0
|
|
||||||
this.originalOffset = offset
|
|
||||||
this.length = length || this.file.size
|
|
||||||
|
|
||||||
this.seq = require("seqread")
|
this.seq = require("seqread")
|
||||||
this.seq.prepare(path)
|
this.seq.prepare(path)
|
||||||
}
|
}
|
||||||
|
readBytes(size, ptr) { return this.seq.readBytes(size, ptr) }
|
||||||
readBytes(size, ptr) {
|
|
||||||
return this.seq.readBytes(size, ptr)
|
|
||||||
}
|
|
||||||
|
|
||||||
readByte() {
|
readByte() {
|
||||||
let ptr = this.seq.readBytes(1)
|
const ptr = this.seq.readBytes(1)
|
||||||
let val = sys.peek(ptr)
|
const val = sys.peek(ptr)
|
||||||
sys.free(ptr)
|
sys.free(ptr)
|
||||||
return val
|
return val
|
||||||
}
|
}
|
||||||
|
|
||||||
readShort() {
|
readShort() {
|
||||||
let ptr = this.seq.readBytes(2)
|
const ptr = this.seq.readBytes(2)
|
||||||
let val = sys.peek(ptr) | (sys.peek(ptr + 1) << 8)
|
const val = sys.peek(ptr) | (sys.peek(ptr + 1) << 8)
|
||||||
sys.free(ptr)
|
sys.free(ptr)
|
||||||
return val
|
return val
|
||||||
}
|
}
|
||||||
|
|
||||||
readInt() {
|
readInt() {
|
||||||
let ptr = this.seq.readBytes(4)
|
const 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 val = sys.peek(ptr) | (sys.peek(ptr + 1) << 8) | (sys.peek(ptr + 2) << 16) | (sys.peek(ptr + 3) << 24)
|
||||||
sys.free(ptr)
|
sys.free(ptr)
|
||||||
return val
|
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) {
|
unread(diff) {
|
||||||
let newSkipLen = this.seq.getReadCount() - diff
|
const newSkipLen = this.seq.getReadCount() - diff
|
||||||
this.seq.prepare(this.path)
|
this.seq.prepare(this.path)
|
||||||
this.seq.skip(newSkipLen)
|
this.seq.skip(newSkipLen)
|
||||||
}
|
}
|
||||||
|
rewind() { this.seq.prepare(this.path) }
|
||||||
rewind() {
|
getReadCount() { return this.seq.getReadCount() }
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filebuf = new SequentialFileBuffer(_G.shell.resolvePathInput(exec_args[1]).full)
|
||||||
// Read TAD chunk header to determine format
|
|
||||||
let filebuf = new SequentialFileBuffer(_G.shell.resolvePathInput(exec_args[1]).full)
|
|
||||||
const FILE_SIZE = filebuf.length
|
const FILE_SIZE = filebuf.length
|
||||||
|
|
||||||
if (FILE_SIZE < 7) {
|
if (FILE_SIZE < 7) {
|
||||||
@@ -114,12 +68,12 @@ if (FILE_SIZE < 7) {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read first chunk header (standalone TAD format: no TAV wrapper)
|
// Peek the first chunk header so we know the chunk size for the rough bytes-
|
||||||
let firstSampleCount = filebuf.readShort()
|
// to-seconds conversion shown in the progress bar.
|
||||||
let firstMaxIndex = filebuf.readByte()
|
const firstSampleCount = filebuf.readShort()
|
||||||
let firstPayloadSize = filebuf.readInt()
|
const firstMaxIndex = filebuf.readByte()
|
||||||
|
const firstPayloadSize = filebuf.readInt()
|
||||||
|
|
||||||
// Validate first chunk
|
|
||||||
if (firstSampleCount < 0 || firstSampleCount > 65536) {
|
if (firstSampleCount < 0 || firstSampleCount > 65536) {
|
||||||
serial.println(`ERROR: Invalid sample count ${firstSampleCount}. File may be corrupted.`)
|
serial.println(`ERROR: Invalid sample count ${firstSampleCount}. File may be corrupted.`)
|
||||||
return 1
|
return 1
|
||||||
@@ -133,148 +87,72 @@ if (firstPayloadSize < 1 || firstPayloadSize > 65536) {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rewind to start
|
|
||||||
filebuf.rewind()
|
filebuf.rewind()
|
||||||
|
|
||||||
// Calculate approximate frame info
|
const AVG_CHUNK_SIZE = 7 + firstPayloadSize
|
||||||
const AVG_CHUNK_SIZE = 7 + firstPayloadSize // TAD header (2+1+4) + payload
|
const SAMPLE_RATE = 32000
|
||||||
const SAMPLE_RATE = 32000
|
const bufRealTimeLen = Math.floor((firstSampleCount / SAMPLE_RATE) * 1000)
|
||||||
const bufRealTimeLen = Math.floor((firstSampleCount / SAMPLE_RATE) * 1000) // milliseconds per chunk
|
|
||||||
|
|
||||||
if (dumpCoeffs) {
|
if (dumpCoeffs) {
|
||||||
serial.println(`TAD Coefficient Dump Mode`)
|
serial.println(`TAD Coefficient Dump Mode`)
|
||||||
serial.println(`File: ${filebuf.file.name}`)
|
serial.println(`File: ${filebuf.file.name}`)
|
||||||
serial.println(`First chunk header:`)
|
serial.println(`First chunk: ${firstSampleCount} samples, Q${firstMaxIndex}, ${firstPayloadSize} bytes payload`)
|
||||||
serial.println(` Sample Count: ${firstSampleCount}`)
|
|
||||||
serial.println(` Max Index: ${firstMaxIndex}`)
|
|
||||||
serial.println(` Payload Size: ${firstPayloadSize} bytes`)
|
|
||||||
serial.println(`Chunk Duration: ${bufRealTimeLen} ms`)
|
serial.println(`Chunk Duration: ${bufRealTimeLen} ms`)
|
||||||
serial.println(``)
|
serial.println(``)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let bytes_left = FILE_SIZE
|
||||||
let bytes_left = FILE_SIZE
|
|
||||||
let decodedLength = 0
|
let decodedLength = 0
|
||||||
let chunkNumber = 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
|
|
||||||
|
|
||||||
function bytesToSec(i) {
|
function bytesToSec(i) {
|
||||||
// Approximate: use first chunk's ratio
|
|
||||||
return Math.round((i / FILE_SIZE) * (FILE_SIZE / AVG_CHUNK_SIZE) * (bufRealTimeLen / 1000))
|
return Math.round((i / FILE_SIZE) * (FILE_SIZE / AVG_CHUNK_SIZE) * (bufRealTimeLen / 1000))
|
||||||
}
|
}
|
||||||
|
|
||||||
function secToReadable(n) {
|
// Occupy the first idle playhead rather than always grabbing #0, so playback
|
||||||
let mins = ''+((n/60)|0)
|
// doesn't cut off audio already running on another playhead. Falls back to #0
|
||||||
let secs = ''+(n % 60)
|
// when all four are busy.
|
||||||
return `${mins.padStart(2,'0')}:${secs.padStart(2,'0')}`
|
const PLAYHEAD = audio.getFreePlayhead(0)
|
||||||
|
audio.resetParams(PLAYHEAD)
|
||||||
|
audio.purgeQueue(PLAYHEAD)
|
||||||
|
audio.setPcmMode(PLAYHEAD)
|
||||||
|
audio.setPcmQueueCapacityIndex(PLAYHEAD, 2)
|
||||||
|
const QUEUE_MAX = audio.getPcmQueueCapacity(PLAYHEAD)
|
||||||
|
audio.setMasterVolume(PLAYHEAD, 255)
|
||||||
|
audio.play(PLAYHEAD)
|
||||||
|
|
||||||
|
if (interactive) {
|
||||||
|
gui.audioInit({
|
||||||
|
title: `${filebuf.file.name} TAD Q${firstMaxIndex} ${SAMPLE_RATE/1000}kHz`,
|
||||||
|
tag: "TAD"
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
const QUEUE_MAX = audio.getPcmQueueCapacity(0)
|
|
||||||
audio.setMasterVolume(0, 255)
|
|
||||||
audio.play(0)
|
|
||||||
|
|
||||||
|
|
||||||
let stopPlay = false
|
let stopPlay = false
|
||||||
let errorlevel = 0
|
let errorlevel = 0
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (bytes_left > 0 && !stopPlay) {
|
while (bytes_left > 0 && !stopPlay) {
|
||||||
|
if (interactive && gui.audioIsExitRequested()) { stopPlay = true; break }
|
||||||
|
|
||||||
if (interactive) {
|
const sampleCount = filebuf.readShort()
|
||||||
sys.poke(-40, 1)
|
const maxIndex = filebuf.readByte()
|
||||||
if (sys.peek(-41) == 67) { // Backspace key
|
const payloadSize = filebuf.readInt()
|
||||||
stopPlay = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
if (sampleCount < 0 || sampleCount > 65536) {
|
||||||
serial.println(`ERROR: Chunk ${chunkNumber}: Invalid sample count ${sampleCount}. File may be corrupted.`)
|
serial.println(`ERROR: Chunk ${chunkNumber}: Invalid sample count ${sampleCount}.`)
|
||||||
errorlevel = 1
|
errorlevel = 1; break
|
||||||
break
|
|
||||||
}
|
}
|
||||||
if (maxIndex < 0 || maxIndex > 255) {
|
if (maxIndex < 0 || maxIndex > 255) {
|
||||||
serial.println(`ERROR: Chunk ${chunkNumber}: Invalid max index ${maxIndex}. File may be corrupted.`)
|
serial.println(`ERROR: Chunk ${chunkNumber}: Invalid max index ${maxIndex}.`)
|
||||||
errorlevel = 1
|
errorlevel = 1; break
|
||||||
break
|
|
||||||
}
|
}
|
||||||
if (payloadSize < 1 || payloadSize > 65536) {
|
if (payloadSize < 1 || payloadSize > 65536) {
|
||||||
serial.println(`ERROR: Chunk ${chunkNumber}: Invalid payload size ${payloadSize}. File may be corrupted.`)
|
serial.println(`ERROR: Chunk ${chunkNumber}: Invalid payload size ${payloadSize}.`)
|
||||||
errorlevel = 1
|
errorlevel = 1; break
|
||||||
break
|
|
||||||
}
|
}
|
||||||
if (payloadSize + 7 > bytes_left) {
|
if (payloadSize + 7 > bytes_left) {
|
||||||
serial.println(`ERROR: Chunk ${chunkNumber}: Chunk size ${payloadSize + 7} exceeds remaining file size ${bytes_left}`)
|
serial.println(`ERROR: Chunk ${chunkNumber}: Chunk size exceeds remaining file size.`)
|
||||||
errorlevel = 1
|
errorlevel = 1; break
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dumpCoeffs && chunkNumber < 3) {
|
if (dumpCoeffs && chunkNumber < 3) {
|
||||||
@@ -282,80 +160,59 @@ try {
|
|||||||
serial.println(` Sample Count: ${sampleCount}`)
|
serial.println(` Sample Count: ${sampleCount}`)
|
||||||
serial.println(` Max Index: ${maxIndex}`)
|
serial.println(` Max Index: ${maxIndex}`)
|
||||||
serial.println(` Payload Size: ${payloadSize} bytes`)
|
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
|
// Read entire chunk (header + payload) into TAD input buffer.
|
||||||
// This allows reading the complete chunk (header + payload) in one call
|
|
||||||
filebuf.unread(7)
|
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()
|
audio.tadDecode()
|
||||||
|
audio.tadUploadDecoded(PLAYHEAD, sampleCount)
|
||||||
if (dumpCoeffs && chunkNumber < 3) {
|
// After upload tadDecodedBin still holds the chunk until the next
|
||||||
// After decoding, the decoded PCMu8 samples are in tadDecodedBin
|
// tadDecode call, so it's safe to keep slicing samples out of it
|
||||||
serial.println(` Decoded ${sampleCount} samples`)
|
// during the playback wait below.
|
||||||
|
|
||||||
// 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)
|
|
||||||
|
|
||||||
if (!dumpCoeffs) {
|
if (!dumpCoeffs) {
|
||||||
// Sleep for the duration of the audio chunk to pace playback
|
// TAD chunks are typically 1 s long, so feeding the visualiser
|
||||||
// This prevents uploading everything at once
|
// once would freeze it for ~1 s. Walk the chunk in 2048-sample
|
||||||
sys.sleep(bufRealTimeLen)
|
// 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
|
const chunkSize = 7 + payloadSize
|
||||||
let chunkSize = 7 + payloadSize
|
bytes_left -= chunkSize
|
||||||
bytes_left -= chunkSize
|
|
||||||
decodedLength += chunkSize
|
decodedLength += chunkSize
|
||||||
chunkNumber++
|
chunkNumber++
|
||||||
|
|
||||||
// Limit coefficient dump to first 3 chunks
|
|
||||||
if (dumpCoeffs && chunkNumber >= 3) {
|
if (dumpCoeffs && chunkNumber >= 3) {
|
||||||
serial.println(`... (remaining chunks omitted)`)
|
serial.println(`... (remaining chunks omitted)`)
|
||||||
// Keep playing but don't dump more
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} catch (e) {
|
||||||
catch (e) {
|
|
||||||
printerrln(e)
|
printerrln(e)
|
||||||
errorlevel = 1
|
errorlevel = 1
|
||||||
}
|
} finally {
|
||||||
finally {
|
if (interactive) gui.audioClose()
|
||||||
if (interactive) {
|
|
||||||
con.move(cy + 3, 1)
|
|
||||||
con.curs_set(1)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return errorlevel
|
return errorlevel
|
||||||
|
|||||||
21
assets/disk0/tvdos/bin/playtad.js.synopsis
Normal file
21
assets/disk0/tvdos/bin/playtad.js.synopsis
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "playtad",
|
||||||
|
"summary": "Play a TAD audio file",
|
||||||
|
"symbols": {
|
||||||
|
"interactive": { "kind": "option", "short": "-i", "summary": "Interactive mode (visualiser and progress bar)" },
|
||||||
|
"dump": { "kind": "option", "short": "-d", "summary": "Dump coefficients (diagnostic)" },
|
||||||
|
"options": { "kind": "group", "summary": "Options", "members": ["interactive", "dump"] },
|
||||||
|
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "TAD file to play" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "sequence",
|
||||||
|
"children": [
|
||||||
|
{ "type": "reference", "symbol": "file" },
|
||||||
|
{ "type": "repeat", "child": { "type": "reference", "symbol": "options" } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"constraints": [
|
||||||
|
{ "type": "conflicts", "symbols": ["interactive", "dump"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
1232
assets/disk0/tvdos/bin/playtaud.js
Normal file
1232
assets/disk0/tvdos/bin/playtaud.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,7 @@ const ADDRESSING_INTERNAL = 0x02
|
|||||||
const SND_BASE_ADDR = audio.getBaseAddr()
|
const SND_BASE_ADDR = audio.getBaseAddr()
|
||||||
const SND_MEM_ADDR = audio.getMemAddr()
|
const SND_MEM_ADDR = audio.getMemAddr()
|
||||||
const pcm = require("pcm")
|
const pcm = require("pcm")
|
||||||
const AUDIO_DEVICE = 0
|
const AUDIO_DEVICE = audio.getFreePlayhead(0)
|
||||||
const MP2_FRAME_SIZE = [144,216,252,288,360,432,504,576,720,864,1008,1152,1440,1728]
|
const MP2_FRAME_SIZE = [144,216,252,288,360,432,504,576,720,864,1008,1152,1440,1728]
|
||||||
const TAV_TEMPORAL_LEVELS = 2
|
const TAV_TEMPORAL_LEVELS = 2
|
||||||
|
|
||||||
@@ -1746,7 +1746,9 @@ try {
|
|||||||
tadInitialised = true
|
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.tadDecode()
|
||||||
audio.tadUploadDecoded(AUDIO_DEVICE, sampleLen)
|
audio.tadUploadDecoded(AUDIO_DEVICE, sampleLen)
|
||||||
}
|
}
|
||||||
|
|||||||
23
assets/disk0/tvdos/bin/playtav.js.synopsis
Normal file
23
assets/disk0/tvdos/bin/playtav.js.synopsis
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "playtav",
|
||||||
|
"summary": "Play a TAV video file",
|
||||||
|
"symbols": {
|
||||||
|
"interactive": { "kind": "option", "short": "-i", "summary": "Interactive mode" },
|
||||||
|
"filmGrain": {
|
||||||
|
"kind": "option",
|
||||||
|
"long": "--filter-film-grain",
|
||||||
|
"summary": "Apply a film-grain filter",
|
||||||
|
"value": { "name": "LEVEL", "type": "integer", "required": false, "summary": "Grain intensity" }
|
||||||
|
},
|
||||||
|
"options": { "kind": "group", "summary": "Options", "members": ["interactive", "filmGrain"] },
|
||||||
|
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "TAV file to play" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "sequence",
|
||||||
|
"children": [
|
||||||
|
{ "type": "reference", "symbol": "file" },
|
||||||
|
{ "type": "repeat", "child": { "type": "reference", "symbol": "options" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -100,10 +100,14 @@ graphics.clearPixels(0)
|
|||||||
graphics.clearPixels2(0)
|
graphics.clearPixels2(0)
|
||||||
|
|
||||||
// Initialize audio
|
// Initialize audio
|
||||||
audio.resetParams(0)
|
// Occupy the first idle playhead rather than always grabbing #0, so playback
|
||||||
audio.purgeQueue(0)
|
// doesn't cut off audio already running on another playhead. Falls back to #0
|
||||||
audio.setPcmMode(0)
|
// when all four are busy.
|
||||||
audio.setMasterVolume(0, 255)
|
const PLAYHEAD = audio.getFreePlayhead(0)
|
||||||
|
audio.resetParams(PLAYHEAD)
|
||||||
|
audio.purgeQueue(PLAYHEAD)
|
||||||
|
audio.setPcmMode(PLAYHEAD)
|
||||||
|
audio.setMasterVolume(PLAYHEAD, 255)
|
||||||
|
|
||||||
// set colour zero as half-opaque black
|
// set colour zero as half-opaque black
|
||||||
graphics.setPalette(0, 0, 0, 0, 9)
|
graphics.setPalette(0, 0, 0, 0, 9)
|
||||||
@@ -791,14 +795,14 @@ try {
|
|||||||
if (isInterlaced) {
|
if (isInterlaced) {
|
||||||
// fire audio after frame 1
|
// fire audio after frame 1
|
||||||
if (!audioFired && frameCount > 0) {
|
if (!audioFired && frameCount > 0) {
|
||||||
audio.play(0)
|
audio.play(PLAYHEAD)
|
||||||
audioFired = true
|
audioFired = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// fire audio after frame 0
|
// fire audio after frame 0
|
||||||
if (!audioFired) {
|
if (!audioFired) {
|
||||||
audio.play(0)
|
audio.play(PLAYHEAD)
|
||||||
audioFired = true
|
audioFired = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -900,8 +904,8 @@ finally {
|
|||||||
if (PREV_FIELD_BUFFER > 0) sys.free(PREV_FIELD_BUFFER)
|
if (PREV_FIELD_BUFFER > 0) sys.free(PREV_FIELD_BUFFER)
|
||||||
if (NEXT_FIELD_BUFFER > 0) sys.free(NEXT_FIELD_BUFFER)
|
if (NEXT_FIELD_BUFFER > 0) sys.free(NEXT_FIELD_BUFFER)
|
||||||
|
|
||||||
audio.stop(0)
|
audio.stop(PLAYHEAD)
|
||||||
audio.purgeQueue(0)
|
audio.purgeQueue(PLAYHEAD)
|
||||||
|
|
||||||
if (interactive) {
|
if (interactive) {
|
||||||
//con.clear()
|
//con.clear()
|
||||||
|
|||||||
18
assets/disk0/tvdos/bin/playtev.js.synopsis
Normal file
18
assets/disk0/tvdos/bin/playtev.js.synopsis
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "playtev",
|
||||||
|
"summary": "Play a TEV video file",
|
||||||
|
"symbols": {
|
||||||
|
"interactive": { "kind": "option", "short": "-i", "summary": "Interactive mode" },
|
||||||
|
"debugMv": { "kind": "option", "long": "-debug-mv", "summary": "Show motion-vector debug overlay" },
|
||||||
|
"options": { "kind": "group", "summary": "Options", "members": ["interactive", "debugMv"] },
|
||||||
|
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "TEV file to play" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "sequence",
|
||||||
|
"children": [
|
||||||
|
{ "type": "reference", "symbol": "file" },
|
||||||
|
{ "type": "repeat", "child": { "type": "reference", "symbol": "options" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -307,7 +307,7 @@ for (let i = 0; i < cueElements.length; i++) {
|
|||||||
// Execute the player with modified environment
|
// Execute the player with modified environment
|
||||||
exec_args[1] = targetPath
|
exec_args[1] = targetPath
|
||||||
if (playerFile) {
|
if (playerFile) {
|
||||||
let playerPath = `A:\\tvdos\\bin\\${playerFile}.js`
|
let playerPath = `A:${_TVDOS.variables.DOSDIR}/bin/${playerFile}.js`
|
||||||
if (files.open(playerPath).exists) {
|
if (files.open(playerPath).exists) {
|
||||||
eval(files.readText(playerPath))
|
eval(files.readText(playerPath))
|
||||||
} else {
|
} else {
|
||||||
@@ -334,7 +334,7 @@ for (let i = 0; i < cueElements.length; i++) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Execute the appropriate player
|
// 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) {
|
if (!files.open(playerPath).exists) {
|
||||||
serial.println(`Warning: Player script not found: ${playerPath}`)
|
serial.println(`Warning: Player script not found: ${playerPath}`)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1,329 +1,193 @@
|
|||||||
// usage: playwav audiofile.wav [/i]
|
// playwav — WAV (LPCM/ADPCM) player with the shared playgui visualiser.
|
||||||
let fileeeee = files.open(_G.shell.resolvePathInput(exec_args[1]).full)
|
// Usage: playwav <file.wav> [-i]
|
||||||
let filename = fileeeee.fullPath
|
|
||||||
|
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) }
|
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) {
|
function GCD(a, b) {
|
||||||
a = Math.abs(a)
|
a = Math.abs(a); b = Math.abs(b)
|
||||||
b = Math.abs(b)
|
if (b > a) { const t = a; a = b; b = t }
|
||||||
if (b > a) {var temp = a; a = b; b = temp}
|
|
||||||
while (true) {
|
while (true) {
|
||||||
if (b == 0) return a
|
if (b === 0) return a
|
||||||
a %= b
|
a %= b
|
||||||
if (a == 0) return b
|
if (a === 0) return b
|
||||||
b %= a
|
b %= a
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function LCM(a, b) { return (!a || !b) ? 0 : Math.abs((a * b) / GCD(a, b)) }
|
||||||
|
|
||||||
function LCM(a, b) {
|
seqread.prepare(filePath)
|
||||||
return (!a || !b) ? 0 : Math.abs((a * b) / GCD(a, b))
|
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")
|
||||||
|
|
||||||
|
|
||||||
//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")
|
|
||||||
}
|
|
||||||
|
|
||||||
let BLOCK_SIZE = 0
|
let BLOCK_SIZE = 0
|
||||||
let INFILE_BLOCK_SIZE = 0
|
let INFILE_BLOCK_SIZE = 0
|
||||||
const QUEUE_MAX = 8 // according to the spec
|
const QUEUE_MAX = 8
|
||||||
|
|
||||||
let pcmType;
|
let pcmType, nChannels, samplingRate, blockSize, bitsPerSample, byterate
|
||||||
let nChannels;
|
let adpcmSamplesPerBlock
|
||||||
let samplingRate;
|
let readPtr, decodePtr
|
||||||
let blockSize;
|
const comments = {}
|
||||||
let bitsPerSample;
|
|
||||||
let byterate;
|
|
||||||
let comments = {};
|
|
||||||
let adpcmSamplesPerBlock;
|
|
||||||
let readPtr = undefined
|
|
||||||
let decodePtr = undefined
|
|
||||||
|
|
||||||
function bytesToSec(i) {
|
function bytesToSec(i) {
|
||||||
if (adpcmSamplesPerBlock) {
|
if (adpcmSamplesPerBlock) {
|
||||||
let newByteRate = samplingRate
|
const generatedSamples = i / blockSize * adpcmSamplesPerBlock
|
||||||
let generatedSamples = i / blockSize * adpcmSamplesPerBlock
|
return generatedSamples / samplingRate
|
||||||
return generatedSamples / newByteRate
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return i / byterate
|
|
||||||
}
|
}
|
||||||
|
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() {
|
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 (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 "playable!"
|
||||||
}
|
}
|
||||||
// @return decoded sample length (not count!)
|
|
||||||
function decodeInfilePcm(inPtr, outPtr, inputLen) {
|
function decodeInfilePcm(inPtr, outPtr, inputLen) {
|
||||||
// LPCM
|
if (pcmType === 1)
|
||||||
if (1 == pcmType)
|
|
||||||
return pcm.decodeLPCM(inPtr, outPtr, inputLen, { nChannels, bitsPerSample, samplingRate, blockSize })
|
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 })
|
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
|
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
|
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
|
try {
|
||||||
if ("fmt " == chunkName) {
|
while (!stopPlay && seqread.getReadCount() < FILE_SIZE - 8) {
|
||||||
pcmType = seqread.readShort()
|
const chunkName = seqread.readFourCC()
|
||||||
nChannels = seqread.readShort()
|
const chunkSize = seqread.readInt()
|
||||||
samplingRate = seqread.readInt()
|
printdbg(`Reading '${chunkName}' at ${seqread.getReadCount() - 8}`)
|
||||||
byterate = seqread.readInt()
|
|
||||||
blockSize = seqread.readShort()
|
if (chunkName === "fmt ") {
|
||||||
bitsPerSample = seqread.readShort()
|
pcmType = seqread.readShort()
|
||||||
if (pcmType != 2) {
|
nChannels = seqread.readShort()
|
||||||
seqread.skip(chunkSize - 16)
|
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)
|
||||||
|
|
||||||
|
// Occupy the first idle playhead rather than always grabbing #0, so
|
||||||
|
// playback doesn't cut off audio already running on another playhead.
|
||||||
|
// Falls back to #0 when all four are busy.
|
||||||
|
const PLAYHEAD = audio.getFreePlayhead(0)
|
||||||
|
audio.resetParams(PLAYHEAD)
|
||||||
|
audio.purgeQueue(PLAYHEAD)
|
||||||
|
audio.setPcmMode(PLAYHEAD)
|
||||||
|
audio.setMasterVolume(PLAYHEAD, 255)
|
||||||
|
|
||||||
|
let readLength = 1
|
||||||
|
while (!stopPlay && seqread.getReadCount() < startOffset + chunkSize && readLength > 0) {
|
||||||
|
if (interactive && gui.audioIsExitRequested()) { stopPlay = true; break }
|
||||||
|
|
||||||
|
if (audio.getPosition(PLAYHEAD) <= 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(PLAYHEAD, decodePtr, decodedSampleLength, 0)
|
||||||
|
audio.setSampleUploadLength(PLAYHEAD, decodedSampleLength)
|
||||||
|
audio.startSampleUpload(PLAYHEAD)
|
||||||
|
|
||||||
|
sys.spin()
|
||||||
|
}
|
||||||
|
audio.play(PLAYHEAD)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
else {
|
||||||
seqread.skip(2)
|
seqread.skip(chunkSize)
|
||||||
adpcmSamplesPerBlock = seqread.readShort()
|
|
||||||
seqread.skip(chunkSize - (16 + 4))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// define BLOCK_SIZE as integer multiple of blockSize, for LPCM
|
sys.spin()
|
||||||
// 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()
|
|
||||||
}
|
}
|
||||||
else if ("LIST" == chunkName) {
|
} catch (e) {
|
||||||
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(0, 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) {
|
|
||||||
printerrln(e)
|
printerrln(e)
|
||||||
errorlevel = 1
|
errorlevel = 1
|
||||||
}
|
} finally {
|
||||||
finally {
|
if (readPtr !== undefined) sys.free(readPtr)
|
||||||
//audio.stop(0)
|
|
||||||
if (readPtr !== undefined) sys.free(readPtr)
|
|
||||||
if (decodePtr !== undefined) sys.free(decodePtr)
|
if (decodePtr !== undefined) sys.free(decodePtr)
|
||||||
|
if (interactive) gui.audioClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
return errorlevel
|
return errorlevel
|
||||||
|
|||||||
17
assets/disk0/tvdos/bin/playwav.js.synopsis
Normal file
17
assets/disk0/tvdos/bin/playwav.js.synopsis
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "playwav",
|
||||||
|
"summary": "Play a WAV audio file",
|
||||||
|
"symbols": {
|
||||||
|
"interactive": { "kind": "option", "short": "-i", "summary": "Interactive mode (visualiser)" },
|
||||||
|
"options": { "kind": "group", "summary": "Options", "members": ["interactive"] },
|
||||||
|
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "WAV file to play" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "sequence",
|
||||||
|
"children": [
|
||||||
|
{ "type": "reference", "symbol": "file" },
|
||||||
|
{ "type": "repeat", "child": { "type": "reference", "symbol": "options" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
9
assets/disk0/tvdos/bin/printfile.js.synopsis
Normal file
9
assets/disk0/tvdos/bin/printfile.js.synopsis
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "printfile",
|
||||||
|
"summary": "Print a text file with line numbers",
|
||||||
|
"symbols": {
|
||||||
|
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "Text file to print" }
|
||||||
|
},
|
||||||
|
"synopsis": { "type": "reference", "symbol": "file" }
|
||||||
|
}
|
||||||
180
assets/disk0/tvdos/bin/synopsis.js
Normal file
180
assets/disk0/tvdos/bin/synopsis.js
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
/*
|
||||||
|
* synopsis.js -- system-wide help / tldr.
|
||||||
|
*
|
||||||
|
* Prints a command's human-targeted one-line summary and an auto-generated
|
||||||
|
* synopsis (usage line, arguments, options and constraints) derived from its
|
||||||
|
* TSF .synopsis document via the `synopsis` library (synopsis.mjs).
|
||||||
|
*
|
||||||
|
* Usage: synopsis PROGRAM
|
||||||
|
* synopsis (describes itself)
|
||||||
|
*/
|
||||||
|
|
||||||
|
let syn
|
||||||
|
try {
|
||||||
|
syn = require("synopsis")
|
||||||
|
} catch (e) {
|
||||||
|
printerrln("synopsis: the 'synopsis' library is not installed")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const termW = (con.getmaxyx()[1]) || 80
|
||||||
|
|
||||||
|
// Word-wrap plain text to `width`, returning an array of lines.
|
||||||
|
function wrap(text, width) {
|
||||||
|
if (!text) return []
|
||||||
|
if (width < 8) width = 8
|
||||||
|
let words = ('' + text).split(/\s+/).filter(function (w) { return w.length })
|
||||||
|
let lines = [], line = ''
|
||||||
|
words.forEach(function (w) {
|
||||||
|
if (line.length === 0) line = w
|
||||||
|
else if (line.length + 1 + w.length <= width) line += ' ' + w
|
||||||
|
else { lines.push(line); line = w }
|
||||||
|
})
|
||||||
|
if (line.length) lines.push(line)
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print a "left summary" row: the summary is wrapped into the right column and
|
||||||
|
// continuation lines are aligned under it. An over-wide `left` spills onto its
|
||||||
|
// own line.
|
||||||
|
function row(left, summary, leftW, indent) {
|
||||||
|
let pad = ' '.repeat(indent)
|
||||||
|
let gap = 2
|
||||||
|
let sumW = Math.max(8, termW - indent - leftW - gap)
|
||||||
|
let wrapped = wrap(summary, sumW)
|
||||||
|
if (left.length > leftW) {
|
||||||
|
println(pad + left)
|
||||||
|
wrapped.forEach(function (l) { println(pad + ' '.repeat(leftW + gap) + l) })
|
||||||
|
} else {
|
||||||
|
let first = wrapped.length ? wrapped[0] : ''
|
||||||
|
println(pad + left + ' '.repeat(leftW - left.length + gap) + first)
|
||||||
|
for (let i = 1; i < wrapped.length; i++)
|
||||||
|
println(pad + ' '.repeat(leftW + gap) + wrapped[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- resolve the target ----------------------------------------------------
|
||||||
|
let token = (exec_args[1] !== undefined && exec_args[1] !== '') ? exec_args[1] : "synopsis"
|
||||||
|
|
||||||
|
let model = syn.getModel(token)
|
||||||
|
if (!model) {
|
||||||
|
printerrln(`synopsis: no synopsis found for '${token}'`)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display name for a referenced symbol id (used by the constraints section).
|
||||||
|
function symDisplay(id) {
|
||||||
|
let s = model.symbols[id]
|
||||||
|
if (!s) return id
|
||||||
|
if (s.kind === 'option') return s.long || s.short || id
|
||||||
|
if (s.kind === 'positional') return s.name || id
|
||||||
|
if (s.kind === 'subcommand') return s.name || id
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append a "{a, b, c}" hint of permitted values to a summary, if any.
|
||||||
|
function withValues(summary, values) {
|
||||||
|
if (!values || !values.length) return summary || ''
|
||||||
|
let vs = values.map(function (v) {
|
||||||
|
return (v && typeof v === 'object' && ('value' in v)) ? v.value : v
|
||||||
|
}).join(', ')
|
||||||
|
return (summary ? summary + ' ' : '') + '{' + vs + '}'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Left-column text for an option, e.g. "-o, --output=FILE".
|
||||||
|
function optionLeft(e) {
|
||||||
|
let forms = []
|
||||||
|
if (e.short) forms.push(e.short)
|
||||||
|
if (e.long) forms.push(e.long)
|
||||||
|
let s = forms.join(', ')
|
||||||
|
if (e.hasValue) {
|
||||||
|
let vn = (e.value && (e.value.name || e.value.type)) || 'VALUE'
|
||||||
|
if (e.long) s += e.valueRequired ? '=' + vn : '[=' + vn + ']'
|
||||||
|
else s += e.valueRequired ? ' ' + vn : ' [' + vn + ']'
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
function optionSummary(e) {
|
||||||
|
let s = e.summary || ''
|
||||||
|
if (e.negatable) s += (s ? ' ' : '') + '(negatable)'
|
||||||
|
if (e.value && e.value.values && e.value.values.length) s = withValues(s, e.value.values)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
function constraintText(c) {
|
||||||
|
let names = (c.symbols || []).map(symDisplay)
|
||||||
|
if (c.type === 'conflicts') return 'Mutually exclusive: ' + names.join(', ')
|
||||||
|
if (c.type === 'requires') return symDisplay(c.subject) + ' requires ' + (c.targets || []).map(symDisplay).join(', ')
|
||||||
|
if (c.type === 'implies') return symDisplay(c.subject) + ' implies ' + (c.targets || []).map(symDisplay).join(', ')
|
||||||
|
if (c.type === 'cardinality') {
|
||||||
|
let mn = c.minimum, mx = c.maximum, q
|
||||||
|
if (mn === 1 && mx === 1) q = 'Exactly one of'
|
||||||
|
else if (mn === 1 && mx === undefined) q = 'At least one of'
|
||||||
|
else if (mn === undefined && mx === 1) q = 'At most one of'
|
||||||
|
else q = `Between ${mn} and ${mx} of`
|
||||||
|
return q + ': ' + names.join(', ')
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- gather rows -----------------------------------------------------------
|
||||||
|
let argEntries = model.positionals.map(function (p) {
|
||||||
|
return { left: (p.name || p.id) + (p.repeatable ? '...' : ''), summary: withValues(p.summary, p.values) }
|
||||||
|
})
|
||||||
|
let optEntries = model.flags.map(function (e) {
|
||||||
|
return { left: optionLeft(e), summary: optionSummary(e) }
|
||||||
|
})
|
||||||
|
let subEntries = model.subcommands.map(function (s) {
|
||||||
|
return { left: s.name, summary: s.summary || '' }
|
||||||
|
})
|
||||||
|
|
||||||
|
// shared left-column width (capped so a long flag does not push everything out)
|
||||||
|
let leftW = 4
|
||||||
|
argEntries.concat(optEntries, subEntries).forEach(function (e) { if (e.left.length > leftW) leftW = e.left.length })
|
||||||
|
if (leftW > 30) leftW = 30
|
||||||
|
|
||||||
|
// ---- render ----------------------------------------------------------------
|
||||||
|
let title = model.name || token
|
||||||
|
println(model.summary ? `${title} - ${model.summary}` : title)
|
||||||
|
println()
|
||||||
|
|
||||||
|
let usage = syn.getUsage(token)
|
||||||
|
if (usage) {
|
||||||
|
println("Usage:")
|
||||||
|
println(" " + usage)
|
||||||
|
println()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model.description) {
|
||||||
|
wrap(model.description, termW).forEach(function (l) { println(l) })
|
||||||
|
println()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subEntries.length) {
|
||||||
|
println("Commands:")
|
||||||
|
subEntries.forEach(function (e) { row(e.left, e.summary, leftW, 4) })
|
||||||
|
println()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (argEntries.length) {
|
||||||
|
println("Arguments:")
|
||||||
|
argEntries.forEach(function (e) { row(e.left, e.summary, leftW, 4) })
|
||||||
|
println()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (optEntries.length) {
|
||||||
|
println("Options:")
|
||||||
|
optEntries.forEach(function (e) { row(e.left, e.summary, leftW, 4) })
|
||||||
|
println()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model.constraints && model.constraints.length) {
|
||||||
|
let lines = model.constraints.map(constraintText).filter(function (t) { return t })
|
||||||
|
if (lines.length) {
|
||||||
|
println("Constraints:")
|
||||||
|
lines.forEach(function (l) { println(" " + l) })
|
||||||
|
println()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
13
assets/disk0/tvdos/bin/synopsis.js.synopsis
Normal file
13
assets/disk0/tvdos/bin/synopsis.js.synopsis
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "synopsis",
|
||||||
|
"summary": "Print a command's summary and auto-generated synopsis",
|
||||||
|
"description": "Prints the one-line summary and an auto-generated usage line for PROGRAM, derived from its TSF .synopsis document, together with its arguments, options and constraints. With no PROGRAM, describes itself.",
|
||||||
|
"symbols": {
|
||||||
|
"program": { "kind": "positional", "type": "command", "name": "PROGRAM", "summary": "Command to describe; describes synopsis itself when omitted" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "optional",
|
||||||
|
"child": { "type": "reference", "symbol": "program" }
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,341 +1,172 @@
|
|||||||
if (!_G.TAUT) _G.TAUT = {};
|
if (!_G.TAUT) _G.TAUT = {};
|
||||||
let help = {}
|
let help = {}
|
||||||
|
|
||||||
|
let ts = require("typesetter")
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Tags:
|
Tags:
|
||||||
<b> - print the text in emphasis colour (colVoiceHdr aka 230)
|
<b> - print the text in emphasis colour (colVoiceHdr aka 230)
|
||||||
|
<s> - print the text in deemphasis colour (248)
|
||||||
<c> - centre the line. If the line spans multiple lines, centre each line
|
<c> - centre the line. If the line spans multiple lines, centre each line
|
||||||
<r> - align right
|
<r> - align right
|
||||||
<l> - align left
|
<l> - align left
|
||||||
<o> - create virtual typesetting box. Left anchor: where the text cursor is. Right anchor: end of the line
|
<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>)
|
µtone; - replace with the brand string (<col 211>Micro</col><col 239>tone</col>)
|
||||||
|
|
||||||
&bul; - replace with bullet (\u00F9)
|
&bul; - replace with bullet (\u00F9)
|
||||||
&ddot; - replace with double-dot (\u008419u)
|
&ddot; - replace with double-dot (\u008419u)
|
||||||
&mdot; - replace with BIGDOT (\u00FA)
|
&mdot; - replace with BIGDOT (\u00FA)
|
||||||
&updn; - up-down arrow (\u008418u)
|
&updn; - up-down arrow (\u008418u)
|
||||||
&udlr; - four direction arrow (\u008428u\u008429u)
|
&udlr; - four direction arrow (\u008428u\u008429u)
|
||||||
&keyoffsym; - pattern view key-off symbol (\u00A0\u00CD\u00CD\u00A1)
|
|
||||||
|
&keyoffsym; - pattern view key-off symbol (\u00A0\u00B1\u00B1\u00A1)
|
||||||
¬ecutsym; - pattern view note-cut symbol (\u00A4\u00A4\u00A4\u00A4)
|
¬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)
|
- nonbreakable space (only meaningful for typesetters)
|
||||||
­ - soft hyphen (only meaningful for typesetters)
|
­ - soft hyphen (only meaningful for typesetters)
|
||||||
|
|
||||||
default alignment: fully justified
|
default alignment: fully justified
|
||||||
*/
|
*/
|
||||||
|
|
||||||
let helpNotation = `<c>CONTROL NOTATON</c>
|
let helpNotation = `<c>CONTROL NOTATION</c>
|
||||||
|
<c>\u00B7${'\u00B8'.repeat(16)}\u00B9</c>
|
||||||
µtone; <O>shortcuts differentiate normal and shifted shortcuts.</O>
|
µ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 without shift-in</O>
|
||||||
&bul;<b>A</b>&ddot;<b>Z</b> : <O>alphabet with 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 key</O>
|
||||||
&bul;<b>^Q</b> : <O>hit 'q' with control and shift key</O>`
|
&bul;<b>^Q</b> : <O>hit 'q' with control and shift key</O>
|
||||||
|
`
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
let helpJam = `<c>NOTE JAMMING</c>
|
let helpJam = `<c>NOTE JAMMING</c>
|
||||||
|
<c>\u00B7${'\u00B8'.repeat(12)}\u00B9</c>
|
||||||
Push keys to play or insert notes.
|
Push keys to play or insert notes.
|
||||||
w e t y u
|
w e t y u
|
||||||
a s d f g h j k`
|
a s d f g h j k
|
||||||
|
`
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
let helpCommon = `<c>COMMON CONTROLS</c>
|
let helpCommon = `<c>COMMON CONTROLS</c>
|
||||||
|
<c>\u00B7${'\u00B8'.repeat(15)}\u00B9</c>
|
||||||
&bul;<b>!</b> : <O>show this help message</O>
|
&bul;<b>!</b> : <O>show this help message</O>
|
||||||
&bul;<b>Y</b> : <O>play the entire song from the current cue</O>
|
&bul;<b>Y</b> : <O>plays the entire song from the current cue</O>
|
||||||
&bul;<b>U</b> : <O>play the current cue then stop</O>
|
&bul;<b>U</b> : <O>plays the current cue then stop</O>
|
||||||
&bul;<b>I</b> : <O>play the current row</O>
|
&bul;<b>I</b> : <O>plays the current row</O>
|
||||||
&bul;<b>O</b> : <O>stop the playback</O>
|
&bul;<b>O</b> : <O>stops the playback</O>
|
||||||
&bul;<b>tab</b> : <O>switch forward a tab</O>
|
&bul;<b>tab</b> : <O>switchs forward a tab</O>
|
||||||
&bul;<b>TAB</b> : <O>switch backward a tab</O>
|
&bul;<b>TAB</b> : <O>switchs backward a tab</O>
|
||||||
&bul;<b>q</b> : <O>close µtone;</O>`
|
&bul;<b>q</b> : <O>closes µtone;</O>
|
||||||
|
`
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
let helpTimeline = `<c>TIMELINE VIEW</c>
|
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.
|
Timeline has two distinct modes: view and edit mode. Two modes are toggled using the space bar.
|
||||||
|
|
||||||
<b>VIEW MODE</b>
|
<b> VIEW MODE</b>
|
||||||
|
<b>\u00B7${'\u00B8'.repeat(9)}\u00B9</b>
|
||||||
&bul;Note jamming : <O>plays the note</O>
|
&bul;Note jamming : <O>plays the note</O>
|
||||||
&bul;<b>&udlr;</b> : <O>move the viewing cursor by voices and rows</O>
|
&bul;<b>&udlr;</b> : <O>moves the viewing cursor by voices and rows</O>
|
||||||
&bul;<b>pg&updn;</b> : <O>go to previous/next cue</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>toggle timeline view mode. W-most detailed, R-most abridged</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>toggle soloing of the selected voice</O>
|
&bul;<b>n</b> : <O>toggles soloing of the selected voice</O>
|
||||||
&bul;<b>m</b> : <O>toggle muting 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> EDIT MODE</b>
|
||||||
|
<b>\u00B7${'\u00B8'.repeat(9)}\u00B9</b>
|
||||||
&bul;Note jamming : <O>(note column) inserts the note</O>
|
&bul;Note jamming : <O>(note column) inserts the note</O>
|
||||||
&bul;<b>{</b>&mdot;<b>}</b> : <O>(note column) lower/raise a note by one octave (or period)</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) lower/raise a note by one unit</O>
|
&bul;<b>[</b>&mdot;<b>]</b> : <O>(note column) lowers/raises a note by one unit</O>
|
||||||
&bul;<b>=</b> : <O>(note column) insert a key-off &keyoffsym;</O>
|
&bul;<b>z</b> : <O>(note column) inserts a key-off &keyoffsym;</O>
|
||||||
&bul;<b>^</b> : <O>(note column) insert a note-cut ¬ecutsym;</O>
|
&bul;<b>x</b> : <O>(note column) inserts a note-cut ¬ecutsym;</O>
|
||||||
&bul;<b>.</b> : <O>remove a symbol on the selected column</O>
|
&bul;<b>.</b> : <O>clears fields</O>
|
||||||
&bul;<b>bksp</b> : <O>delete one character on the selected column</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>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>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>(panning column) slide left/right</O>
|
||||||
&bul;<b>-</b>&mdot;<b>=</b> : <O>(vol/pan col) fine slide down/up</O>
|
&bul;<b>-</b>&mdot;<b>=</b> : <O>(vol/pan col) fine slide down/up</O>
|
||||||
&bul;<b>&udlr;</b> : <O>move the viewing cursor by columns and rows</O>
|
&bul;<b>&udlr;</b> : <O>moves the viewing cursor by columns and rows</O>
|
||||||
&bul;<b>pg&updn;</b> : <O>go to previous/next cue</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
|
// assemble help text pieces to complete help message
|
||||||
|
|
||||||
const SCRW = con.getmaxyx()[1]
|
const HRULE = '<s>' + '\u00B3'.repeat(_G.TAUT.HELPMSG_WIDTH) + '</s>\n'
|
||||||
|
|
||||||
// Display-command palette. taut.js's popup uses (HELP_COL_TEXT on background) as the
|
// taut.js's popup uses (HELP_COL_TEXT on background) as the default colour pair.
|
||||||
// default colour pair, so embedded `\x1B[38;5;Nm` codes switch foreground only.
|
// The shared typesetter module owns the palette and the markup expander.
|
||||||
const HELP_COL_TEXT = 239 // popup body default (== colWHITE)
|
function typeset(text) {
|
||||||
const HELP_COL_EMPH = 230 // <b>...</b> highlight (== colVoiceHdr)
|
return ts.typeset(text, _G.TAUT.HELPMSG_WIDTH)
|
||||||
const HELP_COL_BRAND = 211 // first half of "Microtone"
|
|
||||||
const HELP_COL_BRAND_DIM = 239 // second half of "Microtone"
|
|
||||||
|
|
||||||
const fgEsc = (n) => `\x1B[38;5;${n}m`
|
|
||||||
const ESC_DEFAULT = fgEsc(HELP_COL_TEXT)
|
|
||||||
const ESC_EMPH = fgEsc(HELP_COL_EMPH)
|
|
||||||
const MICROTONE = `${fgEsc(HELP_COL_BRAND)}Micro${fgEsc(HELP_COL_BRAND_DIM)}tone${ESC_DEFAULT}`
|
|
||||||
|
|
||||||
// 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\u00CD\u00CD\u00A1')
|
|
||||||
.replaceAll('¬ecutsym;', '\u00A4\u00A4\u00A4\u00A4')
|
|
||||||
.replaceAll(' ', '\u007F')
|
|
||||||
.replaceAll('­', '')
|
|
||||||
.replaceAll('<', '<')
|
|
||||||
.replaceAll('>', '>')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 (`..u`) : 1 visible char
|
|
||||||
// - non-breaking space ( ) : 1 visible char (consumed as part of a word)
|
|
||||||
// - soft hyphen () : 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 <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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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) {
|
|
||||||
if (line.length === 0) return [' '.repeat(width)]
|
|
||||||
|
|
||||||
let alignment = '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) {
|
|
||||||
text = expandEntities(text)
|
|
||||||
const out = []
|
|
||||||
for (const srcLine of text.split('\n')) {
|
|
||||||
for (const outLine of typesetSourceLine(srcLine, width)) out.push(outLine)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
function typeset(text, customWidth) {
|
|
||||||
let typesetWidth = customWidth
|
|
||||||
if (typesetWidth === undefined) typesetWidth = _G.TAUT.HELPMSG_WIDTH
|
|
||||||
if (typesetWidth === undefined) {
|
|
||||||
const currentPosX = con.getyx()[1] // 1-indexed
|
|
||||||
typesetWidth = SCRW - currentPosX + 1
|
|
||||||
}
|
|
||||||
return typesetText(text, typesetWidth)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let helpMessages = [ // index: taut.js PANEL_NAMES
|
let helpMessages = [ // index: taut.js PANEL_NAMES
|
||||||
[helpJam, helpTimeline, helpCommon, helpNotation].join('\n\n'),
|
/* Timeline */[helpJam, helpTimeline, helpCommon, helpNotation].join(HRULE),
|
||||||
[helpCommon, helpNotation].join('\n\n'), // placeholder
|
/* Cues */[helpCommon, helpNotation].join(HRULE), // placeholder
|
||||||
[helpCommon, helpNotation].join('\n\n'), // placeholder
|
/* Patterns */[helpCommon, helpNotation].join(HRULE), // placeholder
|
||||||
[helpCommon, helpNotation].join('\n\n'), // placeholder
|
/* Samples */[helpCommon, helpNotation].join(HRULE), // placeholder
|
||||||
[helpCommon, helpNotation].join('\n\n'), // placeholder
|
/* Instruments */[helpCommon, helpNotation].join(HRULE), // placeholder
|
||||||
[helpCommon, helpNotation].join('\n\n'), // placeholder
|
/* Project */[helpProjectFlags, helpCommon, helpNotation].join(HRULE), // placeholder
|
||||||
[helpCommon, helpNotation].join('\n\n'), // placeholder
|
/* File */[helpCommon, helpNotation].join(HRULE), // placeholder
|
||||||
]
|
]
|
||||||
|
|
||||||
help.MSG_BY_TABS = helpMessages.map(it => typeset(it))
|
help.MSG_BY_TABS = helpMessages.map(it => typeset(it))
|
||||||
help.typeset = typeset
|
help.typeset = typeset
|
||||||
help.COL_TEXT = HELP_COL_TEXT
|
help.COL_TEXT = ts.COL_TEXT
|
||||||
help.COL_EMPH = HELP_COL_EMPH
|
help.COL_EMPH = ts.COL_EMPH
|
||||||
|
|
||||||
if (!_G.TAUT.HELPMSG) _G.TAUT.HELPMSG=help;
|
if (!_G.TAUT.HELPMSG) _G.TAUT.HELPMSG=help;
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* TAUT Sample Editor
|
* TAUT Sample Editor (stub)
|
||||||
* Sub-program launched by taut.js when the Samples tab is active.
|
* Sub-program launched from taut.js's Samples viewer. Rows 1-3 are owned by
|
||||||
* Rows 1-3 are owned by the parent; this program draws rows 4+.
|
* the parent; this program draws rows 4+.
|
||||||
*
|
*
|
||||||
* exec_args[1] = path to .taud file
|
* exec_args:
|
||||||
* Sets _G.TAUT.UI.NEXTPANEL before returning to request a panel switch.
|
* [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
|
* Created by minjaesong on 2026-04-27
|
||||||
|
* Stub editing UI added on 2026-05-26
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const win = require("wintex")
|
const win = require("wintex")
|
||||||
|
|
||||||
const PANEL_COUNT = 7
|
const PARENT_PANEL = (exec_args[2] !== undefined) ? (exec_args[2] | 0) : 3 // VIEW_SAMPLES
|
||||||
const MY_PANEL = 3 // VIEW_SAMPLES
|
const SAMPLE_IDX = (exec_args[3] !== undefined) ? (exec_args[3] | 0) : -1
|
||||||
|
|
||||||
const [SCRH, SCRW] = con.getmaxyx()
|
const [SCRH, SCRW] = con.getmaxyx()
|
||||||
const PANEL_Y = 4
|
const PANEL_Y = 4
|
||||||
@@ -21,38 +26,122 @@ const PANEL_H = SCRH - PANEL_Y
|
|||||||
const colStatus = 253
|
const colStatus = 253
|
||||||
const colContent = 240
|
const colContent = 240
|
||||||
const colHdr = 230
|
const colHdr = 230
|
||||||
|
const colEmph = 211
|
||||||
|
const colDim = 246
|
||||||
|
const colBack = 255
|
||||||
|
const colSel = 41
|
||||||
|
|
||||||
function drawSampleEditContents(wo) {
|
// 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++) {
|
for (let y = PANEL_Y; y < SCRH; y++) {
|
||||||
con.move(y, 1)
|
con.move(y, 1)
|
||||||
con.color_pair(colContent, 255)
|
con.color_pair(colContent, colBack)
|
||||||
print(' '.repeat(SCRW))
|
print(' '.repeat(SCRW))
|
||||||
}
|
}
|
||||||
|
// Title
|
||||||
con.move(PANEL_Y + 1, 3)
|
con.move(PANEL_Y + 1, 3)
|
||||||
con.color_pair(colHdr, 255)
|
con.color_pair(colHdr, colBack); print('[ Sample Editor ] ')
|
||||||
print('[ Sample Editor ]')
|
con.color_pair(colEmph, colBack); print('Sample ')
|
||||||
con.move(PANEL_Y + 3, 3)
|
con.color_pair(colStatus, colBack)
|
||||||
con.color_pair(colStatus, 255)
|
if (SAMPLE_IDX >= 0) print('#' + (SAMPLE_IDX + 1).toString(16).toUpperCase().padStart(2, '0'))
|
||||||
print('placeholder — not yet implemented')
|
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() {
|
function drawHints() {
|
||||||
con.move(SCRH, 1)
|
con.move(SCRH, 1)
|
||||||
con.color_pair(colStatus, 255)
|
con.color_pair(colStatus, colBack)
|
||||||
print(' '.repeat(SCRW - 1))
|
print(' '.repeat(SCRW - 1))
|
||||||
con.move(SCRH, 1)
|
con.move(SCRH, 1)
|
||||||
con.color_pair(colHdr, 255); print('Tab ')
|
con.color_pair(colHdr, colBack); print('28u29u ')
|
||||||
con.color_pair(colStatus, 255); print('Panel')
|
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) {
|
function sampleEditInput(wo, event) {
|
||||||
// placeholder — no interaction yet
|
// wintex panel input — wired up but the loop below handles keys directly.
|
||||||
}
|
}
|
||||||
|
|
||||||
const panel = new win.WindowObject(1, PANEL_Y, SCRW, PANEL_H, sampleEditInput, drawSampleEditContents, undefined, ()=>{})
|
function drawAll() {
|
||||||
|
drawSampleEditFrame()
|
||||||
|
drawToolList()
|
||||||
|
drawHints()
|
||||||
|
}
|
||||||
|
|
||||||
|
const panel = new win.WindowObject(1, PANEL_Y, SCRW, PANEL_H, sampleEditInput, drawAll, undefined, ()=>{})
|
||||||
|
|
||||||
panel.drawContents()
|
panel.drawContents()
|
||||||
drawHints()
|
|
||||||
|
|
||||||
let done = false
|
let done = false
|
||||||
while (!done) {
|
while (!done) {
|
||||||
@@ -60,17 +149,32 @@ while (!done) {
|
|||||||
if (event[0] !== 'key_down') return
|
if (event[0] !== 'key_down') return
|
||||||
const keysym = event[1]
|
const keysym = event[1]
|
||||||
const keyJustHit = (1 == event[2])
|
const keyJustHit = (1 == event[2])
|
||||||
const shiftDown = (event.includes(59) || event.includes(60))
|
|
||||||
|
|
||||||
if (!keyJustHit) return
|
if (!keyJustHit) return
|
||||||
|
|
||||||
if (keysym === '<TAB>') {
|
if (keysym === '<ESCAPE>' || keysym === '<TAB>') {
|
||||||
_G.TAUT.UI.NEXTPANEL = (MY_PANEL + (shiftDown ? -1 : 1) + PANEL_COUNT) % PANEL_COUNT
|
_G.TAUT.UI.NEXTPANEL = PARENT_PANEL
|
||||||
done = true
|
done = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
panel.processInput(event)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
9
assets/disk0/tvdos/bin/tee.js.synopsis
Normal file
9
assets/disk0/tvdos/bin/tee.js.synopsis
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "tee",
|
||||||
|
"summary": "Copy a pipe's stream to a file and pass it on",
|
||||||
|
"symbols": {
|
||||||
|
"file": { "kind": "positional", "type": "path", "name": "FILE", "summary": "File to write the stream to" }
|
||||||
|
},
|
||||||
|
"synopsis": { "type": "reference", "symbol": "file" }
|
||||||
|
}
|
||||||
17
assets/disk0/tvdos/bin/touch.js.synopsis
Normal file
17
assets/disk0/tvdos/bin/touch.js.synopsis
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "touch",
|
||||||
|
"summary": "Update a file's modification time, creating it if absent",
|
||||||
|
"symbols": {
|
||||||
|
"noCreate": { "kind": "option", "short": "-c", "summary": "Do not create the file if it does not exist" },
|
||||||
|
"options": { "kind": "group", "summary": "Options", "members": ["noCreate"] },
|
||||||
|
"file": { "kind": "positional", "type": "path", "name": "FILE", "summary": "File to touch" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "sequence",
|
||||||
|
"children": [
|
||||||
|
{ "type": "repeat", "child": { "type": "reference", "symbol": "options" } },
|
||||||
|
{ "type": "reference", "symbol": "file" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
9
assets/disk0/tvdos/bin/writeto.js.synopsis
Normal file
9
assets/disk0/tvdos/bin/writeto.js.synopsis
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "writeto",
|
||||||
|
"summary": "Write a pipe's stream to a file",
|
||||||
|
"symbols": {
|
||||||
|
"file": { "kind": "positional", "type": "path", "name": "FILE", "summary": "File to write the stream to" }
|
||||||
|
},
|
||||||
|
"synopsis": { "type": "reference", "symbol": "file" }
|
||||||
|
}
|
||||||
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/libmediadec.hop.per
Normal file
12
assets/disk0/tvdos/hopper/libmediadec.hop.per
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
HopperManifestVersion:1
|
||||||
|
HopperPackageName:libmediadec
|
||||||
|
HopperPackageVersion:1.0.0
|
||||||
|
HopperPackageMaintainer:CuriousTorvald
|
||||||
|
HopperProvides:libmediadec
|
||||||
|
HopperRequires:libseqread 1.*
|
||||||
|
ProperName:LibMediaDec
|
||||||
|
ProperAuthor:CuriousTorvald
|
||||||
|
ProperDescription:Video decoding library for TSVM
|
||||||
|
Licence:MIT
|
||||||
|
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||||
|
SystemPackagePath:/tvdos/include/mediadec.mjs;/tvdos/include/mediadec_common.mjs;/tvdos/include/mediadec_ipf.mjs;/tvdos/include/mediadec_tav.mjs;/tvdos/include/mediadec_tev.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*
|
||||||
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
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 }
|
||||||
86
assets/disk0/tvdos/include/mediadec.mjs
Normal file
86
assets/disk0/tvdos/include/mediadec.mjs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/*
|
||||||
|
* mediadec.mjs — the all-in-one media-decoding library for TVDOS movie players.
|
||||||
|
*
|
||||||
|
* One simple public API, three internal backends (iPF/MOV, TEV, TAV/TAP),
|
||||||
|
* sharing the front-end utilities in mediadec_common.mjs. Used by playmov.js.
|
||||||
|
*
|
||||||
|
* const mediadec = require("mediadec")
|
||||||
|
* const dec = mediadec.open("A:\\film.tav", { interactive: true })
|
||||||
|
* while (true) {
|
||||||
|
* const ev = dec.step() // [backend] decode the next due frame to RAM
|
||||||
|
* if (ev.type === 'eof') break
|
||||||
|
* if (ev.type !== 'frame') { sys.sleep(1); continue }
|
||||||
|
* dec.blit() // [draw] upload the RAM frame to the screen
|
||||||
|
* // ...or in ASCII mode (no upload): dec.sampleGray(buf,w,h); aa.render/flush
|
||||||
|
* // ...or grab the frame yourself: sys.peek(dec.frameBuffer + ...)
|
||||||
|
* }
|
||||||
|
* dec.close()
|
||||||
|
*
|
||||||
|
* step() decodes the next due frame into a generic RAM RGB888 buffer (exposed as
|
||||||
|
* .frameBuffer); the caller decides what to do with it — upload it with .blit(),
|
||||||
|
* sample it for ASCII, or read it directly. (iPF is the exception: it decodes
|
||||||
|
* straight to the 4bpp display planes, so .frameBuffer is 0 and .sampleGray/.blit
|
||||||
|
* operate on the planes — see mediadec_ipf.mjs.)
|
||||||
|
*
|
||||||
|
* The decoder object every backend returns exposes a uniform interface:
|
||||||
|
* .info {format,width,height,fps,totalFrames,hasAudio,hasSubtitles,
|
||||||
|
* isInterlaced,colourSpace,graphicsMode,isStill}
|
||||||
|
* .step() -> { type:'frame'|'idle'|'eof'|'newfile'|'error', frameCount }
|
||||||
|
* .frameBuffer RAM RGB888 address of the current frame (0 for iPF; see above)
|
||||||
|
* .frameWidth/.frameHeight dimensions of the frame in .frameBuffer
|
||||||
|
* .blit() upload the current RAM frame to the screen (adapter)
|
||||||
|
* .sampleGray(dst,w,h) fill an ASCII brightness buffer from the RAM frame
|
||||||
|
* .sampleColour(dst,w,h) fill a per-cell RGB buffer (w*h*3) from the RAM frame
|
||||||
|
* .subtitle {visible,text,position,useUnicode,dirty} (resolved by the lib)
|
||||||
|
* .pause(b)/.isPaused() .setVolume(v)/.getVolume()
|
||||||
|
* .seekSeconds(n) .cue(d) .cues
|
||||||
|
* .frameCount .currentTimecodeNs .videoRate .frameMode [.qY/.qCo/.qCg]
|
||||||
|
* .close()
|
||||||
|
*/
|
||||||
|
|
||||||
|
// NOTE: every require() below is deliberately made at call time (inside open()),
|
||||||
|
// never at module top level. TVDOS's require() loads a module by eval()-ing it,
|
||||||
|
// and requiring one module *while another module is still being eval()-ed* nests
|
||||||
|
// the evals — which can collide on the loader's `let exports` binding and throw
|
||||||
|
// "Identifier 'exports' has already been declared" at load, breaking every file.
|
||||||
|
// Keeping requires at runtime means each is a single, non-nested eval.
|
||||||
|
|
||||||
|
// Open a movie file: sniff the magic, then hand off to the matching backend.
|
||||||
|
// `opts` (all optional): interactive, debugMotionVectors, enableDeblocking,
|
||||||
|
// enableBoundaryAwareDecoding, deinterlaceAlgorithm, filmGrainLevel.
|
||||||
|
function open(fullPathStr, opts) {
|
||||||
|
opts = opts || {}
|
||||||
|
|
||||||
|
const common = require("mediadec_common")
|
||||||
|
|
||||||
|
// IMPORTANT: query the file size via files.open() BEFORE preparing seqread.
|
||||||
|
// On the real disk driver both share the drive's serial port, so a files.open()
|
||||||
|
// *after* seqread.prepare() clobbers the read position and the first readBytes()
|
||||||
|
// returns driver leftovers (the size as an ASCII string) instead of the file's
|
||||||
|
// bytes — which made every file fail the magic check. Every original player
|
||||||
|
// reads the size first, then prepares seqread.
|
||||||
|
const fileLength = files.open(fullPathStr).size
|
||||||
|
const sr = common.openSeqread(fullPathStr)
|
||||||
|
const magic = common.readMagic(sr)
|
||||||
|
const fmt = common.detectFormat(magic)
|
||||||
|
|
||||||
|
con.clear()
|
||||||
|
con.curs_set(0)
|
||||||
|
|
||||||
|
switch (fmt) {
|
||||||
|
case 'mov': return require("mediadec_ipf").create(magic, sr, fileLength, opts, common)
|
||||||
|
case 'tev': return require("mediadec_tev").create(magic, sr, fileLength, opts, common)
|
||||||
|
case 'tav': return require("mediadec_tav").create(magic, sr, fileLength, opts, common, false)
|
||||||
|
case 'tap': return require("mediadec_tav").create(magic, sr, fileLength, opts, common, true)
|
||||||
|
case 'ucf':
|
||||||
|
throw Error("UCF cue files are not directly playable; play the TAV stream they index")
|
||||||
|
default:
|
||||||
|
throw Error("Unrecognised movie file (magic: " + magic.map(b => b.toString(16)).join(' ') + ")")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exports = {
|
||||||
|
open: open,
|
||||||
|
// Lazy require so this module never requires another at load time (see note above).
|
||||||
|
detectFormat: function (magic) { return require("mediadec_common").detectFormat(magic) }
|
||||||
|
}
|
||||||
448
assets/disk0/tvdos/include/mediadec_common.mjs
Normal file
448
assets/disk0/tvdos/include/mediadec_common.mjs
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
/*
|
||||||
|
* mediadec_common.mjs — shared front-end utilities for the mediadec library.
|
||||||
|
*
|
||||||
|
* Holds everything the three movie backends (iPF/MOV, TEV, TAV) duplicated in
|
||||||
|
* the old standalone players: magic constants, packet-type / SSF-opcode tables,
|
||||||
|
* the TAV quality LUT, seqread selection, the audio router, the subtitle
|
||||||
|
* engine, bias lighting, and the `sampleGray` / `sampleColour` source samplers
|
||||||
|
* used by the player's ASCII-render path — both a *Screen pair (read the GPU
|
||||||
|
* display planes, for iPF) and a *RGB pair (read a RAM RGB888 frame, for the
|
||||||
|
* decode-into-RAM backends TEV / TAV).
|
||||||
|
*
|
||||||
|
* Runs in the same GraalVM context as the player, so the host globals
|
||||||
|
* (sys/graphics/audio/con/serial/files/gzip) are visible directly, exactly as
|
||||||
|
* in seqread.mjs / playgui.mjs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Magic numbers ───────────────────────────────────────────────────────────
|
||||||
|
const MAGIC_MOV = [0x1F, 0x54, 0x53, 0x56, 0x4D, 0x4D, 0x4F, 0x56] // "\x1FTSVMMOV"
|
||||||
|
const MAGIC_TEV = [0x1F, 0x54, 0x53, 0x56, 0x4D, 0x54, 0x45, 0x56] // "\x1FTSVMTEV"
|
||||||
|
const MAGIC_TAV = [0x1F, 0x54, 0x53, 0x56, 0x4D, 0x54, 0x41, 0x56] // "\x1FTSVMTAV"
|
||||||
|
const MAGIC_TAP = [0x1F, 0x54, 0x53, 0x56, 0x4D, 0x54, 0x41, 0x50] // "\x1FTSVMTAP"
|
||||||
|
const MAGIC_UCF = [0x1F, 0x54, 0x53, 0x56, 0x4D, 0x55, 0x43, 0x46] // "\x1FTSVMUCF"
|
||||||
|
|
||||||
|
// ── MP2 frame-size table (shared by iPF/TEV/TAV) ────────────────────────────
|
||||||
|
const MP2_FRAME_SIZE = [144,216,252,288,360,432,504,576,720,864,1008,1152,1440,1728]
|
||||||
|
|
||||||
|
// ── SSF subtitle opcodes (shared) ───────────────────────────────────────────
|
||||||
|
const SSF_OP_NOP = 0x00
|
||||||
|
const SSF_OP_SHOW = 0x01
|
||||||
|
const SSF_OP_HIDE = 0x02
|
||||||
|
const SSF_OP_MOVE = 0x03
|
||||||
|
const SSF_OP_UPLOAD_LOW_FONT = 0x80
|
||||||
|
const SSF_OP_UPLOAD_HIGH_FONT = 0x81
|
||||||
|
|
||||||
|
// ── TAV quality LUT (index → quantiser) ─────────────────────────────────────
|
||||||
|
const QLUT = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,66,68,70,72,74,76,78,80,82,84,86,88,90,92,94,96,98,100,102,104,106,108,110,112,114,116,118,120,122,124,126,128,132,136,140,144,148,152,156,160,164,168,172,176,180,184,188,192,196,200,204,208,212,216,220,224,228,232,236,240,244,248,252,256,264,272,280,288,296,304,312,320,328,336,344,352,360,368,376,384,392,400,408,416,424,432,440,448,456,464,472,480,488,496,504,512,528,544,560,576,592,608,624,640,656,672,688,704,720,736,752,768,784,800,816,832,848,864,880,896,912,928,944,960,976,992,1008,1024,1056,1088,1120,1152,1184,1216,1248,1280,1312,1344,1376,1408,1440,1472,1504,1536,1568,1600,1632,1664,1696,1728,1760,1792,1824,1856,1888,1920,1952,1984,2016,2048,2112,2176,2240,2304,2368,2432,2496,2560,2624,2688,2752,2816,2880,2944,3008,3072,3136,3200,3264,3328,3392,3456,3520,3584,3648,3712,3776,3840,3904,3968,4032,4096]
|
||||||
|
|
||||||
|
// ── Display-plane addresses (4bpp / mode 4) ─────────────────────────────────
|
||||||
|
const DISP_RG = -1048577
|
||||||
|
const DISP_BA = -1310721
|
||||||
|
const DISP_PLANE3 = -1310721 - 262144 // mode-8 third plane base (for getRGBfromScr)
|
||||||
|
|
||||||
|
// ── seqread selection ───────────────────────────────────────────────────────
|
||||||
|
// Mirrors the tape-vs-disk branch every old player carried. Returns a prepared
|
||||||
|
// seqread module instance (a stateful singleton — only one decoder at a time).
|
||||||
|
function openSeqread(fullPathStr) {
|
||||||
|
let sr
|
||||||
|
if (fullPathStr.startsWith('$:/TAPE') || fullPathStr.startsWith('$:\\TAPE')) {
|
||||||
|
sr = require("seqreadtape")
|
||||||
|
sr.prepare(fullPathStr)
|
||||||
|
sr.seek(0)
|
||||||
|
} else {
|
||||||
|
sr = require("seqread")
|
||||||
|
sr.prepare(fullPathStr)
|
||||||
|
}
|
||||||
|
return sr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the 8-byte magic into a JS array (frees the scratch buffer).
|
||||||
|
function readMagic(sr) {
|
||||||
|
let p = sr.readBytes(8)
|
||||||
|
let out = []
|
||||||
|
for (let i = 0; i < 8; i++) out.push(sys.peek(p + i) & 255)
|
||||||
|
sys.free(p)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function magicEquals(got, want) {
|
||||||
|
for (let i = 0; i < 8; i++) if (got[i] !== want[i]) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect container format from the 8-byte magic. Returns 'mov'|'tev'|'tav'|'tap'|'ucf'|null.
|
||||||
|
function detectFormat(magic) {
|
||||||
|
if (magicEquals(magic, MAGIC_MOV)) return 'mov'
|
||||||
|
if (magicEquals(magic, MAGIC_TEV)) return 'tev'
|
||||||
|
if (magicEquals(magic, MAGIC_TAV)) return 'tav'
|
||||||
|
if (magicEquals(magic, MAGIC_TAP)) return 'tap'
|
||||||
|
if (magicEquals(magic, MAGIC_UCF)) return 'ucf'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Luma ─────────────────────────────────────────────────────────────────────
|
||||||
|
// BT.601 integer luma from 8-bit RGB.
|
||||||
|
function luma8(r, g, b) { return (r * 77 + g * 150 + b * 29) >> 8 }
|
||||||
|
|
||||||
|
// ── Audio router ─────────────────────────────────────────────────────────────
|
||||||
|
// One playhead, deferred play(). Handles the per-packet audio codecs shared by
|
||||||
|
// the backends. TAV's bundled-MP2 (0x40) pre-decode/streaming stays in the TAV
|
||||||
|
// backend because it interleaves with the GOP display loop.
|
||||||
|
function makeAudioRouter(sr) {
|
||||||
|
const playhead = audio.getFreePlayhead(0)
|
||||||
|
const SND_BASE = audio.getBaseAddr()
|
||||||
|
const SND_MEM = audio.getMemAddr()
|
||||||
|
audio.resetParams(playhead)
|
||||||
|
audio.purgeQueue(playhead)
|
||||||
|
audio.setPcmMode(playhead)
|
||||||
|
let volume = 255
|
||||||
|
audio.setMasterVolume(playhead, volume)
|
||||||
|
|
||||||
|
let mp2Init = false
|
||||||
|
let fired = false
|
||||||
|
|
||||||
|
return {
|
||||||
|
playhead: playhead,
|
||||||
|
sndBase: SND_BASE,
|
||||||
|
sndMem: SND_MEM,
|
||||||
|
|
||||||
|
// Fire playback once, on the first displayed frame.
|
||||||
|
fire() { if (!fired) { audio.play(playhead); fired = true } },
|
||||||
|
isFired() { return fired },
|
||||||
|
|
||||||
|
stop() { audio.stop(playhead) },
|
||||||
|
resume() { audio.play(playhead) },
|
||||||
|
purge() { audio.purgeQueue(playhead); fired = false },
|
||||||
|
|
||||||
|
setVolume(v) { volume = (v < 0) ? 0 : (v > 255) ? 255 : v; audio.setMasterVolume(playhead, volume) },
|
||||||
|
getVolume() { return volume },
|
||||||
|
|
||||||
|
// MP2 packet: payload already length-known by caller; reads `len` bytes.
|
||||||
|
mp2(len) {
|
||||||
|
if (!mp2Init) { mp2Init = true; audio.mp2Init() }
|
||||||
|
sr.readBytes(len, SND_BASE - 2368)
|
||||||
|
audio.mp2Decode()
|
||||||
|
audio.mp2UploadDecoded(playhead)
|
||||||
|
},
|
||||||
|
// MP2 frame whose size is implicit in the iPF packet type.
|
||||||
|
ensureMp2() { if (!mp2Init) { mp2Init = true; audio.mp2Init() } },
|
||||||
|
|
||||||
|
// TAD packet.
|
||||||
|
tad(sampleLen, payloadLen) {
|
||||||
|
sr.readBytes(payloadLen, SND_MEM - 917504)
|
||||||
|
audio.tadDecode()
|
||||||
|
audio.tadUploadDecoded(playhead, sampleLen)
|
||||||
|
},
|
||||||
|
// Native (zstd PCMu8) packet.
|
||||||
|
nativePcm(zstdLen) {
|
||||||
|
let zstdPtr = sys.malloc(zstdLen)
|
||||||
|
sr.readBytes(zstdLen, zstdPtr)
|
||||||
|
let pcmPtr = sys.malloc(65536)
|
||||||
|
let pcmLen = gzip.decompFromTo(zstdPtr, zstdLen, pcmPtr)
|
||||||
|
if (pcmLen > 65536) { sys.free(zstdPtr); sys.free(pcmPtr); throw Error(`PCM data too long -- got ${pcmLen} bytes`) }
|
||||||
|
audio.putPcmDataByPtr(playhead, pcmPtr, pcmLen, 0)
|
||||||
|
audio.setSampleUploadLength(playhead, pcmLen)
|
||||||
|
audio.startSampleUpload(playhead)
|
||||||
|
sys.free(zstdPtr)
|
||||||
|
sys.free(pcmPtr)
|
||||||
|
},
|
||||||
|
// Raw PCM (iPF 0x1000/0x1001): payload bytes streamed directly.
|
||||||
|
rawPcm(len) {
|
||||||
|
let frame = sr.readBytes(len)
|
||||||
|
audio.putPcmDataByPtr(playhead, frame, len, 0)
|
||||||
|
audio.setSampleUploadLength(playhead, len)
|
||||||
|
audio.startSampleUpload(playhead)
|
||||||
|
sys.free(frame)
|
||||||
|
},
|
||||||
|
|
||||||
|
close() { audio.stop(playhead); audio.purgeQueue(playhead) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Subtitle engine ──────────────────────────────────────────────────────────
|
||||||
|
// Parses SSF (frame-locked 0x30) and SSF-TC (timecode 0x31) packets and exposes
|
||||||
|
// the *active* subtitle as state; the player renders it (the "postprocessor"
|
||||||
|
// stage). Font-ROM uploads are hardware writes, so the engine performs them.
|
||||||
|
// fontUploadBase: -1300607 (TEV) or -133121 (TAV) — kept per-format for parity.
|
||||||
|
function makeSubtitleEngine(sr, fontUploadBase) {
|
||||||
|
const subtitle = { visible: false, text: "", position: 0, useUnicode: false, dirty: false }
|
||||||
|
let events = []
|
||||||
|
let nextIndex = 0
|
||||||
|
let fontUploaded = false
|
||||||
|
|
||||||
|
function uploadFont(opcode, remainingBytes) {
|
||||||
|
if (remainingBytes >= 3) {
|
||||||
|
let payloadLen = sr.readShort()
|
||||||
|
if (remainingBytes >= payloadLen + 2) {
|
||||||
|
let fontData = sr.readBytes(payloadLen)
|
||||||
|
for (let i = 0; i < Math.min(payloadLen, 1920); i++) sys.poke(fontUploadBase - i, sys.peek(fontData + i))
|
||||||
|
sys.poke(-1299460, (opcode == SSF_OP_UPLOAD_LOW_FONT) ? 18 : 19)
|
||||||
|
sys.free(fontData)
|
||||||
|
}
|
||||||
|
fontUploaded = true
|
||||||
|
subtitle.useUnicode = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
subtitle: subtitle,
|
||||||
|
get fontUploaded() { return fontUploaded },
|
||||||
|
|
||||||
|
// Frame-locked subtitle packet (0x30): applies immediately.
|
||||||
|
parseLegacy(packetSize) {
|
||||||
|
sr.readOneByte(); sr.readOneByte(); sr.readOneByte() // 24-bit index
|
||||||
|
let opcode = sr.readOneByte()
|
||||||
|
let remainingBytes = packetSize - 4
|
||||||
|
switch (opcode) {
|
||||||
|
case SSF_OP_SHOW: {
|
||||||
|
if (remainingBytes > 1) {
|
||||||
|
let tb = sr.readBytes(remainingBytes)
|
||||||
|
let s = ""
|
||||||
|
for (let i = 0; i < remainingBytes - 1; i++) { let b = sys.peek(tb + i); if (b === 0) break; s += String.fromCharCode(b) }
|
||||||
|
sys.free(tb)
|
||||||
|
subtitle.text = s; subtitle.visible = true; subtitle.useUnicode = fontUploaded; subtitle.dirty = true
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case SSF_OP_HIDE: { subtitle.visible = false; subtitle.text = ""; subtitle.dirty = true; break }
|
||||||
|
case SSF_OP_MOVE: {
|
||||||
|
if (remainingBytes >= 2) {
|
||||||
|
let pos = sr.readOneByte(); sr.readOneByte()
|
||||||
|
if (pos >= 0 && pos <= 8) { subtitle.position = pos; subtitle.dirty = true }
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case SSF_OP_UPLOAD_LOW_FONT:
|
||||||
|
case SSF_OP_UPLOAD_HIGH_FONT: { uploadFont(opcode, remainingBytes); break }
|
||||||
|
default: { if (remainingBytes > 0) { let s = sr.readBytes(remainingBytes); sys.free(s) } break }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Timecode subtitle packet (0x31): buffered, applied by poll().
|
||||||
|
parseTC(packetSize) {
|
||||||
|
let i0 = sr.readOneByte(), i1 = sr.readOneByte(), i2 = sr.readOneByte()
|
||||||
|
let index = i0 | (i1 << 8) | (i2 << 16)
|
||||||
|
let tc = 0
|
||||||
|
for (let i = 0; i < 8; i++) { tc += sr.readOneByte() * Math.pow(2, i * 8) }
|
||||||
|
let opcode = sr.readOneByte()
|
||||||
|
let remainingBytes = packetSize - 12
|
||||||
|
let text = null
|
||||||
|
if (remainingBytes > 1 && (opcode === SSF_OP_SHOW || (opcode >= 0x10 && opcode <= 0x2F))) {
|
||||||
|
let tb = sr.readBytes(remainingBytes)
|
||||||
|
text = ""
|
||||||
|
for (let i = 0; i < remainingBytes - 1; i++) { let b = sys.peek(tb + i); if (b === 0) break; text += String.fromCharCode(b) }
|
||||||
|
sys.free(tb)
|
||||||
|
} else if (remainingBytes > 0) {
|
||||||
|
let s = sr.readBytes(remainingBytes); sys.free(s)
|
||||||
|
}
|
||||||
|
events.push({ timecode_ns: tc, index: index, opcode: opcode, text: text })
|
||||||
|
},
|
||||||
|
|
||||||
|
// Advance through timecode events whose time has been reached.
|
||||||
|
poll(currentTimeNs) {
|
||||||
|
while (nextIndex < events.length) {
|
||||||
|
let ev = events[nextIndex]
|
||||||
|
if (ev.timecode_ns > currentTimeNs) break
|
||||||
|
switch (ev.opcode) {
|
||||||
|
case SSF_OP_SHOW: subtitle.text = ev.text || ""; subtitle.visible = true; subtitle.useUnicode = fontUploaded; subtitle.dirty = true; break
|
||||||
|
case SSF_OP_HIDE: subtitle.visible = false; subtitle.text = ""; subtitle.dirty = true; break
|
||||||
|
case SSF_OP_MOVE:
|
||||||
|
if (ev.text && ev.text.length > 0) {
|
||||||
|
let pos = ev.text.charCodeAt(0)
|
||||||
|
if (pos >= 0 && pos <= 8) { subtitle.position = pos; subtitle.dirty = true }
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
nextIndex++
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// After a seek: jump the event cursor to the first event at/after `tc`.
|
||||||
|
resetTo(tc) {
|
||||||
|
nextIndex = 0
|
||||||
|
for (let i = 0; i < events.length; i++) { if (events[i].timecode_ns >= tc) { nextIndex = i; break } }
|
||||||
|
subtitle.visible = false; subtitle.text = ""; subtitle.dirty = true
|
||||||
|
},
|
||||||
|
|
||||||
|
hasEvents() { return events.length > 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bias lighting ────────────────────────────────────────────────────────────
|
||||||
|
// Samples the screen borders and drifts the background colour toward them —
|
||||||
|
// the "ambilight" the old players ran after each frame upload. Mode-aware
|
||||||
|
// (4/5/8 bpp) read-back, matching playtav's getRGBfromScr.
|
||||||
|
function makeBias(width, height, graphicsMode) {
|
||||||
|
const BIAS_MIN = 1.0 / 16.0
|
||||||
|
let old = [BIAS_MIN, BIAS_MIN, BIAS_MIN]
|
||||||
|
const nativeWidth = graphics.getPixelDimension()[0]
|
||||||
|
const nativeHeight = graphics.getPixelDimension()[1]
|
||||||
|
const STRIDE = 560
|
||||||
|
|
||||||
|
function rgbFromScr(x, y) {
|
||||||
|
let off = y * STRIDE + x
|
||||||
|
let fb1 = sys.peek(DISP_RG - off)
|
||||||
|
let fb2 = sys.peek(DISP_BA - off)
|
||||||
|
if (graphicsMode == 5) {
|
||||||
|
let fb3 = sys.peek(DISP_PLANE3 - off)
|
||||||
|
return [((fb1 >>> 2) & 31) / 31.0, (((fb1 & 3) << 3) | ((fb2 >>> 5) & 7)) / 31.0, (fb2 & 31) / 31.0]
|
||||||
|
} else if (graphicsMode == 4) {
|
||||||
|
return [(fb1 >>> 4) / 15.0, (fb1 & 15) / 15.0, (fb2 >>> 4) / 15.0]
|
||||||
|
} else {
|
||||||
|
let fb3 = sys.peek(DISP_PLANE3 - off)
|
||||||
|
return [fb1 / 255.0, fb2 / 255.0, fb3 / 255.0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return function setBiasLighting() {
|
||||||
|
let samples = []
|
||||||
|
let offsetX = Math.floor((nativeWidth - width) / 2)
|
||||||
|
let offsetY = Math.floor((nativeHeight - height) / 2)
|
||||||
|
let stepX = Math.max(8, Math.floor(width / 18))
|
||||||
|
let stepY = Math.max(8, Math.floor(height / 17))
|
||||||
|
let margin = Math.min(8, Math.floor(width / 70))
|
||||||
|
|
||||||
|
for (let x = margin; x < width - margin; x += stepX) {
|
||||||
|
samples.push(rgbFromScr(x + offsetX, margin + offsetY))
|
||||||
|
samples.push(rgbFromScr(x + offsetX, height - margin - 1 + offsetY))
|
||||||
|
}
|
||||||
|
for (let y = margin; y < height - margin; y += stepY) {
|
||||||
|
samples.push(rgbFromScr(margin + offsetX, y + offsetY))
|
||||||
|
samples.push(rgbFromScr(width - margin - 1 + offsetX, y + offsetY))
|
||||||
|
}
|
||||||
|
|
||||||
|
let out = [0.0, 0.0, 0.0]
|
||||||
|
samples.forEach(rgb => { out[0] += rgb[0]; out[1] += rgb[1]; out[2] += rgb[2] })
|
||||||
|
out[0] = BIAS_MIN + (out[0] / samples.length / 2.0)
|
||||||
|
out[1] = BIAS_MIN + (out[1] / samples.length / 2.0)
|
||||||
|
out[2] = BIAS_MIN + (out[2] / samples.length / 2.0)
|
||||||
|
|
||||||
|
let bgr = (old[0] * 5 + out[0]) / 6.0
|
||||||
|
let bgg = (old[1] * 5 + out[1]) / 6.0
|
||||||
|
let bgb = (old[2] * 5 + out[2]) / 6.0
|
||||||
|
old = [bgr, bgg, bgb]
|
||||||
|
graphics.setBackground(Math.round(bgr * 255), Math.round(bgg * 255), Math.round(bgb * 255))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── sampleGray source ────────────────────────────────────────────────────────
|
||||||
|
// Fill an ASCII brightness buffer (dst, dstW×dstH) by nearest-sampling the GPU
|
||||||
|
// framebuffer (the shared "player framebuffer" the backend has just blit()ted
|
||||||
|
// to). Reading the screen — rather than each backend's private frame store —
|
||||||
|
// keeps one sampler for every format/kind (TAV's GOP videoBuffer is Java-heap
|
||||||
|
// and has no JS-addressable VM address, so reading it directly is impossible).
|
||||||
|
//
|
||||||
|
// Only ~dstW·dstH peeks per call, so it is cheap regardless of frame size.
|
||||||
|
// Pixel `off` is backward-addressed (DISP_RG-off / DISP_BA-off), matching how
|
||||||
|
// every decoder writes the framebuffer. `mode` selects 4/5/8-bpp unpacking
|
||||||
|
// (mirrors playtav's getRGBfromScr).
|
||||||
|
function sampleGrayScreen(width, height, dst, dstW, dstH, mode) {
|
||||||
|
for (let y = 0; y < dstH; y++) {
|
||||||
|
let sy = (y * height / dstH) | 0
|
||||||
|
let dstRow = y * dstW
|
||||||
|
for (let x = 0; x < dstW; x++) {
|
||||||
|
let sx = (x * width / dstW) | 0
|
||||||
|
let off = sy * 560 + sx
|
||||||
|
let fb1 = sys.peek(DISP_RG - off) & 255
|
||||||
|
let fb2 = sys.peek(DISP_BA - off) & 255
|
||||||
|
let r, g, b
|
||||||
|
if (mode == 5) {
|
||||||
|
r = ((fb1 >>> 2) & 31) * 255 / 31
|
||||||
|
g = (((fb1 & 3) << 3) | ((fb2 >>> 5) & 7)) * 255 / 31
|
||||||
|
b = (fb2 & 31) * 255 / 31
|
||||||
|
} else if (mode == 8) {
|
||||||
|
r = fb1; g = fb2; b = sys.peek(DISP_PLANE3 - off) & 255
|
||||||
|
} else { // mode 4
|
||||||
|
r = (fb1 >>> 4) * 17
|
||||||
|
g = (fb1 & 15) * 17
|
||||||
|
b = (fb2 >>> 4) * 17
|
||||||
|
}
|
||||||
|
dst[dstRow + x] = luma8(r | 0, g | 0, b | 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── sampleColour source ──────────────────────────────────────────────────────
|
||||||
|
// Companion to sampleGrayScreen: fill an RGB buffer (dst, length dstW·dstH·3,
|
||||||
|
// laid out R,G,B per cell) by point-sampling the GPU framebuffer at the CENTRE
|
||||||
|
// of each cell. Used by the player's colour-ASCII postprocessor — aa.mjs picks
|
||||||
|
// each glyph from brightness, this supplies the per-cell ink colour. Same
|
||||||
|
// backend-specific `mode` (4/5/8-bpp unpacking) and same cheap ~dstW·dstH peek
|
||||||
|
// count as sampleGrayScreen.
|
||||||
|
function sampleColourScreen(width, height, dst, dstW, dstH, mode) {
|
||||||
|
for (let y = 0; y < dstH; y++) {
|
||||||
|
let sy = ((y + 0.5) * height / dstH) | 0
|
||||||
|
if (sy >= height) sy = height - 1
|
||||||
|
let dstRow = y * dstW * 3
|
||||||
|
for (let x = 0; x < dstW; x++) {
|
||||||
|
let sx = ((x + 0.5) * width / dstW) | 0
|
||||||
|
if (sx >= width) sx = width - 1
|
||||||
|
let off = sy * 560 + sx
|
||||||
|
let fb1 = sys.peek(DISP_RG - off) & 255
|
||||||
|
let fb2 = sys.peek(DISP_BA - off) & 255
|
||||||
|
let r, g, b
|
||||||
|
if (mode == 5) {
|
||||||
|
r = ((fb1 >>> 2) & 31) * 255 / 31
|
||||||
|
g = (((fb1 & 3) << 3) | ((fb2 >>> 5) & 7)) * 255 / 31
|
||||||
|
b = (fb2 & 31) * 255 / 31
|
||||||
|
} else if (mode == 8) {
|
||||||
|
r = fb1; g = fb2; b = sys.peek(DISP_PLANE3 - off) & 255
|
||||||
|
} else { // mode 4
|
||||||
|
r = (fb1 >>> 4) * 17
|
||||||
|
g = (fb1 & 15) * 17
|
||||||
|
b = (fb2 >>> 4) * 17
|
||||||
|
}
|
||||||
|
let di = dstRow + x * 3
|
||||||
|
dst[di] = r | 0; dst[di + 1] = g | 0; dst[di + 2] = b | 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── sampleGray / sampleColour from a RAM RGB888 frame ─────────────────────────
|
||||||
|
// Companions to the *Screen samplers that read a decoded frame straight out of a
|
||||||
|
// JS-addressable RGB888 RAM buffer (3 bytes/pixel, forward-addressed) instead of
|
||||||
|
// the GPU display planes. Backends that decode into RAM (TEV / TAV) use these so
|
||||||
|
// the ASCII renderer can sample the frame WITHOUT it ever being uploaded to the
|
||||||
|
// video adapter — the whole point of the generic RAM-frame model. Same cheap
|
||||||
|
// ~dstW·dstH·3 peek count and the same nearest-sampling geometry as the *Screen
|
||||||
|
// versions (sampleGrayRGB row-aligned; sampleColourRGB at the cell centre).
|
||||||
|
function sampleGrayRGB(srcPtr, width, height, dst, dstW, dstH) {
|
||||||
|
for (let y = 0; y < dstH; y++) {
|
||||||
|
let sy = (y * height / dstH) | 0
|
||||||
|
let dstRow = y * dstW
|
||||||
|
for (let x = 0; x < dstW; x++) {
|
||||||
|
let sx = (x * width / dstW) | 0
|
||||||
|
let o = srcPtr + (sy * width + sx) * 3
|
||||||
|
let r = sys.peek(o) & 255, g = sys.peek(o + 1) & 255, b = sys.peek(o + 2) & 255
|
||||||
|
dst[dstRow + x] = luma8(r, g, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleColourRGB(srcPtr, width, height, dst, dstW, dstH) {
|
||||||
|
for (let y = 0; y < dstH; y++) {
|
||||||
|
let sy = ((y + 0.5) * height / dstH) | 0
|
||||||
|
if (sy >= height) sy = height - 1
|
||||||
|
let dstRow = y * dstW * 3
|
||||||
|
for (let x = 0; x < dstW; x++) {
|
||||||
|
let sx = ((x + 0.5) * width / dstW) | 0
|
||||||
|
if (sx >= width) sx = width - 1
|
||||||
|
let o = srcPtr + (sy * width + sx) * 3
|
||||||
|
let di = dstRow + x * 3
|
||||||
|
dst[di] = sys.peek(o) & 255; dst[di + 1] = sys.peek(o + 1) & 255; dst[di + 2] = sys.peek(o + 2) & 255
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exports = {
|
||||||
|
MAGIC_MOV, MAGIC_TEV, MAGIC_TAV, MAGIC_TAP, MAGIC_UCF,
|
||||||
|
MP2_FRAME_SIZE, QLUT,
|
||||||
|
SSF_OP_NOP, SSF_OP_SHOW, SSF_OP_HIDE, SSF_OP_MOVE,
|
||||||
|
SSF_OP_UPLOAD_LOW_FONT, SSF_OP_UPLOAD_HIGH_FONT,
|
||||||
|
DISP_RG, DISP_BA,
|
||||||
|
openSeqread, readMagic, detectFormat, magicEquals,
|
||||||
|
luma8,
|
||||||
|
makeAudioRouter, makeSubtitleEngine, makeBias,
|
||||||
|
sampleGrayScreen, sampleColourScreen,
|
||||||
|
sampleGrayRGB, sampleColourRGB
|
||||||
|
}
|
||||||
192
assets/disk0/tvdos/include/mediadec_ipf.mjs
Normal file
192
assets/disk0/tvdos/include/mediadec_ipf.mjs
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
/*
|
||||||
|
* mediadec_ipf.mjs — legacy MOV / iPF backend for the mediadec library.
|
||||||
|
*
|
||||||
|
* Ported from assets/disk0/tvdos/bin/playmv1.js. Decodes iPF1 / iPF1a /
|
||||||
|
* iPF2 / iPF2a / iPF1-delta video packets straight to the 4bpp display planes
|
||||||
|
* (the proven, fast path), plus MP2 and raw-PCM audio and the background-colour
|
||||||
|
* packet. Presents at decode time (so blit() is a no-op); bias lighting is a
|
||||||
|
* separate player-driven stage via the bias() method; the ASCII path reads the
|
||||||
|
* planes back via common.sampleGrayScreen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const WIDTH = 560
|
||||||
|
const HEIGHT = 448
|
||||||
|
const FBUF_SIZE = WIDTH * HEIGHT
|
||||||
|
|
||||||
|
function create(magic, sr, fileLength, opts, common) {
|
||||||
|
const audioR = common.makeAudioRouter(sr)
|
||||||
|
|
||||||
|
// Header (after the 8-byte magic): w, h, fps, frameCount, queue info.
|
||||||
|
let width = sr.readShort()
|
||||||
|
let height = sr.readShort()
|
||||||
|
let fps = sr.readShort(); if (fps == 0) fps = 9999
|
||||||
|
const FRAME_COUNT = sr.readInt() % 16777216
|
||||||
|
sr.readShort() // skip unused
|
||||||
|
sr.readShort() // audioQueueInfo (unused for playback)
|
||||||
|
sr.skip(10)
|
||||||
|
|
||||||
|
graphics.setGraphicsMode(4)
|
||||||
|
graphics.clearPixels(255)
|
||||||
|
graphics.clearPixels2(240)
|
||||||
|
|
||||||
|
const FRAME_TIME = 1.0 / fps
|
||||||
|
const applyBias = common.makeBias(width, height, 4)
|
||||||
|
|
||||||
|
const ipfbuf = sys.malloc(FBUF_SIZE)
|
||||||
|
|
||||||
|
const info = {
|
||||||
|
format: 'ipf', width: width, height: height, fps: fps,
|
||||||
|
totalFrames: FRAME_COUNT, hasAudio: true, hasSubtitles: false,
|
||||||
|
isInterlaced: false, colourSpace: 'YCoCg', graphicsMode: 4, isStill: false
|
||||||
|
}
|
||||||
|
|
||||||
|
// No subtitles in iPF; expose an inert state object for the uniform API.
|
||||||
|
const subtitle = { visible: false, text: "", position: 0, useUnicode: false, dirty: false }
|
||||||
|
|
||||||
|
let akku = FRAME_TIME
|
||||||
|
let lastT = sys.nanoTime()
|
||||||
|
let doFrameskip = true
|
||||||
|
let autoBg = true
|
||||||
|
let framesRead = 0
|
||||||
|
let frameCount = 0
|
||||||
|
let paused = false
|
||||||
|
|
||||||
|
function setBackgroundPacket() {
|
||||||
|
autoBg = false
|
||||||
|
let rgbx = sr.readInt()
|
||||||
|
graphics.setBackground((rgbx & 0xFF000000) >>> 24, (rgbx & 0x00FF0000) >>> 16, (rgbx & 0x0000FF00) >>> 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
function step() {
|
||||||
|
const now = sys.nanoTime()
|
||||||
|
if (paused) { lastT = now; return { type: 'idle' } }
|
||||||
|
akku += (now - lastT) / 1000000000.0
|
||||||
|
lastT = now
|
||||||
|
|
||||||
|
if (sr.getReadCount() >= fileLength) return { type: 'eof' }
|
||||||
|
if (akku < FRAME_TIME) return { type: 'idle' }
|
||||||
|
|
||||||
|
// Drain accumulated time into a frame budget (frameskip drops late frames).
|
||||||
|
let frameUnit = 0
|
||||||
|
while (akku >= FRAME_TIME) { akku -= FRAME_TIME; frameUnit += 1 }
|
||||||
|
if (!doFrameskip) frameUnit = 1
|
||||||
|
|
||||||
|
let displayed = false
|
||||||
|
while (frameUnit >= 1 && sr.getReadCount() < fileLength) {
|
||||||
|
let packetType = sr.readShort()
|
||||||
|
|
||||||
|
if (0xFFFF === packetType) { // sync — one frame boundary
|
||||||
|
frameUnit -= 1
|
||||||
|
}
|
||||||
|
else if (0xFEFF === packetType) { // explicit background colour
|
||||||
|
setBackgroundPacket()
|
||||||
|
}
|
||||||
|
else if (packetType < 2047) { // video
|
||||||
|
if (packetType == 4 || packetType == 5 || packetType == 260 || packetType == 261) {
|
||||||
|
let decodefun = (packetType > 255) ? graphics.decodeIpf2 : graphics.decodeIpf1
|
||||||
|
let payloadLen = sr.readInt()
|
||||||
|
if (framesRead >= FRAME_COUNT) return { type: 'eof' }
|
||||||
|
framesRead += 1
|
||||||
|
let gz = sr.readBytes(payloadLen)
|
||||||
|
if (frameUnit == 1) {
|
||||||
|
gzip.decompFromTo(gz, payloadLen, ipfbuf)
|
||||||
|
decodefun(ipfbuf, common.DISP_RG, common.DISP_BA, width, height, (packetType & 255) == 5)
|
||||||
|
audioR.fire()
|
||||||
|
displayed = true
|
||||||
|
frameCount += 1
|
||||||
|
}
|
||||||
|
sys.free(gz)
|
||||||
|
}
|
||||||
|
else if (packetType == 516) { // iPF1-delta
|
||||||
|
doFrameskip = false
|
||||||
|
let payloadLen = sr.readInt()
|
||||||
|
if (framesRead >= FRAME_COUNT) return { type: 'eof' }
|
||||||
|
framesRead += 1
|
||||||
|
let gz = sr.readBytes(payloadLen)
|
||||||
|
if (frameUnit == 1) {
|
||||||
|
gzip.decompFromTo(gz, payloadLen, ipfbuf)
|
||||||
|
graphics.applyIpf1d(ipfbuf, common.DISP_RG, common.DISP_BA, width, height)
|
||||||
|
audioR.fire()
|
||||||
|
displayed = true
|
||||||
|
frameCount += 1
|
||||||
|
}
|
||||||
|
sys.free(gz)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw Error(`Unknown iPF video packet type ${packetType} at ${sr.getReadCount() - 2}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (4096 <= packetType && packetType <= 6143) { // audio
|
||||||
|
let readLength = (packetType >>> 8 == 17)
|
||||||
|
? common.MP2_FRAME_SIZE[(packetType & 255) >>> 1]
|
||||||
|
: sr.readInt()
|
||||||
|
if (readLength == 0) throw Error("iPF audio read length is zero")
|
||||||
|
if (packetType >>> 8 == 17) { // MP2
|
||||||
|
audioR.ensureMp2()
|
||||||
|
sr.readBytes(readLength, audioR.sndBase - 2368)
|
||||||
|
audio.mp2Decode()
|
||||||
|
audio.mp2UploadDecoded(0)
|
||||||
|
}
|
||||||
|
else if (packetType == 0x1000 || packetType == 0x1001) { // raw PCM
|
||||||
|
audioR.rawPcm(readLength)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw Error(`iPF audio packet type ${packetType} at ${sr.getReadCount() - 2}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Unknown — stop to avoid desync (matches old players' break).
|
||||||
|
return { type: 'eof' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return displayed ? { type: 'frame', frameCount: frameCount } : { type: 'idle' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The frame is already on the display planes (decoded there in step()), so
|
||||||
|
// presenting is a no-op. Bias lighting is a separate, player-driven stage
|
||||||
|
// (bias() below) and is skipped when an explicit background packet disabled it.
|
||||||
|
function blit() { }
|
||||||
|
|
||||||
|
// iPF decodes straight to the 4bpp display planes (no fast JS planar->RGB
|
||||||
|
// path), so — unlike TEV / TAV — there is no RAM RGB888 frame: the planes ARE
|
||||||
|
// the frame. sampleGray/sampleColour therefore read the planes back; this still
|
||||||
|
// costs no extra upload in ASCII mode, since decoding already wrote the planes.
|
||||||
|
function sampleGray(dst, w, h) { common.sampleGrayScreen(width, height, dst, w, h, 4) }
|
||||||
|
function sampleColour(dst, w, h) { common.sampleColourScreen(width, height, dst, w, h, 4) }
|
||||||
|
|
||||||
|
return {
|
||||||
|
info: info,
|
||||||
|
subtitle: subtitle,
|
||||||
|
get frameCount() { return frameCount },
|
||||||
|
get currentTimecodeNs() { return Math.floor(frameCount * (1000000000.0 / fps)) },
|
||||||
|
get videoRate() { return 0 },
|
||||||
|
get frameMode() { return ' ' },
|
||||||
|
cues: [],
|
||||||
|
|
||||||
|
// No generic RAM frame for iPF: it decodes straight to the display planes,
|
||||||
|
// so frameBuffer is 0. Use sampleGray/sampleColour to read the frame instead.
|
||||||
|
get frameBuffer() { return 0 },
|
||||||
|
get frameWidth() { return width },
|
||||||
|
get frameHeight() { return height },
|
||||||
|
|
||||||
|
step: step,
|
||||||
|
blit: blit,
|
||||||
|
bias() { if (autoBg) applyBias() }, // skipped when an explicit bg packet set the colour
|
||||||
|
sampleGray: sampleGray,
|
||||||
|
sampleColour: sampleColour,
|
||||||
|
pause(p) { paused = p; if (p) audioR.stop(); else { audioR.resume(); lastT = sys.nanoTime() } },
|
||||||
|
isPaused() { return paused },
|
||||||
|
setVolume(v) { audioR.setVolume(v) },
|
||||||
|
getVolume() { return audioR.getVolume() },
|
||||||
|
seekSeconds(_n) { /* iPF has no index; seeking unsupported */ },
|
||||||
|
cue(_d) { /* no cues */ },
|
||||||
|
|
||||||
|
close() {
|
||||||
|
sys.free(ipfbuf)
|
||||||
|
audioR.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exports = { create }
|
||||||
757
assets/disk0/tvdos/include/mediadec_tav.mjs
Normal file
757
assets/disk0/tvdos/include/mediadec_tav.mjs
Normal file
@@ -0,0 +1,757 @@
|
|||||||
|
/*
|
||||||
|
* mediadec_tav.mjs — TAV (TSVM Advanced Video) backend for the mediadec library.
|
||||||
|
*
|
||||||
|
* Ported from assets/disk0/tvdos/bin/playtav.js — the heaviest backend. DWT
|
||||||
|
* codec with: I/P frames, unified 3D-DWT GOPs (async triple-buffer + overflow
|
||||||
|
* queue), interlaced fields (yadif), TAP still images, UCF cue files +
|
||||||
|
* multi-file concatenation, Left/Right + cue seeking, screen masking, videotex
|
||||||
|
* (text-mode video), bundled MP2, and MP2/TAD/native-PCM audio, plus extended
|
||||||
|
* headers (XFPS) and timecode-driven subtitles.
|
||||||
|
*
|
||||||
|
* The original main-loop body becomes step(): each call performs one iteration
|
||||||
|
* (optional packet read + GOP state machine + a time-gated display) and, when a
|
||||||
|
* frame is due, materialises it into PRESENT_RGB (an RGB888 RAM buffer) before
|
||||||
|
* returning 'frame'. This is the one structural change from the original: every
|
||||||
|
* source (I/P ping-pong, progressive GOP in the Java-heap videoBuffer, interlaced
|
||||||
|
* GOP) is funnelled into one RAM frame, so blit() (upload to the adapter) and the
|
||||||
|
* ASCII sampler both read from RAM — neither reads pixels back off the display
|
||||||
|
* planes, and `frameBuffer` exposes the frame for arbitrary reuse.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TAV_VERSION = 1
|
||||||
|
const UCF_VERSION = 1
|
||||||
|
const ADDRESSING_EXTERNAL = 0x01
|
||||||
|
const ADDRESSING_INTERNAL = 0x02
|
||||||
|
const TAV_TEMPORAL_LEVELS = 2
|
||||||
|
|
||||||
|
const TAV_PACKET_IFRAME = 0x10
|
||||||
|
const TAV_PACKET_PFRAME = 0x11
|
||||||
|
const TAV_PACKET_GOP_UNIFIED = 0x12
|
||||||
|
const TAV_PACKET_AUDIO_MP2 = 0x20
|
||||||
|
const TAV_PACKET_AUDIO_NATIVE = 0x21
|
||||||
|
const TAV_PACKET_AUDIO_PCM_16LE = 0x22
|
||||||
|
const TAV_PACKET_AUDIO_ADPCM = 0x23
|
||||||
|
const TAV_PACKET_AUDIO_TAD = 0x24
|
||||||
|
const TAV_PACKET_SUBTITLE = 0x30
|
||||||
|
const TAV_PACKET_SUBTITLE_TC = 0x31
|
||||||
|
const TAV_PACKET_VIDEOTEX = 0x3F
|
||||||
|
const TAV_PACKET_AUDIO_BUNDLED = 0x40
|
||||||
|
const TAV_PACKET_EXTENDED_HDR = 0xEF
|
||||||
|
const TAV_PACKET_SCREEN_MASK = 0xF2
|
||||||
|
const TAV_PACKET_GOP_SYNC = 0xFC
|
||||||
|
const TAV_PACKET_TIMECODE = 0xFD
|
||||||
|
const TAV_PACKET_SYNC_NTSC = 0xFE
|
||||||
|
const TAV_PACKET_SYNC = 0xFF
|
||||||
|
const TAV_FILE_HEADER_FIRST = 0x1F
|
||||||
|
|
||||||
|
const BLIP = '\x847u'
|
||||||
|
|
||||||
|
const BUFFER_SLOTS = 3
|
||||||
|
const MAX_GOP_SIZE = 24
|
||||||
|
|
||||||
|
function create(magic, sr, fileLength, opts, common, isTap) {
|
||||||
|
const QLUT = common.QLUT
|
||||||
|
const audioR = common.makeAudioRouter(sr)
|
||||||
|
const subEngine = common.makeSubtitleEngine(sr, -133121) // TAV font-ROM base
|
||||||
|
const SND_BASE = audioR.sndBase
|
||||||
|
const AUDIO_DEVICE = audioR.playhead
|
||||||
|
|
||||||
|
// ── Header (32 bytes incl. magic) ───────────────────────────────────────
|
||||||
|
let version = sr.readOneByte()
|
||||||
|
let width = sr.readShort()
|
||||||
|
let height = sr.readShort()
|
||||||
|
let fps = sr.readOneByte()
|
||||||
|
let fps_num = fps, fps_den = 1
|
||||||
|
let totalFrames = sr.readInt()
|
||||||
|
let waveletFilter = sr.readOneByte()
|
||||||
|
let decompLevels = sr.readOneByte()
|
||||||
|
let qualityY = sr.readOneByte()
|
||||||
|
let qualityCo = sr.readOneByte()
|
||||||
|
let qualityCg = sr.readOneByte()
|
||||||
|
let extraFlags = sr.readOneByte()
|
||||||
|
let videoFlags = sr.readOneByte()
|
||||||
|
let qualityLevel = sr.readOneByte()
|
||||||
|
let channelLayout = sr.readOneByte()
|
||||||
|
let entropyCoder = sr.readOneByte()
|
||||||
|
let encoderPreset = sr.readOneByte()
|
||||||
|
sr.skip(2) // reserved + device orientation
|
||||||
|
let fileRole = sr.readOneByte()
|
||||||
|
|
||||||
|
let baseVersion = (version > 8) ? (version - 8) : version
|
||||||
|
let temporalMotionCoder = (version > 8) ? 1 : 0
|
||||||
|
if (baseVersion < 1 || baseVersion > 8) throw Error(`Unsupported TAV base version ${baseVersion}`)
|
||||||
|
|
||||||
|
const hasAudio = (extraFlags & 0x01) !== 0
|
||||||
|
const hasSubtitles = (extraFlags & 0x02) !== 0
|
||||||
|
let isInterlaced = (videoFlags & 0x01) !== 0
|
||||||
|
let isNTSC = (videoFlags & 0x02) !== 0
|
||||||
|
let isLossless = (videoFlags & 0x04) !== 0
|
||||||
|
let colourSpace = (version % 2 == 0) ? "ICtCp" : "YCoCg"
|
||||||
|
|
||||||
|
// ── Graphics ─────────────────────────────────────────────────────────────
|
||||||
|
graphics.setGraphicsMode(4)
|
||||||
|
graphics.setGraphicsMode(5)
|
||||||
|
graphics.clearPixels(0); graphics.clearPixels2(0); graphics.clearPixels3(0); graphics.clearPixels4(0)
|
||||||
|
let gpuGraphicsMode = graphics.getGraphicsMode()
|
||||||
|
|
||||||
|
let decodeHeight = isInterlaced ? (height >> 1) : height
|
||||||
|
let frametime = 1000000000.0 / fps
|
||||||
|
let FRAME_TIME = 1.0 / fps
|
||||||
|
let applyBias = common.makeBias(width, height, gpuGraphicsMode)
|
||||||
|
|
||||||
|
// ── Frame buffers ────────────────────────────────────────────────────────
|
||||||
|
let FRAME_SIZE = width * height * 3
|
||||||
|
const SLOT_SIZE = MAX_GOP_SIZE * width * height * 3
|
||||||
|
const RGB_BUFFER_A = sys.malloc(FRAME_SIZE)
|
||||||
|
const RGB_BUFFER_B = sys.malloc(FRAME_SIZE)
|
||||||
|
sys.memset(RGB_BUFFER_A, 0, FRAME_SIZE)
|
||||||
|
sys.memset(RGB_BUFFER_B, 0, FRAME_SIZE)
|
||||||
|
let CURRENT_RGB = RGB_BUFFER_A
|
||||||
|
let PREV_RGB = RGB_BUFFER_B
|
||||||
|
|
||||||
|
// Canonical decoded-frame buffer: every displayed frame is materialised here
|
||||||
|
// as RGB888, whatever its source (I/P ping-pong, progressive GOP in the
|
||||||
|
// Java-heap videoBuffer, or an interlaced GOP that needs deinterlacing). This
|
||||||
|
// is the one ~735 kB buffer the generic RAM-frame model costs: blit() uploads
|
||||||
|
// it, the ASCII path samples it, and `frameBuffer` exposes it to callers — so
|
||||||
|
// a frame can be reused without ever round-tripping through the display planes.
|
||||||
|
const PRESENT_RGB = sys.malloc(FRAME_SIZE)
|
||||||
|
sys.memset(PRESENT_RGB, 0, FRAME_SIZE)
|
||||||
|
|
||||||
|
const FIELD_SIZE = width * decodeHeight * 3
|
||||||
|
const CURR_FIELD = isInterlaced ? sys.malloc(FIELD_SIZE) : 0
|
||||||
|
const PREV_FIELD = isInterlaced ? sys.malloc(FIELD_SIZE) : 0
|
||||||
|
const NEXT_FIELD = isInterlaced ? sys.malloc(FIELD_SIZE) : 0
|
||||||
|
if (isInterlaced) { sys.memset(CURR_FIELD, 0, FIELD_SIZE); sys.memset(PREV_FIELD, 0, FIELD_SIZE); sys.memset(NEXT_FIELD, 0, FIELD_SIZE) }
|
||||||
|
let prevField = PREV_FIELD, curField = CURR_FIELD, nextField = NEXT_FIELD
|
||||||
|
|
||||||
|
const info = {
|
||||||
|
format: isTap ? 'tap' : 'tav', width: width, height: height, fps: fps,
|
||||||
|
totalFrames: totalFrames, hasAudio: hasAudio, hasSubtitles: hasSubtitles,
|
||||||
|
isInterlaced: isInterlaced, colourSpace: colourSpace, graphicsMode: gpuGraphicsMode,
|
||||||
|
isStill: !!isTap
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Playback / GOP state ─────────────────────────────────────────────────
|
||||||
|
let frameCount = 0, trueFrameCount = 0
|
||||||
|
let akku = FRAME_TIME, akku2 = 0.0
|
||||||
|
let firstFrameIssued = false
|
||||||
|
let nextFrameTime = 0
|
||||||
|
let paused = false
|
||||||
|
let decoderDbgInfo = {}
|
||||||
|
let videoRate = 0
|
||||||
|
let videoRateBin = []
|
||||||
|
|
||||||
|
let currentGopBufferSlot = 0, currentGopSize = 0, currentGopFrameIndex = 0
|
||||||
|
let readyGopData = null, decodingGopData = null
|
||||||
|
let asyncDecodeInProgress = false, asyncDecodeSlot = 0, asyncDecodeGopSize = 0
|
||||||
|
let asyncDecodePtr = 0, asyncDecodeStartTime = 0
|
||||||
|
let iframeReady = false
|
||||||
|
let shouldReadPackets = true
|
||||||
|
let overflowQueue = []
|
||||||
|
|
||||||
|
let predecodedPcmBuffer = null, predecodedPcmSize = 0, predecodedPcmOffset = 0
|
||||||
|
const PCM_UPLOAD_CHUNK = 2304
|
||||||
|
|
||||||
|
let cueElements = [], currentCueIndex = -1, skipped = false
|
||||||
|
let iframePositions = []
|
||||||
|
let currentFileIndex = 1
|
||||||
|
|
||||||
|
// Subtitle/timecode
|
||||||
|
let currentTimecodeNs = 0, baseTimecodeNs = 0, baseTimecodeFrameCount = 0
|
||||||
|
|
||||||
|
// Screen mask
|
||||||
|
let screenMaskEntries = [], screenMaskTop = 0, screenMaskRight = 0, screenMaskBottom = 0, screenMaskLeft = 0
|
||||||
|
|
||||||
|
// Deferred-display descriptor consumed by blit()/sampleGray().
|
||||||
|
let pending = { kind: null, src: 0, frameIndex: 0, bufferOffset: 0, frameNo: 0, gopSize: 0 }
|
||||||
|
|
||||||
|
let lastT = sys.nanoTime()
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
function updateDataRateBin(rate) { videoRateBin.push(rate); if (videoRateBin.length > 10) videoRateBin.shift() }
|
||||||
|
function getVideoRate() { let b = videoRateBin.reduce((a, c) => a + c, 0); return b * fps / videoRateBin.length }
|
||||||
|
|
||||||
|
function parseXFPS(s) {
|
||||||
|
let p = s.split("/")
|
||||||
|
if (p.length === 2) { let n = parseInt(p[0], 10), d = parseInt(p[1], 10); if (!isNaN(n) && !isNaN(d) && d > 0) { fps_num = n; fps_den = d; fps = n / d; return true } }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateScreenMask(frameNum) {
|
||||||
|
if (screenMaskEntries.length === 0) return
|
||||||
|
for (let i = screenMaskEntries.length - 1; i >= 0; i--) {
|
||||||
|
if (screenMaskEntries[i].frameNum <= frameNum) {
|
||||||
|
screenMaskTop = screenMaskEntries[i].top; screenMaskRight = screenMaskEntries[i].right
|
||||||
|
screenMaskBottom = screenMaskEntries[i].bottom; screenMaskLeft = screenMaskEntries[i].left
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function fillMaskedRegions() { return } // disabled upstream; kept for parity
|
||||||
|
|
||||||
|
function rotateFields() { let t = prevField; prevField = curField; curField = nextField; nextField = t }
|
||||||
|
|
||||||
|
function cleanupAsyncDecode() {
|
||||||
|
// asyncDecodePtr ALIASES readyGopData.compressedPtr / decodingGopData.compressedPtr:
|
||||||
|
// startAsyncGop records the same compressedPtr in both the asyncDecodePtr tracker and
|
||||||
|
// the GOP record (handleGopPacket cases + overflow drain). The normal free paths know
|
||||||
|
// this (free via one var, zero the other); a blind free of all three here double-frees
|
||||||
|
// and sys.free throws "No allocation for pointer", aborting close() before it frees the
|
||||||
|
// RGB frame buffers (leaking two width*height*3 allocations). Free each pointer once.
|
||||||
|
let freed = {}
|
||||||
|
function freeOnce(p) { if (p && !freed[p]) { freed[p] = true; sys.free(p) } }
|
||||||
|
if (asyncDecodeInProgress) freeOnce(asyncDecodePtr)
|
||||||
|
if (readyGopData) freeOnce(readyGopData.compressedPtr)
|
||||||
|
if (decodingGopData) freeOnce(decodingGopData.compressedPtr)
|
||||||
|
asyncDecodeInProgress = false; asyncDecodePtr = 0; asyncDecodeGopSize = 0
|
||||||
|
readyGopData = null; decodingGopData = null
|
||||||
|
if (predecodedPcmBuffer !== null) { sys.free(predecodedPcmBuffer); predecodedPcmBuffer = null; predecodedPcmSize = 0; predecodedPcmOffset = 0 }
|
||||||
|
currentGopSize = 0; currentGopFrameIndex = 0; nextFrameTime = 0; shouldReadPackets = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNearestIframe(targetFrame) {
|
||||||
|
if (iframePositions.length === 0) return null
|
||||||
|
let result = null
|
||||||
|
for (let i = iframePositions.length - 1; i >= 0; i--) { if (iframePositions[i].frameNum <= targetFrame) { result = iframePositions[i]; break } }
|
||||||
|
return result || iframePositions[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanForwardToIframe(targetFrame) {
|
||||||
|
let savedPos = sr.getReadCount()
|
||||||
|
try {
|
||||||
|
let scanFrameCount = frameCount
|
||||||
|
while (sr.getReadCount() < fileLength) {
|
||||||
|
let packetPos = sr.getReadCount()
|
||||||
|
let pType = sr.readOneByte()
|
||||||
|
if (pType === TAV_PACKET_SYNC || pType === TAV_PACKET_SYNC_NTSC) { if (pType === TAV_PACKET_SYNC) scanFrameCount++; continue }
|
||||||
|
if (pType === TAV_PACKET_IFRAME && scanFrameCount >= targetFrame) { iframePositions.push({ offset: packetPos, frameNum: scanFrameCount }); return { offset: packetPos, frameNum: scanFrameCount } }
|
||||||
|
if (pType !== TAV_PACKET_SYNC && pType !== TAV_PACKET_SYNC_NTSC && pType !== TAV_FILE_HEADER_FIRST) { let s = sr.readInt(); sr.skip(s) }
|
||||||
|
else if (pType === TAV_FILE_HEADER_FIRST) break
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch (e) { serial.printerr(`Scan error: ${e}`); return null }
|
||||||
|
finally { sr.seek(savedPos) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyNewHeader(h) {
|
||||||
|
version = h.version; width = h.width; height = h.height; fps = h.fps
|
||||||
|
totalFrames = h.totalFrames; waveletFilter = h.waveletFilter; decompLevels = h.decompLevels
|
||||||
|
qualityY = h.qualityY; qualityCo = h.qualityCo; qualityCg = h.qualityCg
|
||||||
|
extraFlags = h.extraFlags; videoFlags = h.videoFlags; qualityLevel = h.qualityLevel
|
||||||
|
channelLayout = h.channelLayout
|
||||||
|
baseVersion = (version > 8) ? (version - 8) : version
|
||||||
|
temporalMotionCoder = (version > 8) ? 1 : 0
|
||||||
|
isInterlaced = (videoFlags & 0x01) !== 0; isNTSC = (videoFlags & 0x02) !== 0; isLossless = (videoFlags & 0x04) !== 0
|
||||||
|
colourSpace = (version % 2 == 0) ? "ICtCp" : "YCoCg"
|
||||||
|
decodeHeight = isInterlaced ? (height >> 1) : height
|
||||||
|
frametime = 1000000000.0 / fps; FRAME_TIME = 1.0 / fps
|
||||||
|
applyBias = common.makeBias(width, height, gpuGraphicsMode)
|
||||||
|
info.width = width; info.height = height; info.fps = fps; info.totalFrames = totalFrames
|
||||||
|
info.isInterlaced = isInterlaced; info.colourSpace = colourSpace
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a header object on success, or null/error code.
|
||||||
|
function tryReadNextTAVHeader() {
|
||||||
|
let newMagic = new Array(7)
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < newMagic.length; i++) newMagic[i] = sr.readOneByte()
|
||||||
|
while (newMagic[0] == 255) { newMagic.shift(); newMagic[newMagic.length - 1] = sr.readOneByte() }
|
||||||
|
|
||||||
|
let isValidTAV = true, isValidUCF = true
|
||||||
|
for (let i = 0; i < newMagic.length; i++) { if (newMagic[i] !== common.MAGIC_TAV[i + 1]) isValidTAV = false }
|
||||||
|
for (let i = 0; i < newMagic.length; i++) { if (newMagic[i] !== common.MAGIC_UCF[i + 1]) isValidUCF = false }
|
||||||
|
if (!isValidTAV && !isValidUCF) { serial.printerr("Header mismatch: got " + newMagic.join()); return null }
|
||||||
|
|
||||||
|
if (isValidTAV) {
|
||||||
|
let h = {
|
||||||
|
version: sr.readOneByte(), width: sr.readShort(), height: sr.readShort(),
|
||||||
|
fps: sr.readOneByte(), totalFrames: sr.readInt(), waveletFilter: sr.readOneByte(),
|
||||||
|
decompLevels: sr.readOneByte(), qualityY: sr.readOneByte(), qualityCo: sr.readOneByte(),
|
||||||
|
qualityCg: sr.readOneByte(), extraFlags: sr.readOneByte(), videoFlags: sr.readOneByte(),
|
||||||
|
qualityLevel: sr.readOneByte(), channelLayout: sr.readOneByte(), fileRole: sr.readOneByte()
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 4; i++) sr.readOneByte() // reserved
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
// UCF cue file: parse cue table then recurse to the following TAV header.
|
||||||
|
let uver = sr.readOneByte()
|
||||||
|
if (uver !== UCF_VERSION) { serial.println(`Unsupported UCF version ${uver}`); return null }
|
||||||
|
let numElements = sr.readShort()
|
||||||
|
let cueSize = sr.readInt()
|
||||||
|
sr.skip(1)
|
||||||
|
for (let i = 0; i < numElements; i++) {
|
||||||
|
let el = {}
|
||||||
|
el.addressingModeAndIntent = sr.readOneByte()
|
||||||
|
el.addressingMode = el.addressingModeAndIntent & 15
|
||||||
|
let nameLen = sr.readShort()
|
||||||
|
el.name = sr.readString(nameLen)
|
||||||
|
if (el.addressingMode === ADDRESSING_EXTERNAL) { let pl = sr.readShort(); el.path = sr.readString(pl) }
|
||||||
|
else if (el.addressingMode === ADDRESSING_INTERNAL) {
|
||||||
|
let ob = []
|
||||||
|
for (let j = 0; j < 6; j++) ob.push(sr.readOneByte())
|
||||||
|
let low32 = 0; for (let j = 0; j < 4; j++) low32 |= (ob[j] << (j * 8))
|
||||||
|
let high16 = 0; for (let j = 4; j < 6; j++) high16 |= (ob[j] << ((j - 4) * 8))
|
||||||
|
el.offset = (high16 * 0x100000000) + (low32 >>> 0)
|
||||||
|
} else { serial.println(`Unknown addressing mode ${el.addressingMode}`); return null }
|
||||||
|
cueElements.push(el)
|
||||||
|
}
|
||||||
|
let rc = sr.getReadCount()
|
||||||
|
sr.skip(cueSize - rc + 1)
|
||||||
|
currentFileIndex -= 1
|
||||||
|
return tryReadNextTAVHeader()
|
||||||
|
} catch (e) { serial.printerr(e); return null }
|
||||||
|
}
|
||||||
|
|
||||||
|
function feedPredecodedPcm() {
|
||||||
|
if (predecodedPcmBuffer !== null && predecodedPcmOffset < predecodedPcmSize) {
|
||||||
|
let remaining = predecodedPcmSize - predecodedPcmOffset
|
||||||
|
let uploadSize = Math.min(PCM_UPLOAD_CHUNK, remaining)
|
||||||
|
sys.memcpy(predecodedPcmBuffer + predecodedPcmOffset, SND_BASE, uploadSize)
|
||||||
|
audio.setSampleUploadLength(AUDIO_DEVICE, uploadSize)
|
||||||
|
audio.startSampleUpload(AUDIO_DEVICE)
|
||||||
|
predecodedPcmOffset += uploadSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAsyncGop(d) {
|
||||||
|
graphics.tavDecodeGopToVideoBufferAsync(
|
||||||
|
d.compressedPtr, d.compressedSize, d.gopSize,
|
||||||
|
width, decodeHeight, baseVersion >= 5, qualityLevel,
|
||||||
|
QLUT[qualityY], QLUT[qualityCo], QLUT[qualityCg], channelLayout,
|
||||||
|
waveletFilter, decompLevels, TAV_TEMPORAL_LEVELS, entropyCoder,
|
||||||
|
d.slot * SLOT_SIZE, temporalMotionCoder, encoderPreset
|
||||||
|
)
|
||||||
|
asyncDecodeInProgress = true; asyncDecodeSlot = d.slot; asyncDecodeGopSize = d.gopSize
|
||||||
|
asyncDecodePtr = d.compressedPtr; asyncDecodeStartTime = sys.nanoTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Decode one I/P video packet into CURRENT_RGB (or field buffer) ───────
|
||||||
|
function decodeIPFrame(packetType, packetOffset) {
|
||||||
|
updateScreenMask(frameCount)
|
||||||
|
if (packetType === TAV_PACKET_IFRAME) iframePositions.push({ offset: packetOffset, frameNum: frameCount })
|
||||||
|
const compressedSize = sr.readInt()
|
||||||
|
let compressedPtr = sr.readBytes(compressedSize)
|
||||||
|
updateDataRateBin(compressedSize)
|
||||||
|
videoRate = compressedSize
|
||||||
|
try {
|
||||||
|
let decodeTarget = isInterlaced ? curField : CURRENT_RGB
|
||||||
|
decoderDbgInfo = graphics.tavDecodeCompressed(
|
||||||
|
compressedPtr, compressedSize, decodeTarget, PREV_RGB,
|
||||||
|
width, decodeHeight, qualityLevel,
|
||||||
|
QLUT[qualityY], QLUT[qualityCo], QLUT[qualityCg], channelLayout,
|
||||||
|
trueFrameCount, waveletFilter, decompLevels, isLossless, version, entropyCoder, encoderPreset
|
||||||
|
)
|
||||||
|
if (isInterlaced) {
|
||||||
|
graphics.tavDeinterlace(trueFrameCount, width, decodeHeight, prevField, curField, nextField, CURRENT_RGB, "yadif")
|
||||||
|
rotateFields()
|
||||||
|
}
|
||||||
|
iframeReady = true
|
||||||
|
} catch (e) { console.log(`TAV frame ${frameCount}: decode failed: ${e}`) }
|
||||||
|
finally { sys.free(compressedPtr) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GOP packet handling (Cases 1–5 + overflow) ──────────────────────────
|
||||||
|
function handleGopPacket() {
|
||||||
|
const gopSize = sr.readOneByte()
|
||||||
|
const compressedSize = sr.readInt()
|
||||||
|
let compressedPtr = sr.readBytes(compressedSize)
|
||||||
|
updateDataRateBin(compressedSize / gopSize)
|
||||||
|
decoderDbgInfo.frameMode = " "
|
||||||
|
|
||||||
|
if (gopSize > MAX_GOP_SIZE) { sys.free(compressedPtr); return }
|
||||||
|
|
||||||
|
if (currentGopSize === 0 && !asyncDecodeInProgress) {
|
||||||
|
if (asyncDecodePtr !== 0) { sys.free(asyncDecodePtr); asyncDecodePtr = 0 }
|
||||||
|
startAsyncGop({ compressedPtr, compressedSize, gopSize, slot: currentGopBufferSlot })
|
||||||
|
}
|
||||||
|
else if (currentGopSize === 0 && asyncDecodeInProgress) {
|
||||||
|
if (readyGopData === null) {
|
||||||
|
readyGopData = { gopSize, slot: (currentGopBufferSlot + 1) % BUFFER_SLOTS, compressedPtr, compressedSize, needsDecode: true, startTime: 0, timeRemaining: 0 }
|
||||||
|
} else if (decodingGopData === null) {
|
||||||
|
decodingGopData = { gopSize, slot: (currentGopBufferSlot + 2) % BUFFER_SLOTS, compressedPtr, compressedSize, needsDecode: true, startTime: 0, timeRemaining: 0 }
|
||||||
|
shouldReadPackets = false
|
||||||
|
} else { sys.free(compressedPtr) }
|
||||||
|
}
|
||||||
|
else if (currentGopSize > 0 && readyGopData === null && !asyncDecodeInProgress && graphics.tavDecodeGopIsComplete()) {
|
||||||
|
let nextSlot = (currentGopBufferSlot + 1) % BUFFER_SLOTS
|
||||||
|
startAsyncGop({ compressedPtr, compressedSize, gopSize, slot: nextSlot })
|
||||||
|
readyGopData = { gopSize, slot: nextSlot, compressedPtr, startTime: asyncDecodeStartTime, timeRemaining: 0 }
|
||||||
|
shouldReadPackets = false
|
||||||
|
}
|
||||||
|
else if (currentGopSize > 0 && readyGopData !== null && decodingGopData === null && !asyncDecodeInProgress && graphics.tavDecodeGopIsComplete()) {
|
||||||
|
let decodingSlot = (currentGopBufferSlot + 2) % BUFFER_SLOTS
|
||||||
|
startAsyncGop({ compressedPtr, compressedSize, gopSize, slot: decodingSlot })
|
||||||
|
decodingGopData = { gopSize, slot: decodingSlot, compressedPtr, startTime: asyncDecodeStartTime, timeRemaining: 0 }
|
||||||
|
shouldReadPackets = false
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
overflowQueue.push({ gopSize, compressedPtr, compressedSize })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── One packet ───────────────────────────────────────────────────────────
|
||||||
|
// Returns true if a multi-file header switch happened (caller emits 'newfile').
|
||||||
|
function readOnePacket() {
|
||||||
|
let packetOffset = sr.getReadCount()
|
||||||
|
let packetType = sr.readOneByte()
|
||||||
|
let newfile = false
|
||||||
|
|
||||||
|
if (packetType == TAV_FILE_HEADER_FIRST) {
|
||||||
|
let nh = tryReadNextTAVHeader()
|
||||||
|
if (nh) {
|
||||||
|
applyNewHeader(nh)
|
||||||
|
frameCount = 0; akku = 0.0; akku2 = 0.0; firstFrameIssued = false
|
||||||
|
baseTimecodeNs = 0; baseTimecodeFrameCount = 0; currentTimecodeNs = 0
|
||||||
|
audio.purgeQueue(AUDIO_DEVICE)
|
||||||
|
currentFileIndex++
|
||||||
|
if (skipped) skipped = false; else currentCueIndex++
|
||||||
|
packetType = sr.readOneByte()
|
||||||
|
newfile = true
|
||||||
|
} else { return { eof: true } }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (packetType === TAV_PACKET_SYNC || packetType == TAV_PACKET_SYNC_NTSC) {
|
||||||
|
// vestigial in TAV's time-based model
|
||||||
|
}
|
||||||
|
else if (packetType === TAV_PACKET_IFRAME || packetType === TAV_PACKET_PFRAME) {
|
||||||
|
decodeIPFrame(packetType, packetOffset)
|
||||||
|
}
|
||||||
|
else if (packetType === TAV_PACKET_GOP_UNIFIED) {
|
||||||
|
handleGopPacket()
|
||||||
|
}
|
||||||
|
else if (packetType === TAV_PACKET_GOP_SYNC) {
|
||||||
|
sr.readOneByte() // frames-in-GOP (ignored; time-based)
|
||||||
|
if (currentGopSize > 0 && readyGopData !== null && decodingGopData !== null) shouldReadPackets = false
|
||||||
|
}
|
||||||
|
else if (packetType === TAV_PACKET_AUDIO_BUNDLED) {
|
||||||
|
let totalAudioSize = sr.readInt()
|
||||||
|
audioR.ensureMp2()
|
||||||
|
let mp2Buffer = sys.malloc(totalAudioSize)
|
||||||
|
sr.readBytes(totalAudioSize, mp2Buffer)
|
||||||
|
const estimatedPcmSize = totalAudioSize * 12
|
||||||
|
predecodedPcmBuffer = sys.malloc(estimatedPcmSize); predecodedPcmSize = 0; predecodedPcmOffset = 0
|
||||||
|
const MP2_DECODE_CHUNK = 2304
|
||||||
|
let srcOffset = 0
|
||||||
|
while (srcOffset < totalAudioSize) {
|
||||||
|
let chunkSize = Math.min(MP2_DECODE_CHUNK, totalAudioSize - srcOffset)
|
||||||
|
sys.memcpy(mp2Buffer + srcOffset, SND_BASE - 2368, chunkSize)
|
||||||
|
audio.mp2Decode()
|
||||||
|
sys.memcpy(SND_BASE, predecodedPcmBuffer + predecodedPcmSize, 2304)
|
||||||
|
predecodedPcmSize += 2304
|
||||||
|
srcOffset += chunkSize
|
||||||
|
}
|
||||||
|
sys.free(mp2Buffer)
|
||||||
|
}
|
||||||
|
else if (packetType === TAV_PACKET_AUDIO_MP2) { let len = sr.readInt(); audioR.mp2(len) }
|
||||||
|
else if (packetType === TAV_PACKET_AUDIO_TAD) { let sampleLen = sr.readShort(); let payloadLen = sr.readInt(); audioR.tad(sampleLen, payloadLen) }
|
||||||
|
else if (packetType === TAV_PACKET_AUDIO_NATIVE) { let zstdLen = sr.readInt(); audioR.nativePcm(zstdLen) }
|
||||||
|
else if (packetType === TAV_PACKET_SUBTITLE) { let size = sr.readInt(); subEngine.parseLegacy(size) }
|
||||||
|
else if (packetType === TAV_PACKET_SUBTITLE_TC) { let size = sr.readInt(); subEngine.parseTC(size) }
|
||||||
|
else if (packetType === TAV_PACKET_VIDEOTEX) {
|
||||||
|
let compressedSize = sr.readInt()
|
||||||
|
let compressedPtr = sr.readBytes(compressedSize)
|
||||||
|
let decompressedPtr = sys.malloc(8192)
|
||||||
|
gzip.decompFromTo(compressedPtr, compressedSize, decompressedPtr)
|
||||||
|
let rows = sys.peek(decompressedPtr), cols = sys.peek(decompressedPtr + 1)
|
||||||
|
let gridSize = rows * cols
|
||||||
|
sys.memcpy(decompressedPtr + 2, -1302529, gridSize * 3)
|
||||||
|
sys.free(compressedPtr); sys.free(decompressedPtr)
|
||||||
|
iframeReady = true // displayed via the I/P path (uploads CURRENT_RGB under the text)
|
||||||
|
}
|
||||||
|
else if (packetType === TAV_PACKET_EXTENDED_HDR) {
|
||||||
|
let numPairs = sr.readShort()
|
||||||
|
for (let i = 0; i < numPairs; i++) {
|
||||||
|
let keyBytes = sr.readBytes(4); let key = ""
|
||||||
|
for (let j = 0; j < 4; j++) key += String.fromCharCode(sys.peek(keyBytes + j))
|
||||||
|
sys.free(keyBytes)
|
||||||
|
let valueType = sr.readOneByte()
|
||||||
|
if (valueType === 0x04) { sr.readInt(); sr.readInt() }
|
||||||
|
else if (valueType === 0x10) {
|
||||||
|
let length = sr.readShort(); let dataBytes = sr.readBytes(length); let dataStr = ""
|
||||||
|
for (let j = 0; j < length; j++) dataStr += String.fromCharCode(sys.peek(dataBytes + j))
|
||||||
|
sys.free(dataBytes)
|
||||||
|
if (key === "XFPS" && parseXFPS(dataStr)) { frametime = 1000000000.0 / fps; FRAME_TIME = 1.0 / fps }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (packetType === TAV_PACKET_SCREEN_MASK) {
|
||||||
|
let frameNum = sr.readInt()
|
||||||
|
let top = sr.readOneByte() | (sr.readOneByte() << 8)
|
||||||
|
let right = sr.readOneByte() | (sr.readOneByte() << 8)
|
||||||
|
let bottom = sr.readOneByte() | (sr.readOneByte() << 8)
|
||||||
|
let left = sr.readOneByte() | (sr.readOneByte() << 8)
|
||||||
|
screenMaskEntries.push({ frameNum, top, right, bottom, left })
|
||||||
|
}
|
||||||
|
else if (packetType === TAV_PACKET_TIMECODE) {
|
||||||
|
let lo = sr.readInt(), hi = sr.readInt()
|
||||||
|
let tc = hi * 0x100000000 + (lo >>> 0)
|
||||||
|
baseTimecodeNs = tc; baseTimecodeFrameCount = frameCount; currentTimecodeNs = tc
|
||||||
|
decoderDbgInfo.frameMode = BLIP
|
||||||
|
}
|
||||||
|
else if (packetType == 0x00) { /* stray arg-terminator byte */ }
|
||||||
|
else { serial.println(`TAV unknown packet 0x${packetType.toString(16)}`); return { eof: true } }
|
||||||
|
|
||||||
|
return { newfile: newfile }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── step(): one main-loop iteration ─────────────────────────────────────
|
||||||
|
function step() {
|
||||||
|
// TAP still: show the pre-decoded frame once, then idle.
|
||||||
|
if (isTap) {
|
||||||
|
if (!firstFrameIssued) { firstFrameIssued = true; pending = { kind: 'rgb', src: CURRENT_RGB, frameNo: 0 }; materializeFrame(); return { type: 'frame', frameCount: 1 } }
|
||||||
|
return { type: 'idle' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// EOF: stream exhausted and nothing buffered.
|
||||||
|
if (sr.getReadCount() >= fileLength && currentGopSize === 0 && readyGopData === null && decodingGopData === null && !asyncDecodeInProgress && overflowQueue.length === 0) {
|
||||||
|
return { type: 'eof' }
|
||||||
|
}
|
||||||
|
|
||||||
|
let newfileEvent = false
|
||||||
|
|
||||||
|
// 1) Gated packet read.
|
||||||
|
if (shouldReadPackets && !paused && sr.getReadCount() < fileLength) {
|
||||||
|
let r = readOnePacket()
|
||||||
|
if (r.eof) return { type: 'eof' }
|
||||||
|
if (r.newfile) newfileEvent = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time accumulation (only while a GOP plays / after first frame).
|
||||||
|
let t2 = sys.nanoTime()
|
||||||
|
if (!paused && firstFrameIssued) {
|
||||||
|
let dt = (t2 - lastT) / 1000000000.0
|
||||||
|
if (currentGopSize > 0) akku += dt
|
||||||
|
akku2 += dt
|
||||||
|
}
|
||||||
|
lastT = t2
|
||||||
|
|
||||||
|
let displayed = false
|
||||||
|
|
||||||
|
// Step 1: first-GOP decode wait.
|
||||||
|
if (asyncDecodeInProgress && currentGopSize === 0) {
|
||||||
|
if (!graphics.tavDecodeGopIsComplete()) { sys.sleep(1) }
|
||||||
|
else {
|
||||||
|
const res = graphics.tavDecodeGopGetResult(); decoderDbgInfo = res[1]
|
||||||
|
currentGopSize = asyncDecodeGopSize; currentGopFrameIndex = 0; currentGopBufferSlot = asyncDecodeSlot
|
||||||
|
asyncDecodeInProgress = false
|
||||||
|
if (nextFrameTime === 0) nextFrameTime = sys.nanoTime()
|
||||||
|
if (!(currentGopSize > 0 && readyGopData !== null && decodingGopData !== null)) shouldReadPackets = true
|
||||||
|
sys.free(asyncDecodePtr); asyncDecodePtr = 0; asyncDecodeGopSize = 0
|
||||||
|
if (readyGopData !== null && readyGopData.needsDecode) {
|
||||||
|
startAsyncGop(readyGopData); readyGopData.needsDecode = false; readyGopData.startTime = asyncDecodeStartTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2a: display I/P frame when due.
|
||||||
|
if (!paused && iframeReady && currentGopSize === 0) {
|
||||||
|
if (nextFrameTime === 0) nextFrameTime = sys.nanoTime()
|
||||||
|
while (sys.nanoTime() < nextFrameTime && !paused) sys.sleep(1)
|
||||||
|
if (!paused) {
|
||||||
|
pending = { kind: 'rgb', src: CURRENT_RGB, frameNo: trueFrameCount }
|
||||||
|
materializeFrame()
|
||||||
|
audioR.fire()
|
||||||
|
firstFrameIssued = true
|
||||||
|
frameCount++; trueFrameCount++; iframeReady = false
|
||||||
|
currentTimecodeNs = Math.floor(akku2 * 1000000000)
|
||||||
|
if (subEngine.hasEvents()) subEngine.poll(currentTimecodeNs)
|
||||||
|
let t = CURRENT_RGB; CURRENT_RGB = PREV_RGB; PREV_RGB = t
|
||||||
|
nextFrameTime += frametime
|
||||||
|
displayed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2&3: display GOP frame when due.
|
||||||
|
if (!paused && currentGopSize > 0 && currentGopFrameIndex < currentGopSize) {
|
||||||
|
while (sys.nanoTime() < nextFrameTime && !paused) sys.sleep(1)
|
||||||
|
if (!paused) {
|
||||||
|
if (isInterlaced) pending = { kind: 'gop-interlaced', frameIndex: currentGopFrameIndex, bufferOffset: currentGopBufferSlot * SLOT_SIZE, frameNo: trueFrameCount, gopSize: currentGopSize }
|
||||||
|
else pending = { kind: 'gop', frameIndex: currentGopFrameIndex, bufferOffset: currentGopBufferSlot * SLOT_SIZE, frameNo: trueFrameCount, gopSize: currentGopSize }
|
||||||
|
materializeFrame()
|
||||||
|
audioR.fire()
|
||||||
|
firstFrameIssued = true
|
||||||
|
currentGopFrameIndex++; frameCount++; trueFrameCount++
|
||||||
|
currentTimecodeNs = Math.floor(akku2 * 1000000000)
|
||||||
|
if (subEngine.hasEvents()) subEngine.poll(currentTimecodeNs)
|
||||||
|
feedPredecodedPcm()
|
||||||
|
if (decodingGopData !== null && decodingGopData.needsDecode && graphics.tavDecodeGopIsComplete()) {
|
||||||
|
startAsyncGop(decodingGopData); decodingGopData.needsDecode = false; decodingGopData.startTime = asyncDecodeStartTime
|
||||||
|
}
|
||||||
|
nextFrameTime += frametime
|
||||||
|
displayed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4–7: GOP finished → transition to ready GOP (triple-buffer rotate).
|
||||||
|
if (!paused && currentGopSize > 0 && currentGopFrameIndex >= currentGopSize) {
|
||||||
|
if (readyGopData !== null) {
|
||||||
|
if (readyGopData.needsDecode) { startAsyncGop(readyGopData); readyGopData.needsDecode = false; readyGopData.startTime = sys.nanoTime() }
|
||||||
|
while (!graphics.tavDecodeGopIsComplete() && !paused) sys.sleep(1)
|
||||||
|
if (!paused) {
|
||||||
|
graphics.tavDecodeGopGetResult()
|
||||||
|
sys.free(readyGopData.compressedPtr)
|
||||||
|
currentGopBufferSlot = readyGopData.slot; currentGopSize = readyGopData.gopSize; currentGopFrameIndex = 0
|
||||||
|
readyGopData = decodingGopData; decodingGopData = null
|
||||||
|
if (graphics.tavDecodeGopIsComplete()) { asyncDecodeInProgress = false; asyncDecodePtr = 0; asyncDecodeGopSize = 0 }
|
||||||
|
shouldReadPackets = true
|
||||||
|
// Drain overflow queue into a free slot.
|
||||||
|
if (overflowQueue.length > 0 && !asyncDecodeInProgress && graphics.tavDecodeGopIsComplete()) {
|
||||||
|
const ov = overflowQueue.shift()
|
||||||
|
let targetSlot = (readyGopData === null) ? (currentGopBufferSlot + 1) % BUFFER_SLOTS
|
||||||
|
: (decodingGopData === null) ? (currentGopBufferSlot + 2) % BUFFER_SLOTS : -1
|
||||||
|
if (targetSlot < 0) overflowQueue.unshift(ov)
|
||||||
|
else {
|
||||||
|
startAsyncGop({ compressedPtr: ov.compressedPtr, compressedSize: ov.compressedSize, gopSize: ov.gopSize, slot: targetSlot })
|
||||||
|
let rec = { gopSize: ov.gopSize, slot: targetSlot, compressedPtr: ov.compressedPtr, startTime: asyncDecodeStartTime, timeRemaining: 0 }
|
||||||
|
if (readyGopData === null) readyGopData = rec; else decodingGopData = rec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentGopSize = 0; currentGopFrameIndex = 0; shouldReadPackets = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sys.sleep(1)
|
||||||
|
|
||||||
|
if (newfileEvent) return { type: 'newfile', frameCount: frameCount }
|
||||||
|
return displayed ? { type: 'frame', frameCount: frameCount } : { type: 'idle' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Materialise / present / sample ───────────────────────────────────────
|
||||||
|
// Land the just-decoded frame in PRESENT_RGB (RGB888 RAM), whatever its source.
|
||||||
|
// Called by step() the moment a frame becomes due, so blit() (upload) and the
|
||||||
|
// ASCII sampler can both consume it from RAM and neither path has to read the
|
||||||
|
// pixels back off the display planes.
|
||||||
|
// rgb : I/P (or TAP still) — already RGB888 in CURRENT_RGB; copy in.
|
||||||
|
// gop : progressive GOP frame in the Java-heap videoBuffer; copy out.
|
||||||
|
// gop-interlaced : interlaced GOP fields; deinterlace into PRESENT_RGB.
|
||||||
|
function materializeFrame() {
|
||||||
|
if (pending.kind === 'rgb') {
|
||||||
|
sys.memcpy(pending.src, PRESENT_RGB, FRAME_SIZE)
|
||||||
|
} else if (pending.kind === 'gop') {
|
||||||
|
graphics.tavCopyGopFrameToRGB(pending.frameIndex, width, height, pending.bufferOffset, PRESENT_RGB)
|
||||||
|
} else if (pending.kind === 'gop-interlaced') {
|
||||||
|
graphics.tavDeinterlaceGopFrameToRGB(pending.frameIndex, pending.gopSize, width, decodeHeight, height, pending.frameNo, pending.bufferOffset, prevField, curField, nextField, PRESENT_RGB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Present the materialised RAM frame to the display planes (with dithering).
|
||||||
|
// bias lighting is a separate, player-driven stage (bias() below).
|
||||||
|
function blit() {
|
||||||
|
graphics.uploadRGBToFramebuffer(PRESENT_RGB, width, height, pending.frameNo, false)
|
||||||
|
if (pending.kind === 'gop' || pending.kind === 'gop-interlaced') { updateScreenMask(frameCount); fillMaskedRegions() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The current frame already sits in PRESENT_RGB (materialised in step()), so
|
||||||
|
// sampling never touches the display planes — ASCII mode needs no blit().
|
||||||
|
function sampleGray(dst, w, h) { common.sampleGrayRGB(PRESENT_RGB, width, height, dst, w, h) }
|
||||||
|
function sampleColour(dst, w, h) { common.sampleColourRGB(PRESENT_RGB, width, height, dst, w, h) }
|
||||||
|
|
||||||
|
// ── TAP still: decode the single image now ──────────────────────────────
|
||||||
|
if (isTap) {
|
||||||
|
let packetType = sr.readOneByte()
|
||||||
|
while (packetType !== TAV_PACKET_IFRAME && sr.getReadCount() < fileLength) {
|
||||||
|
if (packetType === TAV_PACKET_EXTENDED_HDR) {
|
||||||
|
let numPairs = sr.readShort()
|
||||||
|
for (let i = 0; i < numPairs; i++) {
|
||||||
|
let kb = sr.readBytes(4); let key = ""; for (let j = 0; j < 4; j++) key += String.fromCharCode(sys.peek(kb + j)); sys.free(kb)
|
||||||
|
let vt = sr.readOneByte()
|
||||||
|
if (vt === 0x04) sr.skip(8)
|
||||||
|
else if (vt === 0x10) { let len = sr.readShort(); let db = sr.readBytes(len); if (key === "XFPS") { let s = ""; for (let j = 0; j < len; j++) s += String.fromCharCode(sys.peek(db + j)); parseXFPS(s) } sys.free(db) }
|
||||||
|
}
|
||||||
|
} else if (packetType === TAV_PACKET_SCREEN_MASK) { sr.skip(12) }
|
||||||
|
else if (packetType === TAV_PACKET_TIMECODE) { sr.skip(8) }
|
||||||
|
else { let size = sr.readInt(); sr.skip(size) }
|
||||||
|
packetType = sr.readOneByte()
|
||||||
|
}
|
||||||
|
if (packetType === TAV_PACKET_IFRAME) {
|
||||||
|
const compressedSize = sr.readInt()
|
||||||
|
const compressedPtr = sr.readBytes(compressedSize)
|
||||||
|
graphics.tavDecodeCompressed(compressedPtr, compressedSize, CURRENT_RGB, PREV_RGB, width, height, qualityLevel, QLUT[qualityY], QLUT[qualityCo], QLUT[qualityCg], channelLayout, 0, waveletFilter, decompLevels, isLossless, version, entropyCoder, 2)
|
||||||
|
sys.free(compressedPtr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
info: info,
|
||||||
|
subtitle: subEngine.subtitle,
|
||||||
|
get frameCount() { return frameCount },
|
||||||
|
get currentTimecodeNs() { return currentTimecodeNs },
|
||||||
|
get akku() { return akku2 },
|
||||||
|
get videoRate() { return getVideoRate() },
|
||||||
|
get frameMode() { return decoderDbgInfo.frameMode || ' ' },
|
||||||
|
get qY() { return decoderDbgInfo.qY }, get qCo() { return decoderDbgInfo.qCo }, get qCg() { return decoderDbgInfo.qCg },
|
||||||
|
get cues() { return cueElements },
|
||||||
|
get currentCueIndex() { return currentCueIndex },
|
||||||
|
get currentFileIndex() { return currentFileIndex },
|
||||||
|
|
||||||
|
// Generic RAM frame: RGB888 buffer holding the current decoded frame,
|
||||||
|
// valid after step() returns 'frame'. Callers may read it for their own use.
|
||||||
|
get frameBuffer() { return PRESENT_RGB },
|
||||||
|
get frameWidth() { return width },
|
||||||
|
get frameHeight() { return height },
|
||||||
|
|
||||||
|
step: step,
|
||||||
|
blit: blit,
|
||||||
|
bias() { applyBias() },
|
||||||
|
sampleGray: sampleGray,
|
||||||
|
sampleColour: sampleColour,
|
||||||
|
|
||||||
|
pause(p) {
|
||||||
|
paused = p
|
||||||
|
if (p) audioR.stop()
|
||||||
|
else { audioR.resume(); lastT = sys.nanoTime() }
|
||||||
|
},
|
||||||
|
isPaused() { return paused },
|
||||||
|
setVolume(v) { audioR.setVolume(v) },
|
||||||
|
getVolume() { return audioR.getVolume() },
|
||||||
|
|
||||||
|
seekSeconds(n) {
|
||||||
|
if (isTap) return
|
||||||
|
let target
|
||||||
|
if (n < 0) target = Math.max(0, frameCount - Math.floor(fps * (-n)))
|
||||||
|
else target = Math.min(totalFrames - 1, frameCount + Math.floor(fps * n))
|
||||||
|
let seekTarget = findNearestIframe(target)
|
||||||
|
if (n > 0 && (!seekTarget || seekTarget.frameNum <= frameCount)) seekTarget = scanForwardToIframe(target)
|
||||||
|
if (!seekTarget) return
|
||||||
|
if (n > 0 && seekTarget.frameNum <= frameCount) return
|
||||||
|
cleanupAsyncDecode()
|
||||||
|
sr.seek(seekTarget.offset)
|
||||||
|
frameCount = seekTarget.frameNum; akku = FRAME_TIME; akku2 += n; firstFrameIssued = false
|
||||||
|
baseTimecodeNs = Math.floor(seekTarget.frameNum * frametime); baseTimecodeFrameCount = seekTarget.frameNum; currentTimecodeNs = baseTimecodeNs
|
||||||
|
subEngine.resetTo(baseTimecodeNs)
|
||||||
|
audio.purgeQueue(AUDIO_DEVICE)
|
||||||
|
skipped = true
|
||||||
|
},
|
||||||
|
|
||||||
|
cue(d) {
|
||||||
|
if (cueElements.length === 0) return
|
||||||
|
currentCueIndex = (d < 0)
|
||||||
|
? ((currentCueIndex <= 0) ? cueElements.length - 1 : currentCueIndex - 1)
|
||||||
|
: ((currentCueIndex >= cueElements.length - 1) ? 0 : currentCueIndex + 1)
|
||||||
|
let cue = cueElements[currentCueIndex]
|
||||||
|
if (cue.addressingMode !== ADDRESSING_INTERNAL) return
|
||||||
|
cleanupAsyncDecode()
|
||||||
|
sr.seek(cue.offset)
|
||||||
|
frameCount = 0; akku = FRAME_TIME; akku2 = 0.0; firstFrameIssued = false
|
||||||
|
baseTimecodeNs = 0; baseTimecodeFrameCount = 0; currentTimecodeNs = 0
|
||||||
|
subEngine.resetTo(0)
|
||||||
|
audio.purgeQueue(AUDIO_DEVICE)
|
||||||
|
skipped = true
|
||||||
|
},
|
||||||
|
|
||||||
|
close() {
|
||||||
|
cleanupAsyncDecode()
|
||||||
|
sys.free(RGB_BUFFER_A); sys.free(RGB_BUFFER_B); sys.free(PRESENT_RGB)
|
||||||
|
if (isInterlaced) { sys.free(CURR_FIELD); sys.free(PREV_FIELD); sys.free(NEXT_FIELD) }
|
||||||
|
while (overflowQueue.length > 0) { const ov = overflowQueue.shift(); sys.free(ov.compressedPtr) }
|
||||||
|
audioR.close()
|
||||||
|
sys.poke(-1299460, 20); sys.poke(-1299460, 21) // reset font ROM
|
||||||
|
graphics.resetPalette()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exports = { create }
|
||||||
233
assets/disk0/tvdos/include/mediadec_tev.mjs
Normal file
233
assets/disk0/tvdos/include/mediadec_tev.mjs
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
/*
|
||||||
|
* mediadec_tev.mjs — TEV (TSVM Enhanced Video) backend for the mediadec library.
|
||||||
|
*
|
||||||
|
* Ported from assets/disk0/tvdos/bin/playtev.js. DCT codec, YCoCg-R / ICtCp,
|
||||||
|
* motion compensation, optional deblock / boundary-aware decoding, interlaced
|
||||||
|
* (yadif/bwdif) support, NTSC frame duplication, MP2 audio, SSF + SSF-TC
|
||||||
|
* subtitles. Decodes into an off-screen RGB888 ping-pong buffer (the generic
|
||||||
|
* RAM frame): blit() uploads it to the adapter, while the ASCII path samples it
|
||||||
|
* straight from RAM, and `frameBuffer` exposes it for arbitrary reuse.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TEV_VERSION_YCOCG = 2
|
||||||
|
const TEV_VERSION_ICtCp = 3
|
||||||
|
|
||||||
|
const TEV_PACKET_IFRAME = 0x10
|
||||||
|
const TEV_PACKET_PFRAME = 0x11
|
||||||
|
const TEV_PACKET_AUDIO_MP2 = 0x20
|
||||||
|
const TEV_PACKET_SUBTITLE = 0x30
|
||||||
|
const TEV_PACKET_SUBTITLE_TC = 0x31
|
||||||
|
const TEV_PACKET_SYNC = 0xFF
|
||||||
|
|
||||||
|
function create(magic, sr, fileLength, opts, common) {
|
||||||
|
const audioR = common.makeAudioRouter(sr)
|
||||||
|
const subEngine = common.makeSubtitleEngine(sr, -1300607) // TEV font-ROM base
|
||||||
|
|
||||||
|
// Header
|
||||||
|
let version = sr.readOneByte()
|
||||||
|
if (version !== TEV_VERSION_YCOCG && version !== TEV_VERSION_ICtCp) {
|
||||||
|
throw Error(`Unsupported TEV version: ${version}`)
|
||||||
|
}
|
||||||
|
let width = sr.readShort()
|
||||||
|
let height = sr.readShort()
|
||||||
|
let fps = sr.readOneByte()
|
||||||
|
let totalFrames = sr.readInt()
|
||||||
|
let qualityY = sr.readOneByte()
|
||||||
|
let qualityCo = sr.readOneByte()
|
||||||
|
let qualityCg = sr.readOneByte()
|
||||||
|
let flags = sr.readOneByte()
|
||||||
|
let videoFlags = sr.readOneByte()
|
||||||
|
sr.readOneByte() // unused
|
||||||
|
const hasAudio = !!(flags & 1)
|
||||||
|
const hasSubtitle = !!(flags & 2)
|
||||||
|
const isInterlaced = !!(videoFlags & 1)
|
||||||
|
const isNTSC = !!(videoFlags & 2)
|
||||||
|
const colorSpace = (version === TEV_VERSION_ICtCp) ? "ICtCp" : "YCoCg"
|
||||||
|
|
||||||
|
// Options
|
||||||
|
const debugMV = !!opts.debugMotionVectors
|
||||||
|
const enableDeblock = !!opts.enableDeblocking
|
||||||
|
const enableBoundaryAware = !!opts.enableBoundaryAwareDecoding
|
||||||
|
const deinterlaceAlgo = opts.deinterlaceAlgorithm || "yadif"
|
||||||
|
|
||||||
|
graphics.setGraphicsMode(4)
|
||||||
|
graphics.clearPixels(0)
|
||||||
|
graphics.clearPixels2(0)
|
||||||
|
// NB: palette 0 is translucent black by default (used by the playgui chrome);
|
||||||
|
// we deliberately do NOT redefine it, nor reset it on close.
|
||||||
|
|
||||||
|
const FRAME_PIXELS = width * height
|
||||||
|
const FRAME_SIZE = 560 * 448 * 3
|
||||||
|
const FIELD_SIZE = 560 * 224 * 3
|
||||||
|
|
||||||
|
const RGB_BUFFER_A = sys.malloc(FRAME_SIZE)
|
||||||
|
const RGB_BUFFER_B = sys.malloc(FRAME_SIZE)
|
||||||
|
sys.memset(RGB_BUFFER_A, 0, FRAME_PIXELS * 3)
|
||||||
|
sys.memset(RGB_BUFFER_B, 0, FRAME_PIXELS * 3)
|
||||||
|
let CURRENT_RGB = RGB_BUFFER_A
|
||||||
|
let PREV_RGB = RGB_BUFFER_B
|
||||||
|
|
||||||
|
const CURR_FIELD = isInterlaced ? sys.malloc(FIELD_SIZE) : 0
|
||||||
|
const PREV_FIELD = isInterlaced ? sys.malloc(FIELD_SIZE) : 0
|
||||||
|
const NEXT_FIELD = isInterlaced ? sys.malloc(FIELD_SIZE) : 0
|
||||||
|
if (isInterlaced) {
|
||||||
|
sys.memset(CURR_FIELD, 0, FIELD_SIZE); sys.memset(PREV_FIELD, 0, FIELD_SIZE); sys.memset(NEXT_FIELD, 0, FIELD_SIZE)
|
||||||
|
}
|
||||||
|
let curField = CURR_FIELD, prevField = PREV_FIELD, nextField = NEXT_FIELD
|
||||||
|
|
||||||
|
sys.memset(common.DISP_RG, 0, FRAME_PIXELS)
|
||||||
|
sys.memset(common.DISP_BA, 15, FRAME_PIXELS)
|
||||||
|
|
||||||
|
const FRAME_TIME = 1.0 / fps
|
||||||
|
const FRAME_TIME_NS = 1000000000.0 / fps
|
||||||
|
const applyBias = common.makeBias(width, height, 4)
|
||||||
|
|
||||||
|
const info = {
|
||||||
|
format: 'tev', width: width, height: height, fps: fps,
|
||||||
|
totalFrames: totalFrames, hasAudio: hasAudio, hasSubtitles: hasSubtitle,
|
||||||
|
isInterlaced: isInterlaced, colourSpace: colorSpace, graphicsMode: 4, isStill: false
|
||||||
|
}
|
||||||
|
|
||||||
|
let akku = FRAME_TIME
|
||||||
|
let lastT = sys.nanoTime()
|
||||||
|
let frameCount = 0
|
||||||
|
let trueFrameCount = 0
|
||||||
|
let frameDuped = false
|
||||||
|
let paused = false
|
||||||
|
let currentFrameType = "I"
|
||||||
|
let videoRate = 0
|
||||||
|
let currentFrameSrc = CURRENT_RGB
|
||||||
|
|
||||||
|
const blockDataPtr = sys.malloc(FRAME_SIZE)
|
||||||
|
|
||||||
|
function rotateFields() { let t = prevField; prevField = curField; curField = nextField; nextField = t }
|
||||||
|
|
||||||
|
function decodeVideo(packetType) {
|
||||||
|
let payloadLen = sr.readInt()
|
||||||
|
videoRate = payloadLen
|
||||||
|
let compressedPtr = sr.readBytes(payloadLen)
|
||||||
|
currentFrameType = (packetType == TEV_PACKET_IFRAME) ? "I" : "P"
|
||||||
|
|
||||||
|
// NTSC frame duplication: drop one decode every 1000 frames (≈29.97).
|
||||||
|
if (isNTSC && frameCount % 1000 == 501 && !frameDuped) {
|
||||||
|
frameDuped = true
|
||||||
|
sys.free(compressedPtr)
|
||||||
|
return false // keep previous frame on screen
|
||||||
|
}
|
||||||
|
frameDuped = false
|
||||||
|
|
||||||
|
let actualSize
|
||||||
|
try { actualSize = gzip.decompFromTo(compressedPtr, payloadLen, blockDataPtr) }
|
||||||
|
catch (e) { sys.free(compressedPtr); serial.println(`TEV frame ${frameCount}: gzip failed: ${e}`); return false }
|
||||||
|
|
||||||
|
let decodingHeight = isInterlaced ? (height / 2) | 0 : height
|
||||||
|
if (isInterlaced) {
|
||||||
|
graphics.tevDecode(blockDataPtr, nextField, curField, width, decodingHeight, qualityY, qualityCo, qualityCg, trueFrameCount, debugMV, version, enableDeblock, enableBoundaryAware)
|
||||||
|
graphics.tevDeinterlace(trueFrameCount, width, decodingHeight, prevField, curField, nextField, CURRENT_RGB, deinterlaceAlgo)
|
||||||
|
rotateFields()
|
||||||
|
} else {
|
||||||
|
graphics.tevDecode(blockDataPtr, CURRENT_RGB, PREV_RGB, width, decodingHeight, qualityY, qualityCo, qualityCg, trueFrameCount, debugMV, version, enableDeblock, enableBoundaryAware)
|
||||||
|
}
|
||||||
|
currentFrameSrc = CURRENT_RGB
|
||||||
|
sys.free(compressedPtr)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function step() {
|
||||||
|
const now = sys.nanoTime()
|
||||||
|
if (paused) { lastT = now; return { type: 'idle' } }
|
||||||
|
akku += (now - lastT) / 1000000000.0
|
||||||
|
lastT = now
|
||||||
|
|
||||||
|
if (sr.getReadCount() >= fileLength) return { type: 'eof' }
|
||||||
|
if (akku < FRAME_TIME) return { type: 'idle' }
|
||||||
|
|
||||||
|
let packetType = sr.readOneByte()
|
||||||
|
|
||||||
|
if (packetType == TEV_PACKET_SYNC) {
|
||||||
|
akku -= FRAME_TIME
|
||||||
|
frameCount++
|
||||||
|
trueFrameCount++
|
||||||
|
// Swap ping-pong: the just-shown frame becomes the reference.
|
||||||
|
let t = CURRENT_RGB; CURRENT_RGB = PREV_RGB; PREV_RGB = t
|
||||||
|
return { type: 'idle' }
|
||||||
|
}
|
||||||
|
else if (packetType == TEV_PACKET_IFRAME || packetType == TEV_PACKET_PFRAME) {
|
||||||
|
let shown = decodeVideo(packetType)
|
||||||
|
if (shown) {
|
||||||
|
// audio after frame 0 (progressive) / frame 1 (interlaced)
|
||||||
|
if (!isInterlaced || frameCount > 0) audioR.fire()
|
||||||
|
if (subEngine.hasEvents()) subEngine.poll(frameCount * FRAME_TIME_NS)
|
||||||
|
return { type: 'frame', frameCount: frameCount }
|
||||||
|
}
|
||||||
|
return { type: 'idle' }
|
||||||
|
}
|
||||||
|
else if (packetType == TEV_PACKET_AUDIO_MP2) {
|
||||||
|
let audioLen = sr.readInt()
|
||||||
|
audioR.mp2(audioLen)
|
||||||
|
return { type: 'idle' }
|
||||||
|
}
|
||||||
|
else if (packetType == TEV_PACKET_SUBTITLE) {
|
||||||
|
let size = sr.readInt(); subEngine.parseLegacy(size); return { type: 'idle' }
|
||||||
|
}
|
||||||
|
else if (packetType == TEV_PACKET_SUBTITLE_TC) {
|
||||||
|
let size = sr.readInt(); subEngine.parseTC(size); return { type: 'idle' }
|
||||||
|
}
|
||||||
|
else if (packetType == 0x00) {
|
||||||
|
return { type: 'idle' } // stray arg-terminator byte
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
serial.println(`TEV unknown packet type 0x${packetType.toString(16)}`)
|
||||||
|
return { type: 'eof' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Present the decoded RAM frame to the display planes (with dithering).
|
||||||
|
// bias lighting is a separate, player-driven stage (bias() below).
|
||||||
|
function blit() {
|
||||||
|
graphics.uploadRGBToFramebuffer(currentFrameSrc, width, height, frameCount, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The decoded frame already sits in currentFrameSrc (RGB888 RAM), so sampling
|
||||||
|
// reads RAM directly — ASCII mode needs no blit() / display-plane round-trip.
|
||||||
|
function sampleGray(dst, w, h) { common.sampleGrayRGB(currentFrameSrc, width, height, dst, w, h) }
|
||||||
|
function sampleColour(dst, w, h) { common.sampleColourRGB(currentFrameSrc, width, height, dst, w, h) }
|
||||||
|
|
||||||
|
return {
|
||||||
|
info: info,
|
||||||
|
subtitle: subEngine.subtitle,
|
||||||
|
get frameCount() { return frameCount },
|
||||||
|
get currentTimecodeNs() { return Math.floor(frameCount * FRAME_TIME_NS) },
|
||||||
|
get videoRate() { return videoRate * fps },
|
||||||
|
get frameMode() { return currentFrameType },
|
||||||
|
get qY() { return qualityY }, get qCo() { return qualityCo }, get qCg() { return qualityCg },
|
||||||
|
cues: [],
|
||||||
|
|
||||||
|
// Generic RAM frame: the current decoded frame as RGB888 (the live
|
||||||
|
// ping-pong buffer), valid after step() returns 'frame'. Callers may read it.
|
||||||
|
get frameBuffer() { return currentFrameSrc },
|
||||||
|
get frameWidth() { return width },
|
||||||
|
get frameHeight() { return height },
|
||||||
|
|
||||||
|
step: step,
|
||||||
|
blit: blit,
|
||||||
|
bias() { applyBias() },
|
||||||
|
sampleGray: sampleGray,
|
||||||
|
sampleColour: sampleColour,
|
||||||
|
pause(p) { paused = p; if (p) audioR.stop(); else { audioR.resume(); lastT = sys.nanoTime() } },
|
||||||
|
isPaused() { return paused },
|
||||||
|
setVolume(v) { audioR.setVolume(v) },
|
||||||
|
getVolume() { return audioR.getVolume() },
|
||||||
|
seekSeconds(_n) { /* TEV has no index; seeking unsupported */ },
|
||||||
|
cue(_d) {},
|
||||||
|
|
||||||
|
close() {
|
||||||
|
sys.free(blockDataPtr)
|
||||||
|
sys.free(RGB_BUFFER_A); sys.free(RGB_BUFFER_B)
|
||||||
|
if (isInterlaced) { sys.free(CURR_FIELD); sys.free(PREV_FIELD); sys.free(NEXT_FIELD) }
|
||||||
|
audioR.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exports = { create }
|
||||||
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
|
||||||
@@ -281,9 +281,997 @@ function printTopBar(status, moreInfo) {
|
|||||||
con.move(1, 1)
|
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
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mini-AAlib (embedded, for the wavescope) ───────────────────────────────
|
||||||
|
//
|
||||||
|
// Stripped port of `disk0/hopper/include/aa.mjs`, sized to one job: convert a
|
||||||
|
// small pixel-space brightness buffer into ASCII glyphs with three monochrome
|
||||||
|
// intensities (DIM / NORMAL / BOLD). No dither. No brightness / contrast /
|
||||||
|
// gamma / inversion. No REVERSE / SPECIAL / BOLDFONT attribute support.
|
||||||
|
// See aa.mjs for the full algorithm, credits (Jan Hubicka & the AA-group,
|
||||||
|
// 1997), and the long-form comments — those are not duplicated here.
|
||||||
|
//
|
||||||
|
// Tables (params + 65536-entry LUT + filltable) are built once on first use
|
||||||
|
// from the TSVM 7×14 font ROM, so the wavescope's glyph-selection matches the
|
||||||
|
// brightness profile of the cells the hardware text mode actually paints.
|
||||||
|
|
||||||
|
const AA_FONT_PATH = "A:/tvdos/tsvm.chr"
|
||||||
|
const AA_NORMAL = 0
|
||||||
|
const AA_DIM = 1
|
||||||
|
const AA_BOLD = 2
|
||||||
|
const AA_NATTRS = 3
|
||||||
|
const AA_NCHARS = 256 * AA_NATTRS
|
||||||
|
const AA_DIMMUL = 5.3
|
||||||
|
const AA_BOLDMUL = 2.7
|
||||||
|
const AA_MUL = 8
|
||||||
|
const AA_VAL = 13 // uniform-cell threshold
|
||||||
|
const AA_PRIORITY = [4, 5, 3] // NORMAL, DIM, BOLD (matches aalib)
|
||||||
|
|
||||||
|
let aa_font = null // { width, height, data }
|
||||||
|
let aa_params = null // Uint16Array((NCHARS+1)*5)
|
||||||
|
let aa_table = null // Uint16Array(65536)
|
||||||
|
let aa_filltable = null // Uint16Array(256)
|
||||||
|
|
||||||
|
function aa_loadFont() {
|
||||||
|
if (aa_font) return aa_font
|
||||||
|
const fh = files.open(AA_FONT_PATH)
|
||||||
|
if (!fh.exists) throw Error("playgui: font ROM not found: " + AA_FONT_PATH)
|
||||||
|
const blob = fh.bread()
|
||||||
|
const FW = 7, FH = 14, ROM = 1920
|
||||||
|
if (blob.length !== ROM && blob.length !== ROM * 2) {
|
||||||
|
throw Error("playgui: bad font ROM size " + blob.length)
|
||||||
|
}
|
||||||
|
const data = new Uint8Array(256 * FW * FH)
|
||||||
|
const halves = blob.length / ROM
|
||||||
|
const startHalf = (halves === 2) ? 0 : 1
|
||||||
|
for (let h = 0; h < halves; h++) {
|
||||||
|
const romStart = h * ROM
|
||||||
|
const charBase = (startHalf + h) * 128
|
||||||
|
for (let c = 0; c < 128; c++) {
|
||||||
|
const srcBase = romStart + c * FH
|
||||||
|
const dstBase = (charBase + c) * FW * FH
|
||||||
|
for (let r = 0; r < FH; r++) {
|
||||||
|
const b = blob[srcBase + r] & 0xFF
|
||||||
|
for (let x = 0; x < FW; x++) {
|
||||||
|
data[dstBase + r * FW + x] = ((b >> (6 - x)) & 1) ? 0xFF : 0x00
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
aa_font = { width: FW, height: FH, data: data }
|
||||||
|
return aa_font
|
||||||
|
}
|
||||||
|
|
||||||
|
function aa_alowed(i) {
|
||||||
|
const c = i & 0xff
|
||||||
|
const attr = (i >>> 8)
|
||||||
|
if (attr >= AA_NATTRS) return false
|
||||||
|
// printable ASCII, space, or extended (>160) — keep AA_EIGHT chars so the
|
||||||
|
// glyph palette includes the TSVM ROM's box-drawing / shade / dot range.
|
||||||
|
if (!(c >= 33 && c <= 126) && c !== 0x20 && !(c > 160)) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// (NE, NW, SE, SW) brightness for glyph `code` under `attr`. Quadrant labelling
|
||||||
|
// follows aalib's bit-numbering quirk; the LUT lookup later swaps the halves
|
||||||
|
// back to natural orientation. See aa.mjs:_glyphValues for the long-form note.
|
||||||
|
function aa_glyphValues(code, attr, out) {
|
||||||
|
const fd = aa_font.data
|
||||||
|
const fw = aa_font.width
|
||||||
|
const fh = aa_font.height
|
||||||
|
const base = code * fw * fh
|
||||||
|
const halfW = fw >> 1
|
||||||
|
const halfH = fh >> 1
|
||||||
|
const leftW = halfW
|
||||||
|
const topH = halfH
|
||||||
|
let v1 = 0, v2 = 0, v3 = 0, v4 = 0
|
||||||
|
for (let r = 0; r < topH; r++) {
|
||||||
|
const rowBase = base + r * fw
|
||||||
|
for (let x = 0; x < leftW; x++) if (fd[rowBase + x]) v2++
|
||||||
|
for (let x = leftW; x < fw; x++) if (fd[rowBase + x]) v1++
|
||||||
|
}
|
||||||
|
for (let r = topH; r < fh; r++) {
|
||||||
|
const rowBase = base + r * fw
|
||||||
|
for (let x = 0; x < leftW; x++) if (fd[rowBase + x]) v4++
|
||||||
|
for (let x = leftW; x < fw; x++) if (fd[rowBase + x]) v3++
|
||||||
|
}
|
||||||
|
v1 *= AA_MUL; v2 *= AA_MUL; v3 *= AA_MUL; v4 *= AA_MUL
|
||||||
|
if (attr === AA_DIM) {
|
||||||
|
v1 = (v1 + 1) / AA_DIMMUL
|
||||||
|
v2 = (v2 + 1) / AA_DIMMUL
|
||||||
|
v3 = (v3 + 1) / AA_DIMMUL
|
||||||
|
v4 = (v4 + 1) / AA_DIMMUL
|
||||||
|
} else if (attr === AA_BOLD) {
|
||||||
|
v1 *= AA_BOLDMUL
|
||||||
|
v2 *= AA_BOLDMUL
|
||||||
|
v3 *= AA_BOLDMUL
|
||||||
|
v4 *= AA_BOLDMUL
|
||||||
|
}
|
||||||
|
out[0] = v1; out[1] = v2; out[2] = v3; out[3] = v4
|
||||||
|
}
|
||||||
|
|
||||||
|
function aa_calcparams() {
|
||||||
|
aa_loadFont()
|
||||||
|
aa_params = new Uint16Array((AA_NCHARS + 1) * 5)
|
||||||
|
const tmp = new Float64Array(4)
|
||||||
|
let ma1 = 0, ma2 = 0, ma3 = 0, ma4 = 0, msum = 0
|
||||||
|
let mi1 = 50000, mi2 = 50000, mi3 = 50000, mi4 = 50000, misum = 50000
|
||||||
|
for (let i = 0; i < AA_NCHARS; i++) {
|
||||||
|
if (!aa_alowed(i)) continue
|
||||||
|
aa_glyphValues(i & 0xff, i >>> 8, tmp)
|
||||||
|
const v1 = tmp[0], v2 = tmp[1], v3 = tmp[2], v4 = tmp[3]
|
||||||
|
if (v1 > ma1) ma1 = v1
|
||||||
|
if (v2 > ma2) ma2 = v2
|
||||||
|
if (v3 > ma3) ma3 = v3
|
||||||
|
if (v4 > ma4) ma4 = v4
|
||||||
|
const s = v1 + v2 + v3 + v4
|
||||||
|
if (s > msum) msum = s
|
||||||
|
if (v1 < mi1) mi1 = v1
|
||||||
|
if (v2 < mi2) mi2 = v2
|
||||||
|
if (v3 < mi3) mi3 = v3
|
||||||
|
if (v4 < mi4) mi4 = v4
|
||||||
|
if (s < misum) misum = s
|
||||||
|
}
|
||||||
|
msum -= misum
|
||||||
|
mi1 = misum / 4; mi2 = misum / 4; mi3 = misum / 4; mi4 = misum / 4
|
||||||
|
ma1 = msum / 4; ma2 = msum / 4; ma3 = msum / 4; ma4 = msum / 4
|
||||||
|
for (let i = 0; i < AA_NCHARS; i++) {
|
||||||
|
aa_glyphValues(i & 0xff, i >>> 8, tmp)
|
||||||
|
const v1r = tmp[0], v2r = tmp[1], v3r = tmp[2], v4r = tmp[3]
|
||||||
|
const sr = v1r + v2r + v3r + v4r
|
||||||
|
let sum = Math.floor((sr - misum) * (1020 / msum) + 0.5)
|
||||||
|
let v1 = Math.floor((v1r - mi1) * (255 / ma1) + 0.5)
|
||||||
|
let v2 = Math.floor((v2r - mi2) * (255 / ma2) + 0.5)
|
||||||
|
let v3 = Math.floor((v3r - mi3) * (255 / ma3) + 0.5)
|
||||||
|
let v4 = Math.floor((v4r - mi4) * (255 / ma4) + 0.5)
|
||||||
|
if (v1 > 255) v1 = 255; else if (v1 < 0) v1 = 0
|
||||||
|
if (v2 > 255) v2 = 255; else if (v2 < 0) v2 = 0
|
||||||
|
if (v3 > 255) v3 = 255; else if (v3 < 0) v3 = 0
|
||||||
|
if (v4 > 255) v4 = 255; else if (v4 < 0) v4 = 0
|
||||||
|
if (sum > 1020) sum = 1020; else if (sum < 0) sum = 0
|
||||||
|
aa_params[i * 5 + 0] = v1
|
||||||
|
aa_params[i * 5 + 1] = v2
|
||||||
|
aa_params[i * 5 + 2] = v3
|
||||||
|
aa_params[i * 5 + 3] = v4
|
||||||
|
aa_params[i * 5 + 4] = sum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function aa_pow2(x) { return x * x }
|
||||||
|
function aa_pos(i1, i2, i3, i4) { return (i1 << 12) + (i2 << 8) + (i3 << 4) + i4 }
|
||||||
|
function aa_dist(i1, i2, i3, i4, i5, y1, y2, y3, y4, y5) {
|
||||||
|
return 2 * (aa_pow2(i1 - y1) + aa_pow2(i2 - y2) + aa_pow2(i3 - y3) + aa_pow2(i4 - y4))
|
||||||
|
+ aa_pow2(i5 - y5)
|
||||||
|
}
|
||||||
|
function aa_dist1(i1, i2, i3, i4, i5, y1, y2, y3, y4, y5) {
|
||||||
|
return aa_pow2(i1 - y1) + aa_pow2(i2 - y2) + aa_pow2(i3 - y3) + aa_pow2(i4 - y4)
|
||||||
|
+ 2 * aa_pow2(i5 - y5)
|
||||||
|
}
|
||||||
|
|
||||||
|
function aa_mktable() {
|
||||||
|
if (!aa_params) aa_calcparams()
|
||||||
|
aa_table = new Uint16Array(65536)
|
||||||
|
aa_filltable = new Uint16Array(256)
|
||||||
|
const next = new Int32Array(65536)
|
||||||
|
for (let i = 0; i < 65536; i++) next[i] = i
|
||||||
|
let first = -1, last = -1
|
||||||
|
function add(i) {
|
||||||
|
if (next[i] === i && last !== i) {
|
||||||
|
if (last !== -1) { next[last] = i; last = i }
|
||||||
|
else { last = first = i }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = 0; i < AA_NCHARS; i++) {
|
||||||
|
if (!aa_alowed(i)) continue
|
||||||
|
const i1 = aa_params[i * 5 + 0]
|
||||||
|
const i2 = aa_params[i * 5 + 1]
|
||||||
|
const i3 = aa_params[i * 5 + 2]
|
||||||
|
const i4 = aa_params[i * 5 + 3]
|
||||||
|
const i5 = aa_params[i * 5 + 4]
|
||||||
|
const p1 = i1 >> 4, p2 = i2 >> 4, p3 = i3 >> 4, p4 = i4 >> 4
|
||||||
|
const p = aa_pos(p1, p2, p3, p4)
|
||||||
|
if (aa_table[p]) {
|
||||||
|
const ex = aa_table[p]
|
||||||
|
const ex1 = aa_params[ex * 5 + 0]
|
||||||
|
const ex2 = aa_params[ex * 5 + 1]
|
||||||
|
const ex3 = aa_params[ex * 5 + 2]
|
||||||
|
const ex4 = aa_params[ex * 5 + 3]
|
||||||
|
const ex5 = aa_params[ex * 5 + 4]
|
||||||
|
const pp1 = (p1 << 4) | p1
|
||||||
|
const pp2 = (p2 << 4) | p2
|
||||||
|
const pp3 = (p3 << 4) | p3
|
||||||
|
const pp4 = (p4 << 4) | p4
|
||||||
|
const ppsum = pp1 + pp2 + pp3 + pp4
|
||||||
|
const dNew = aa_dist(i1, i2, i3, i4, i5, pp1, pp2, pp3, pp4, ppsum)
|
||||||
|
const dOld = aa_dist(ex1, ex2, ex3, ex4, ex5, pp1, pp2, pp3, pp4, ppsum)
|
||||||
|
if (dNew > dOld) continue
|
||||||
|
if (dNew === dOld && AA_PRIORITY[(i >>> 8)] <= AA_PRIORITY[(ex >>> 8)]) continue
|
||||||
|
}
|
||||||
|
aa_table[p] = i
|
||||||
|
add(p)
|
||||||
|
}
|
||||||
|
for (let q = 0; q < 256; q++) {
|
||||||
|
let mindist = Infinity
|
||||||
|
let best = 0
|
||||||
|
for (let i = 0; i < AA_NCHARS; i++) {
|
||||||
|
if (!aa_alowed(i)) continue
|
||||||
|
const d1 = aa_dist1(aa_params[i * 5 + 0], aa_params[i * 5 + 1],
|
||||||
|
aa_params[i * 5 + 2], aa_params[i * 5 + 3],
|
||||||
|
aa_params[i * 5 + 4],
|
||||||
|
q, q, q, q, q * 4)
|
||||||
|
if (d1 < mindist ||
|
||||||
|
(d1 === mindist && AA_PRIORITY[(i >>> 8)] > AA_PRIORITY[(best >>> 8)])) {
|
||||||
|
aa_filltable[q] = i
|
||||||
|
mindist = d1
|
||||||
|
best = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// BFS propagation: claim neighbour slots that we cover better than whoever
|
||||||
|
// got there first. Lifted verbatim from aamktabl.c via aa.mjs.
|
||||||
|
while (true) {
|
||||||
|
if (last !== -1) next[last] = last
|
||||||
|
else break
|
||||||
|
const blocked = last
|
||||||
|
let i = first
|
||||||
|
if (i === -1) break
|
||||||
|
first = -1; last = -1
|
||||||
|
let prev
|
||||||
|
do {
|
||||||
|
const m0 = (i >> 12) & 15
|
||||||
|
const m1 = (i >> 8) & 15
|
||||||
|
const m2 = (i >> 4) & 15
|
||||||
|
const m3 = i & 15
|
||||||
|
const c = aa_table[i]
|
||||||
|
const cp0 = aa_params[c * 5 + 0]
|
||||||
|
const cp1 = aa_params[c * 5 + 1]
|
||||||
|
const cp2 = aa_params[c * 5 + 2]
|
||||||
|
const cp3 = aa_params[c * 5 + 3]
|
||||||
|
const cp4 = aa_params[c * 5 + 4]
|
||||||
|
for (let dm = 0; dm < 4; dm++) {
|
||||||
|
for (let sgn = -1; sgn <= 1; sgn += 2) {
|
||||||
|
let n0 = m0, n1 = m1, n2 = m2, n3 = m3
|
||||||
|
if (dm === 0) { n0 += sgn; if (n0 < 0 || n0 >= 16) continue }
|
||||||
|
else if (dm === 1) { n1 += sgn; if (n1 < 0 || n1 >= 16) continue }
|
||||||
|
else if (dm === 2) { n2 += sgn; if (n2 < 0 || n2 >= 16) continue }
|
||||||
|
else { n3 += sgn; if (n3 < 0 || n3 >= 16) continue }
|
||||||
|
const index = aa_pos(n0, n1, n2, n3)
|
||||||
|
const ch = aa_table[index]
|
||||||
|
if (ch === c || index === blocked) continue
|
||||||
|
let replace = !ch
|
||||||
|
if (!replace) {
|
||||||
|
const ii1 = (n0 << 4) | n0
|
||||||
|
const ii2 = (n1 << 4) | n1
|
||||||
|
const ii3 = (n2 << 4) | n2
|
||||||
|
const ii4 = (n3 << 4) | n3
|
||||||
|
const iisum = ii1 + ii2 + ii3 + ii4
|
||||||
|
const dNew = aa_dist(ii1, ii2, ii3, ii4, iisum,
|
||||||
|
cp0, cp1, cp2, cp3, cp4)
|
||||||
|
const dOld = aa_dist(ii1, ii2, ii3, ii4, iisum,
|
||||||
|
aa_params[ch * 5 + 0],
|
||||||
|
aa_params[ch * 5 + 1],
|
||||||
|
aa_params[ch * 5 + 2],
|
||||||
|
aa_params[ch * 5 + 3],
|
||||||
|
aa_params[ch * 5 + 4])
|
||||||
|
if (dNew < dOld) replace = true
|
||||||
|
}
|
||||||
|
if (replace) { aa_table[index] = c; add(index) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prev = i
|
||||||
|
i = next[i]
|
||||||
|
next[prev] = prev
|
||||||
|
} while (i !== prev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render an imgW × imgH brightness buffer (imgW = scrW*2, imgH = scrH*2) into
|
||||||
|
// per-cell (glyph, attr) outputs. No dither, no params.
|
||||||
|
function aa_render(img, scrW, scrH, tbOut, attrOut) {
|
||||||
|
if (!aa_table) aa_mktable()
|
||||||
|
const tbl = aa_table
|
||||||
|
const fill = aa_filltable
|
||||||
|
const wi = scrW * 2
|
||||||
|
for (let y = 0; y < scrH; y++) {
|
||||||
|
let pos = 2 * y * wi
|
||||||
|
let pos1 = y * scrW
|
||||||
|
for (let x = 0; x < scrW; x++) {
|
||||||
|
const i1 = img[pos + 1] // NE
|
||||||
|
const i2 = img[pos] // NW
|
||||||
|
const i3 = img[pos + wi + 1] // SE
|
||||||
|
const i4 = img[pos + wi] // SW
|
||||||
|
const s = i1 + i2 + i3 + i4
|
||||||
|
const avg = s >> 2
|
||||||
|
let val
|
||||||
|
if (Math.abs(i1 - avg) < AA_VAL &&
|
||||||
|
Math.abs(i2 - avg) < AA_VAL &&
|
||||||
|
Math.abs(i3 - avg) < AA_VAL &&
|
||||||
|
Math.abs(i4 - avg) < AA_VAL) {
|
||||||
|
val = fill[avg]
|
||||||
|
} else {
|
||||||
|
val = tbl[((i2 >> 4) << 12) | ((i1 >> 4) << 8) |
|
||||||
|
((i4 >> 4) << 4) | (i3 >> 4)]
|
||||||
|
}
|
||||||
|
attrOut[pos1] = val >> 8
|
||||||
|
tbOut[pos1] = val & 0xff
|
||||||
|
pos += 2
|
||||||
|
pos1 += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wavescope (rows 3..5) ──────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Peak-detected envelope plotted into a 156×6 pixel buffer (2× cell res),
|
||||||
|
// then converted to ASCII glyphs by the mini-AAlib above. Mid-signal only —
|
||||||
|
// stereo info lives on the bottom bar.
|
||||||
|
//
|
||||||
|
// Three monochrome intensities pick out the wave's body / peaks: DIM cells
|
||||||
|
// are the dim trace, NORMAL cells are the bulk of the waveform, BOLD cells
|
||||||
|
// land on the brightest patches (full-blocked peaks). Amber → white ramp
|
||||||
|
// mimics phosphor bloom.
|
||||||
|
|
||||||
|
const AA_WAVE_W = AG_LANE_W // 78 cells
|
||||||
|
const AA_WAVE_H = AG_ROW_WAVE_BOT - AG_ROW_WAVE_TOP + 1 // 3 cells
|
||||||
|
const AA_WAVE_IW = AA_WAVE_W * 2 // 156 px
|
||||||
|
const AA_WAVE_IH = AA_WAVE_H * 2 // 6 px
|
||||||
|
|
||||||
|
const ag_waveImg = new Uint8Array(AA_WAVE_IW * AA_WAVE_IH)
|
||||||
|
const ag_waveTb = new Uint8Array(AA_WAVE_W * AA_WAVE_H)
|
||||||
|
const ag_waveAttr = new Uint8Array(AA_WAVE_W * AA_WAVE_H)
|
||||||
|
|
||||||
|
// AA_NORMAL=0, AA_DIM=1, AA_BOLD=2 → amber phosphor palette.
|
||||||
|
const AG_WAVE_FG = [166, 130, AG_COL_LABEL]
|
||||||
|
|
||||||
|
function ag_drawWavescope() {
|
||||||
|
const N = AG_SNAPSHOT_N
|
||||||
|
const IW = AA_WAVE_IW
|
||||||
|
const IH = AA_WAVE_IH
|
||||||
|
const img = ag_waveImg
|
||||||
|
img.fill(0)
|
||||||
|
|
||||||
|
// Per-pixel-column envelope: vertical line from max to min sample value.
|
||||||
|
const samplesPerCol = N / IW
|
||||||
|
const yScale = (IH - 1) * 0.5
|
||||||
|
for (let c = 0; c < IW; 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
|
||||||
|
}
|
||||||
|
// [-1, 1] → [0, IH-1]; +1 sits at the top, -1 at the bottom.
|
||||||
|
let yT = ((1 - mx) * yScale + 0.5) | 0
|
||||||
|
let yB = ((1 - mn) * yScale + 0.5) | 0
|
||||||
|
if (yT < 0) yT = 0; else if (yT > IH - 1) yT = IH - 1
|
||||||
|
if (yB < 0) yB = 0; else if (yB > IH - 1) yB = IH - 1
|
||||||
|
for (let y = yT; y <= yB; y++) img[y * IW + c] = 0xFF
|
||||||
|
}
|
||||||
|
|
||||||
|
aa_render(img, AA_WAVE_W, AA_WAVE_H, ag_waveTb, ag_waveAttr)
|
||||||
|
|
||||||
|
// Blit, skipping cells whose packed (attr<<8 | glyph) key is unchanged.
|
||||||
|
for (let r = 0; r < AA_WAVE_H; r++) {
|
||||||
|
for (let c = 0; c < AA_WAVE_W; c++) {
|
||||||
|
const idx = r * AA_WAVE_W + c
|
||||||
|
const att = ag_waveAttr[idx]
|
||||||
|
const ch = ag_waveTb[idx]
|
||||||
|
const key = (att << 8) | ch
|
||||||
|
if (ag_waveGlyph[idx] === key) continue
|
||||||
|
ag_waveGlyph[idx] = key
|
||||||
|
ag_color(AG_WAVE_FG[att] || AG_COL_LABEL, AG_COL_BG)
|
||||||
|
ag_mvprn(AG_ROW_WAVE_TOP + r, AG_COL_INSIDE_L + c, ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 = {
|
exports = {
|
||||||
clearSubtitleArea,
|
clearSubtitleArea,
|
||||||
displaySubtitle,
|
displaySubtitle,
|
||||||
printTopBar,
|
printTopBar,
|
||||||
printBottomBar
|
printBottomBar,
|
||||||
|
audioInit,
|
||||||
|
audioFeedPcm,
|
||||||
|
audioSetProgress,
|
||||||
|
audioRender,
|
||||||
|
audioClose,
|
||||||
|
audioIsExitRequested
|
||||||
}
|
}
|
||||||
581
assets/disk0/tvdos/include/synopsis.mjs
Normal file
581
assets/disk0/tvdos/include/synopsis.mjs
Normal file
@@ -0,0 +1,581 @@
|
|||||||
|
/*
|
||||||
|
* synopsis.mjs -- TVDOS Synopsis Format (TSF) loader, cache and completion
|
||||||
|
* resolver.
|
||||||
|
*
|
||||||
|
* A TSF document (see the "Command Synopsis Format" chapter of the manual and
|
||||||
|
* tvdos_synopsis_format_draft.md) is a JSON file describing a command's
|
||||||
|
* command-line interface: its options, positional arguments, subcommands,
|
||||||
|
* argument types, completion sources and validation constraints. This module
|
||||||
|
* turns those documents into the answers command.js needs while the user is
|
||||||
|
* typing -- chiefly "what can come next at the caret?".
|
||||||
|
*
|
||||||
|
* Where the documents live
|
||||||
|
* ------------------------
|
||||||
|
* * Apps : colocated with the executable, full filename + ".synopsis"
|
||||||
|
* e.g. \tvdos\bin\geturl.js -> \tvdos\bin\geturl.js.synopsis
|
||||||
|
* * Built-in : the shell coreutils are not files, so their synopses live
|
||||||
|
* coreutils in a dedicated directory, \tvdos\synopsis\<name>.synopsis.
|
||||||
|
* Aliases (ls -> dir, rm -> del, ...) resolve to the
|
||||||
|
* canonical command's file automatically.
|
||||||
|
*
|
||||||
|
* Caching (two layers)
|
||||||
|
* --------------------
|
||||||
|
* Parsing JSON and compiling a completion model on every TAB would be wasteful,
|
||||||
|
* so results are cached:
|
||||||
|
* 1. In memory, for the life of the shell session (command.js keeps the
|
||||||
|
* require() handle, so this object persists across keystrokes).
|
||||||
|
* 2. On disk, under \tvdos\cache\synopsis\, as a compiled-model blob. The
|
||||||
|
* TSVM file layer exposes no reliable modification time, so the cache is
|
||||||
|
* validated against the source file's *byte size* plus a CACHE_VERSION
|
||||||
|
* stamp. A source edit that preserves the byte count will not invalidate
|
||||||
|
* the disk cache -- an accepted trade-off. Every disk operation is
|
||||||
|
* best-effort: a failure never breaks completion, it just falls back to
|
||||||
|
* re-parsing.
|
||||||
|
*
|
||||||
|
* Public API
|
||||||
|
* ----------
|
||||||
|
* getCompletion(commandToken, prefixTokens, word) -> result | { ok:false }
|
||||||
|
* getModel(commandToken) -> compiled model | null
|
||||||
|
* getSummary(commandToken) -> one-line summary | null
|
||||||
|
* getUsage(commandToken) -> generated usage string | null
|
||||||
|
* resolveSynopsisPath(commandToken) -> full path | null
|
||||||
|
* registerProvider(name, fn) -> register an `internal` completion source
|
||||||
|
* clearCache() -> drop the in-memory caches
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TSF_VERSION = "1.0"
|
||||||
|
const CACHE_VERSION = 1 // bump when compile()'s output shape changes
|
||||||
|
const SYN_DIR = "\\tvdos\\synopsis" // built-in / coreutil synopses
|
||||||
|
const CACHE_PARENT = "\\tvdos\\cache"
|
||||||
|
const CACHE_DIR = "\\tvdos\\cache\\synopsis" // compiled-model disk cache
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// small local helpers (deliberately mirror command.js internals)
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
function drive() { return (typeof _G !== "undefined" && _G.shell) ? _G.shell.getCurrentDrive() : "A" }
|
||||||
|
|
||||||
|
function trimStartRevSlash(s) {
|
||||||
|
let cnt = 0
|
||||||
|
while (cnt < s.length && s[cnt] === '\\') cnt += 1
|
||||||
|
return s.substring(cnt)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidDriveLetter(l) {
|
||||||
|
if (typeof l === 'string' || l instanceof String) {
|
||||||
|
let lc = l.charCodeAt(0)
|
||||||
|
return (l == '$' || 65 <= lc && lc <= 90 || 97 <= lc && lc <= 122)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileExists(p) { try { return files.open(p).exists } catch (e) { return false } }
|
||||||
|
function fileSize(p) { try { return files.open(p).size | 0 } catch (e) { return 0 } }
|
||||||
|
function readText(p) { try { let f = files.open(p); return f.exists ? f.sread() : null } catch (e) { return null } }
|
||||||
|
|
||||||
|
let _cacheDirReady = false
|
||||||
|
function ensureCacheDir() {
|
||||||
|
if (_cacheDirReady) return
|
||||||
|
let d = drive()
|
||||||
|
let segs = [CACHE_PARENT, CACHE_DIR]
|
||||||
|
for (let i = 0; i < segs.length; i++) {
|
||||||
|
try { let f = files.open(`${d}:${segs[i]}`); if (!f.exists) f.mkDir() } catch (e) { /* best-effort */ }
|
||||||
|
}
|
||||||
|
_cacheDirReady = true
|
||||||
|
}
|
||||||
|
function writeText(p, s) {
|
||||||
|
try { ensureCacheDir(); files.open(p).swrite(s); return true } catch (e) { return false }
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// executable + synopsis-path resolution
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// Find the runnable file a bare command name would resolve to, mirroring the
|
||||||
|
// search order command.js uses (current directory, then PATH, with PATHEXT).
|
||||||
|
function findExecutable(cmd) {
|
||||||
|
let d = drive()
|
||||||
|
if (isValidDriveLetter(cmd[0]) && cmd[1] === ':') {
|
||||||
|
try { let f = files.open(cmd); return f.exists ? f.fullPath : null } catch (e) { return null }
|
||||||
|
}
|
||||||
|
let pwd = (typeof _G !== "undefined" && _G.shell) ? _G.shell.getPwd() : [""]
|
||||||
|
let searchDir = (cmd.charAt(0) === '/') ? [""] : ["/" + pwd.join("/")].concat(_TVDOS.getPath())
|
||||||
|
let pathExt = []
|
||||||
|
if (cmd.split(".")[1] === undefined) {
|
||||||
|
(_TVDOS.variables.PATHEXT || "").split(';').forEach(function (it) {
|
||||||
|
if (it.length) { pathExt.push(it); pathExt.push(it.toUpperCase()) }
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
pathExt.push("")
|
||||||
|
}
|
||||||
|
for (let i = 0; i < searchDir.length; i++) {
|
||||||
|
for (let j = 0; j < pathExt.length; j++) {
|
||||||
|
let search = searchDir[i]; if (!search.endsWith('\\')) search += '\\'
|
||||||
|
let sp = trimStartRevSlash(search + cmd + pathExt[j])
|
||||||
|
try { let f = files.open(`${d}:\\${sp}`); if (f.exists) return f.fullPath } catch (e) { /* keep looking */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a command token to the full path of its .synopsis document, or null.
|
||||||
|
function resolveSynopsisPath(token) {
|
||||||
|
if (!token) return null
|
||||||
|
let d = drive()
|
||||||
|
let lower = token.toLowerCase()
|
||||||
|
|
||||||
|
// built-in coreutil? -> \tvdos\synopsis\<name>.synopsis
|
||||||
|
// try the typed name first, then any alias that shares the same function so
|
||||||
|
// `ls` finds dir.synopsis without a duplicate file.
|
||||||
|
if (typeof _G !== "undefined" && _G.shell && _G.shell.coreutils &&
|
||||||
|
typeof _G.shell.coreutils[lower] === 'function') {
|
||||||
|
let fn = _G.shell.coreutils[lower]
|
||||||
|
let names = [lower]
|
||||||
|
Object.keys(_G.shell.coreutils).forEach(function (k) {
|
||||||
|
if (_G.shell.coreutils[k] === fn && names.indexOf(k) < 0) names.push(k)
|
||||||
|
})
|
||||||
|
for (let i = 0; i < names.length; i++) {
|
||||||
|
let p = `${d}:${SYN_DIR}\\${names[i]}.synopsis`
|
||||||
|
if (fileExists(p)) return p
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// app -> <executable>.synopsis colocated with the program
|
||||||
|
let exe = findExecutable(token)
|
||||||
|
if (!exe) return null
|
||||||
|
let p = exe + ".synopsis"
|
||||||
|
return fileExists(p) ? p : null
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// TSF compilation -- raw document -> completion model
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
function compile(doc) {
|
||||||
|
if (!doc || typeof doc !== 'object') return null
|
||||||
|
let symbols = doc.symbols || {}
|
||||||
|
|
||||||
|
// ---- options: every symbol of kind "option" is an offerable flag ----
|
||||||
|
let flags = [] // one entry per option symbol
|
||||||
|
let flagMap = {} // flag string ("-r", "--recursive", "--no-recursive") -> entry
|
||||||
|
Object.keys(symbols).forEach(function (id) {
|
||||||
|
let s = symbols[id]
|
||||||
|
if (!s || s.kind !== 'option') return
|
||||||
|
let value = s.value || null
|
||||||
|
let hasValue = !!value
|
||||||
|
let entry = {
|
||||||
|
id: id,
|
||||||
|
long: s.long || null,
|
||||||
|
short: s.short || null,
|
||||||
|
summary: s.summary || '',
|
||||||
|
negatable: !!s.negatable,
|
||||||
|
hasValue: hasValue,
|
||||||
|
valueRequired: hasValue ? (value.required !== false) : false,
|
||||||
|
value: value
|
||||||
|
}
|
||||||
|
flags.push(entry)
|
||||||
|
if (entry.long) flagMap[entry.long] = entry
|
||||||
|
if (entry.short) flagMap[entry.short] = entry
|
||||||
|
if (entry.negatable && entry.long) flagMap['--no-' + entry.long.replace(/^--/, '')] = entry
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- positionals + subcommands, in grammar order ----
|
||||||
|
let positionals = []
|
||||||
|
let subcommands = []
|
||||||
|
let seenSub = {}
|
||||||
|
function walk(node, inRepeat) {
|
||||||
|
if (!node || typeof node !== 'object') return
|
||||||
|
switch (node.type) {
|
||||||
|
case 'sequence':
|
||||||
|
case 'choice':
|
||||||
|
(node.children || []).forEach(function (c) { walk(c, inRepeat) }); break
|
||||||
|
case 'optional': walk(node.child, inRepeat); break
|
||||||
|
case 'repeat': walk(node.child, true); break
|
||||||
|
case 'oneOrMore': walk(node.child, true); break
|
||||||
|
case 'reference': {
|
||||||
|
let sym = symbols[node.symbol]
|
||||||
|
if (!sym) return
|
||||||
|
if (sym.kind === 'positional') {
|
||||||
|
positionals.push({
|
||||||
|
id: node.symbol,
|
||||||
|
name: sym.name || node.symbol,
|
||||||
|
type: sym.type || 'string',
|
||||||
|
values: sym.values || null,
|
||||||
|
completion: sym.completion || null,
|
||||||
|
summary: sym.summary || '',
|
||||||
|
repeatable: !!inRepeat
|
||||||
|
})
|
||||||
|
} else if (sym.kind === 'subcommand') {
|
||||||
|
if (!seenSub[node.symbol]) {
|
||||||
|
seenSub[node.symbol] = true
|
||||||
|
subcommands.push({ name: sym.name || node.symbol, summary: sym.summary || '', tsf: sym.tsf || null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break // option / group references add no positional ordering
|
||||||
|
}
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(doc.synopsis, false)
|
||||||
|
|
||||||
|
return {
|
||||||
|
cacheVersion: CACHE_VERSION,
|
||||||
|
tsfVersion: doc.tsfVersion || null,
|
||||||
|
name: doc.name || null,
|
||||||
|
summary: doc.summary || '',
|
||||||
|
description: doc.description || '',
|
||||||
|
symbols: symbols,
|
||||||
|
synopsisNode: doc.synopsis || null,
|
||||||
|
flags: flags,
|
||||||
|
flagMap: flagMap,
|
||||||
|
positionals: positionals,
|
||||||
|
subcommands: subcommands,
|
||||||
|
constraints: doc.constraints || []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// loading + caching
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
let _mem = {} // synopsisPath -> { srcSize, model }
|
||||||
|
let _resolveMemo = {} // "drive|pwd|token" -> synopsisPath | null
|
||||||
|
|
||||||
|
function cacheKey(p) {
|
||||||
|
// FNV-1a 32-bit hash, prefixed with a sanitised basename for readability.
|
||||||
|
let h = 2166136261
|
||||||
|
for (let i = 0; i < p.length; i++) { h ^= p.charCodeAt(i); h = (h * 16777619) >>> 0 }
|
||||||
|
let base = (p.split(/[\\/]/).pop() || 'syn').replace(/[^A-Za-z0-9._-]/g, '_')
|
||||||
|
return base + '_' + ('00000000' + h.toString(16)).slice(-8)
|
||||||
|
}
|
||||||
|
function cachePath(synPath) { return `${drive()}:${CACHE_DIR}\\${cacheKey(synPath)}.json` }
|
||||||
|
|
||||||
|
function loadModel(synPath) {
|
||||||
|
if (!synPath) return null
|
||||||
|
let srcSize = fileSize(synPath)
|
||||||
|
|
||||||
|
// 1. in-memory
|
||||||
|
let mem = _mem[synPath]
|
||||||
|
if (mem && mem.srcSize === srcSize) return mem.model
|
||||||
|
|
||||||
|
// 2. disk cache (size + version validated)
|
||||||
|
let cachedText = readText(cachePath(synPath))
|
||||||
|
if (cachedText) {
|
||||||
|
try {
|
||||||
|
let c = JSON.parse(cachedText)
|
||||||
|
if (c && c.cacheVersion === CACHE_VERSION && c.srcSize === srcSize && c.model) {
|
||||||
|
_mem[synPath] = { srcSize: srcSize, model: c.model }
|
||||||
|
return c.model
|
||||||
|
}
|
||||||
|
} catch (e) { /* corrupt cache -> re-parse */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. parse the source
|
||||||
|
let src = readText(synPath)
|
||||||
|
if (src === null) return null
|
||||||
|
let doc
|
||||||
|
try { doc = JSON.parse(src) }
|
||||||
|
catch (e) { try { serial.printerr("synopsis: bad JSON in " + synPath + ": " + e) } catch (_) {} ; return null }
|
||||||
|
let model = compile(doc)
|
||||||
|
if (!model) return null
|
||||||
|
|
||||||
|
_mem[synPath] = { srcSize: srcSize, model: model }
|
||||||
|
writeText(cachePath(synPath), JSON.stringify({ cacheVersion: CACHE_VERSION, srcSize: srcSize, model: model }))
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModel(token) {
|
||||||
|
if (!token) return null
|
||||||
|
let key = drive() + '|' + ((typeof _G !== "undefined" && _G.shell) ? _G.shell.getPwdString() : '') + '|' + token
|
||||||
|
let synPath
|
||||||
|
if (Object.prototype.hasOwnProperty.call(_resolveMemo, key)) synPath = _resolveMemo[key]
|
||||||
|
else { synPath = resolveSynopsisPath(token); _resolveMemo[key] = synPath }
|
||||||
|
return synPath ? loadModel(synPath) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCache() { _mem = {}; _resolveMemo = {}; _cacheDirReady = false }
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// internal completion providers (for `"completion": { "method": "internal" }`)
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
let _providers = {}
|
||||||
|
function registerProvider(name, fn) { _providers[name] = fn }
|
||||||
|
function safeProvider(name, word, model) {
|
||||||
|
let fn = _providers[name]
|
||||||
|
if (!fn) return []
|
||||||
|
try { return fn(word, model) || [] } catch (e) { return [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// "commands" -- runnable command names (coreutils + PATH executables).
|
||||||
|
registerProvider('commands', function (word) {
|
||||||
|
word = (word || '').toLowerCase()
|
||||||
|
let out = [], seen = {}
|
||||||
|
function add(n) { let k = n.toLowerCase(); if (seen[k]) return; seen[k] = true; out.push(n) }
|
||||||
|
if (typeof _G !== "undefined" && _G.shell && _G.shell.coreutils)
|
||||||
|
Object.keys(_G.shell.coreutils).forEach(function (k) { if (k.toLowerCase().indexOf(word) === 0) add(k) })
|
||||||
|
try {
|
||||||
|
let d = drive()
|
||||||
|
let exts = (_TVDOS.variables.PATHEXT || "").split(';')
|
||||||
|
.filter(function (e) { return e.length }).map(function (e) { return e.toLowerCase() })
|
||||||
|
_TVDOS.getPath().forEach(function (dir) {
|
||||||
|
let full = (dir === '') ? `${d}:\\` : `${d}:${dir.charAt(0) === '\\' ? dir : '\\' + dir}`
|
||||||
|
try {
|
||||||
|
let f = files.open(full); if (!f.exists || !f.isDirectory) return
|
||||||
|
;(f.list() || []).forEach(function (it) {
|
||||||
|
if (it.isDirectory) return
|
||||||
|
let nl = (it.name || '').toLowerCase()
|
||||||
|
if (!exts.some(function (e) { return nl.endsWith(e) })) return
|
||||||
|
let nm = it.name
|
||||||
|
exts.forEach(function (e) { if (nm.toLowerCase().endsWith(e)) nm = nm.substring(0, nm.length - e.length) })
|
||||||
|
if (nm.toLowerCase().indexOf(word) === 0) add(nm)
|
||||||
|
})
|
||||||
|
} catch (e) { /* skip unreadable dir */ }
|
||||||
|
})
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
// "envvars" -- environment variable names.
|
||||||
|
registerProvider('envvars', function (word) {
|
||||||
|
word = word || ''
|
||||||
|
try {
|
||||||
|
return Object.keys(_TVDOS.variables || {}).filter(function (k) {
|
||||||
|
return k.toLowerCase().indexOf(word.toLowerCase()) === 0
|
||||||
|
})
|
||||||
|
} catch (e) { return [] }
|
||||||
|
})
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// completion query
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// Turn a `values` array (bare values or { value, summary } objects) into
|
||||||
|
// completion candidates whose value matches `word` as a prefix.
|
||||||
|
function valuesToCandidates(values, word) {
|
||||||
|
if (!values) return []
|
||||||
|
word = word || ''
|
||||||
|
let out = []
|
||||||
|
values.forEach(function (v) {
|
||||||
|
let val, sum
|
||||||
|
if (v && typeof v === 'object' && ('value' in v)) { val = '' + v.value; sum = v.summary || '' }
|
||||||
|
else { val = '' + v; sum = '' }
|
||||||
|
if (val.indexOf(word) === 0) out.push({ label: val, value: val + ' ', summary: sum, isDir: false })
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Candidates implied by an argument descriptor (a positional, or an option's
|
||||||
|
// `value`). Returns { candidates, filesystem } where `filesystem` is false or
|
||||||
|
// one of 'path' | 'file' | 'directory' -- a request that the caller ALSO offer
|
||||||
|
// matching filesystem entries.
|
||||||
|
function descriptorCandidates(desc, word, model) {
|
||||||
|
word = word || ''
|
||||||
|
let none = { candidates: [], filesystem: false }
|
||||||
|
if (!desc) return none
|
||||||
|
|
||||||
|
let method = (desc.completion && desc.completion.method) || (desc.type === 'enum' ? 'enum' : null)
|
||||||
|
|
||||||
|
// explicit completion block
|
||||||
|
if (method === 'none') return none
|
||||||
|
if (method === 'enum') return { candidates: valuesToCandidates(desc.values, word), filesystem: false }
|
||||||
|
if (method === 'list') {
|
||||||
|
let items = (desc.completion && (desc.completion.items || desc.completion.values)) || desc.values || []
|
||||||
|
return { candidates: valuesToCandidates(items, word), filesystem: false }
|
||||||
|
}
|
||||||
|
if (method === 'internal') {
|
||||||
|
let prov = desc.completion && desc.completion.provider
|
||||||
|
return { candidates: valuesToCandidates(safeProvider(prov, word, model), word), filesystem: false }
|
||||||
|
}
|
||||||
|
// method 'command' (run a program for candidates) is intentionally not
|
||||||
|
// executed here -- side-effect / latency safety -- so it falls through to
|
||||||
|
// the type defaults below.
|
||||||
|
|
||||||
|
// no completion block (or unhandled method): default behaviour by type
|
||||||
|
switch (desc.type) {
|
||||||
|
case 'path': return { candidates: [], filesystem: 'path' }
|
||||||
|
case 'file': return { candidates: [], filesystem: 'file' }
|
||||||
|
case 'directory': return { candidates: [], filesystem: 'directory' }
|
||||||
|
case 'boolean': return { candidates: valuesToCandidates(['true', 'false'], word), filesystem: false }
|
||||||
|
case 'command': return { candidates: valuesToCandidates(safeProvider('commands', word, model), word), filesystem: false }
|
||||||
|
case 'enum': return { candidates: valuesToCandidates(desc.values, word), filesystem: false }
|
||||||
|
case 'user': if (_providers['users']) return { candidates: valuesToCandidates(safeProvider('users', word, model), word), filesystem: false }; break
|
||||||
|
case 'group': if (_providers['groups']) return { candidates: valuesToCandidates(safeProvider('groups', word, model), word), filesystem: false }; break
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
// string / integer / float / url / hostname / unknown: a soft `values`
|
||||||
|
// list may still help; otherwise there is nothing to offer.
|
||||||
|
if (desc.values) return { candidates: valuesToCandidates(desc.values, word), filesystem: false }
|
||||||
|
return none
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every textual form a flag may be typed as (long, short, and the --no- form).
|
||||||
|
function flagForms(entry) {
|
||||||
|
let forms = []
|
||||||
|
if (entry.long) forms.push(entry.long)
|
||||||
|
if (entry.short) forms.push(entry.short)
|
||||||
|
if (entry.negatable && entry.long) forms.push('--no-' + entry.long.replace(/^--/, ''))
|
||||||
|
return forms
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count how many positional arguments `tokens` (the args already typed before
|
||||||
|
// the caret) have consumed, skipping option flags and the values they take.
|
||||||
|
function countPositionals(tokens, model) {
|
||||||
|
let n = 0, skip = false
|
||||||
|
for (let i = 0; i < tokens.length; i++) {
|
||||||
|
let t = tokens[i]
|
||||||
|
if (skip) { skip = false; continue } // this token was an option's value
|
||||||
|
if (t.length > 0 && t.charAt(0) === '-') {
|
||||||
|
if (t.indexOf('=') >= 0) continue // inline value -- no following value token
|
||||||
|
let e = model.flagMap[t]
|
||||||
|
if (e && e.hasValue && e.valueRequired) skip = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalise(r) { return { ok: true, candidates: r.candidates, filesystem: r.filesystem } }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Main entry point used by command.js.
|
||||||
|
*
|
||||||
|
* commandToken : the command (first word on the line)
|
||||||
|
* prefixTokens : the argument tokens already typed, in order, EXCLUDING the
|
||||||
|
* word currently under the caret
|
||||||
|
* word : the partial word under the caret (may be "")
|
||||||
|
*
|
||||||
|
* Returns { ok:false } when there is no synopsis for the command (the caller
|
||||||
|
* should fall back to its own default completion). Otherwise returns
|
||||||
|
* { ok:true, candidates:[{label,value,summary,isDir}], filesystem:<flag> }
|
||||||
|
* where `filesystem` (false | 'path' | 'file' | 'directory') asks the caller to
|
||||||
|
* additionally offer matching filesystem entries.
|
||||||
|
*/
|
||||||
|
function getCompletion(commandToken, prefixTokens, word) {
|
||||||
|
let model = getModel(commandToken)
|
||||||
|
if (!model) return { ok: false }
|
||||||
|
word = word || ''
|
||||||
|
prefixTokens = prefixTokens || []
|
||||||
|
|
||||||
|
// (1) the caret is on an option flag
|
||||||
|
if (word.length > 0 && word.charAt(0) === '-') {
|
||||||
|
// inline value form: --flag=partial
|
||||||
|
if (word.indexOf('--') === 0 && word.indexOf('=') >= 0) {
|
||||||
|
let eq = word.indexOf('=')
|
||||||
|
let flagPart = word.substring(0, eq)
|
||||||
|
let valPart = word.substring(eq + 1)
|
||||||
|
let entry = model.flagMap[flagPart]
|
||||||
|
if (entry && entry.hasValue) {
|
||||||
|
let r = descriptorCandidates(entry.value, valPart, model)
|
||||||
|
r.candidates = r.candidates.map(function (c) {
|
||||||
|
return { label: c.label, value: flagPart + '=' + c.value.replace(/ $/, '') + ' ', summary: c.summary, isDir: false }
|
||||||
|
})
|
||||||
|
return { ok: true, candidates: r.candidates, filesystem: false }
|
||||||
|
}
|
||||||
|
return { ok: true, candidates: [], filesystem: false }
|
||||||
|
}
|
||||||
|
// list flags matching the prefix
|
||||||
|
let out = []
|
||||||
|
model.flags.forEach(function (e) {
|
||||||
|
flagForms(e).forEach(function (f) {
|
||||||
|
if (f.indexOf(word) === 0) out.push({ label: f, value: f + ' ', summary: e.summary, isDir: false })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return { ok: true, candidates: out, filesystem: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// (2) the caret is on the value of the immediately preceding option
|
||||||
|
let prev = prefixTokens.length > 0 ? prefixTokens[prefixTokens.length - 1] : null
|
||||||
|
if (prev && prev.charAt(0) === '-' && prev.indexOf('=') < 0) {
|
||||||
|
let entry = model.flagMap[prev]
|
||||||
|
if (entry && entry.hasValue && entry.valueRequired)
|
||||||
|
return finalise(descriptorCandidates(entry.value, word, model))
|
||||||
|
}
|
||||||
|
|
||||||
|
// (3) a positional argument (or a subcommand in the first slot)
|
||||||
|
let posIndex = countPositionals(prefixTokens, model)
|
||||||
|
if (posIndex === 0 && model.subcommands.length > 0) {
|
||||||
|
let out = model.subcommands
|
||||||
|
.filter(function (s) { return s.name.indexOf(word) === 0 })
|
||||||
|
.map(function (s) { return { label: s.name, value: s.name + ' ', summary: s.summary, isDir: false } })
|
||||||
|
return { ok: true, candidates: out, filesystem: false }
|
||||||
|
}
|
||||||
|
let desc = null
|
||||||
|
if (model.positionals.length > 0) {
|
||||||
|
if (posIndex < model.positionals.length) desc = model.positionals[posIndex]
|
||||||
|
else {
|
||||||
|
let last = model.positionals[model.positionals.length - 1]
|
||||||
|
if (last && last.repeatable) desc = last
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No descriptor for this slot -> let the caller use its default completion.
|
||||||
|
if (!desc) return { ok: false }
|
||||||
|
return finalise(descriptorCandidates(desc, word, model))
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// generated help (per the spec, usage text is derived output, not normative)
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
function grammarToText(node, symbols) {
|
||||||
|
if (!node || typeof node !== 'object') return ''
|
||||||
|
switch (node.type) {
|
||||||
|
case 'sequence':
|
||||||
|
return (node.children || []).map(function (c) { return grammarToText(c, symbols) })
|
||||||
|
.filter(function (s) { return s.length }).join(' ')
|
||||||
|
case 'choice':
|
||||||
|
return '(' + (node.children || []).map(function (c) { return grammarToText(c, symbols) }).join(' | ') + ')'
|
||||||
|
case 'optional':
|
||||||
|
return '[' + grammarToText(node.child, symbols) + ']'
|
||||||
|
case 'repeat': {
|
||||||
|
// a repeat over a group is the familiar [OPTION...] slot
|
||||||
|
let child = node.child
|
||||||
|
if (child && child.type === 'reference' && symbols[child.symbol] && symbols[child.symbol].kind === 'group')
|
||||||
|
return '[' + grammarToText(child, symbols) + '...]'
|
||||||
|
return grammarToText(child, symbols) + '...'
|
||||||
|
}
|
||||||
|
case 'oneOrMore': {
|
||||||
|
let t = grammarToText(node.child, symbols)
|
||||||
|
return t + ' [' + t + '...]'
|
||||||
|
}
|
||||||
|
case 'reference': {
|
||||||
|
let s = symbols[node.symbol]
|
||||||
|
if (!s) return node.symbol
|
||||||
|
if (s.kind === 'group') return 'OPTION'
|
||||||
|
if (s.kind === 'option') return s.long || s.short || node.symbol
|
||||||
|
if (s.kind === 'subcommand') return s.name || node.symbol
|
||||||
|
if (s.kind === 'positional') return s.name || node.symbol
|
||||||
|
return node.symbol
|
||||||
|
}
|
||||||
|
default: return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUsage(token) {
|
||||||
|
let m = getModel(token)
|
||||||
|
if (!m) return null
|
||||||
|
let body = grammarToText(m.synopsisNode, m.symbols)
|
||||||
|
return ((m.name || token) + (body ? ' ' + body : '')).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSummary(token) {
|
||||||
|
let m = getModel(token)
|
||||||
|
return m ? (m.summary || '') : null
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Module exports
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
exports = {
|
||||||
|
getCompletion,
|
||||||
|
getModel,
|
||||||
|
getSummary,
|
||||||
|
getUsage,
|
||||||
|
resolveSynopsisPath,
|
||||||
|
registerProvider,
|
||||||
|
clearCache,
|
||||||
|
TSF_VERSION,
|
||||||
|
}
|
||||||
@@ -10,7 +10,15 @@ const TAUD_MAGIC = [0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64] // \x1F TSV
|
|||||||
const TAUD_VERSION = 1
|
const TAUD_VERSION = 1
|
||||||
const TAUD_HEADER_SIZE = 32 // magic(8) + version(1) + numSongs(1) + compSize(4) + projOff(4) + sig(14)
|
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
|
const TAUD_SONG_ENTRY = 32 // see encodeSongEntry / decodeSongEntry below
|
||||||
const SAMPLEINST_SIZE = 786432 // 737280 sample + 49152 instrument (256 × 192)
|
// 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 PATTERN_SIZE = 512 // bytes per pattern (64 rows × 8 bytes)
|
||||||
const NUM_PATTERNS_MAX = 256
|
const NUM_PATTERNS_MAX = 256
|
||||||
const NUM_CUES = 1024
|
const NUM_CUES = 1024
|
||||||
@@ -75,11 +83,13 @@ function uploadTaudFile(inFile, songIndex, playhead) {
|
|||||||
pos = 8
|
pos = 8
|
||||||
|
|
||||||
// -- 3. Parse header ------------------------------------------------------
|
// -- 3. Parse header ------------------------------------------------------
|
||||||
// version(1) + numSongs(1) + compressedSize(4) + rsvd(2) + signature(16) = 24 bytes
|
// magic(8) + version(1) + numSongs(1) + compSize(4) + projOff(4) + signature(14)
|
||||||
|
// = 32 bytes (terranmon.txt §Header).
|
||||||
let version = sys.peek(filePtr + pos) & 0xFF; pos++
|
let version = sys.peek(filePtr + pos) & 0xFF; pos++
|
||||||
let numSongs = sys.peek(filePtr + pos) & 0xFF; pos++
|
let numSongs = sys.peek(filePtr + pos) & 0xFF; pos++
|
||||||
let compressedSize = _peekU32LE(filePtr, pos); pos += 4
|
let compressedSize = _peekU32LE(filePtr, pos); pos += 4
|
||||||
pos += 18 // skip reserved(2) + signature(16)
|
let projOff = _peekU32LE(filePtr, pos); pos += 4
|
||||||
|
pos += 14 // signature
|
||||||
// pos == 32 == TAUD_HEADER_SIZE
|
// pos == 32 == TAUD_HEADER_SIZE
|
||||||
|
|
||||||
if (songIndex < 0 || songIndex >= numSongs) {
|
if (songIndex < 0 || songIndex >= numSongs) {
|
||||||
@@ -88,18 +98,14 @@ function uploadTaudFile(inFile, songIndex, playhead) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -- 4. Decompress and upload sample+instrument bin -----------------------
|
// -- 4. Decompress and upload sample+instrument bin -----------------------
|
||||||
let decompPtr = sys.malloc(SAMPLEINST_SIZE)
|
// The decompressed image is 8256 kB (8 MB samples bank-major + 64 K instruments)
|
||||||
gzip.decompFromTo(filePtr + pos, compressedSize, decompPtr)
|
// 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
|
pos += compressedSize
|
||||||
|
|
||||||
// Write decompressed data to peripheral memory (backwards addressing:
|
|
||||||
// peripheral byte k lives at memBase - k).
|
|
||||||
for (let i = 0; i < SAMPLEINST_SIZE; i++) {
|
|
||||||
// TODO use sys.memcpy
|
|
||||||
sys.poke(memBase - i, sys.peek(decompPtr + i))
|
|
||||||
}
|
|
||||||
sys.free(decompPtr)
|
|
||||||
|
|
||||||
// -- 5. Parse song-table entry for the requested song --------------------
|
// -- 5. Parse song-table entry for the requested song --------------------
|
||||||
let entryOff = pos + songIndex * TAUD_SONG_ENTRY
|
let entryOff = pos + songIndex * TAUD_SONG_ENTRY
|
||||||
let songOffset = _peekU32LE(filePtr, entryOff)
|
let songOffset = _peekU32LE(filePtr, entryOff)
|
||||||
@@ -114,7 +120,7 @@ function uploadTaudFile(inFile, songIndex, playhead) {
|
|||||||
let patBinCompSize = _peekU32LE(filePtr, entryOff + 18)
|
let patBinCompSize = _peekU32LE(filePtr, entryOff + 18)
|
||||||
let cueSheetCompSize = _peekU32LE(filePtr, entryOff + 22)
|
let cueSheetCompSize = _peekU32LE(filePtr, entryOff + 22)
|
||||||
|
|
||||||
let bpm = bpmStored + 24
|
let bpm = bpmStored + 25
|
||||||
let patsToLoad = numPatsLo | (numPatsHi << 8)
|
let patsToLoad = numPatsLo | (numPatsHi << 8)
|
||||||
|
|
||||||
// -- 6. Decompress + upload patterns --------------------------------------
|
// -- 6. Decompress + upload patterns --------------------------------------
|
||||||
@@ -151,6 +157,50 @@ function uploadTaudFile(inFile, songIndex, playhead) {
|
|||||||
audio.setSongGlobalVolume(playhead, songGlobalVolume)
|
audio.setSongGlobalVolume(playhead, songGlobalVolume)
|
||||||
audio.setSongMixingVolume(playhead, songMixingVolume)
|
audio.setSongMixingVolume(playhead, songMixingVolume)
|
||||||
|
|
||||||
|
// -- 9. Project Data — walk Ixmp blocks for multi-sample instruments -----
|
||||||
|
// Terranmon spec: Project Data starts at `projOff` (zero = absent), magic is
|
||||||
|
// \x1ETaudPrJ + 8 reserved bytes, then a stream of FourCC + Uint32-length
|
||||||
|
// sections. We only consume "Ixmp" here; other sections (PNam, INam, sMet,
|
||||||
|
// etc.) are skipped so the player apps remain free to parse them.
|
||||||
|
if (projOff !== 0 && projOff + 16 <= fileSize) {
|
||||||
|
const projMagic = [0x1E,0x54,0x61,0x75,0x64,0x50,0x72,0x4A] // \x1ETaudPrJ
|
||||||
|
let prjOk = true
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
if ((sys.peek(filePtr + projOff + i) & 0xFF) !== projMagic[i]) { prjOk = false; break }
|
||||||
|
}
|
||||||
|
if (prjOk) {
|
||||||
|
const PATCH_SIZE = 31
|
||||||
|
let p = projOff + 16 // skip magic(8) + reserved(8)
|
||||||
|
while (p + 8 <= fileSize) {
|
||||||
|
const fc = String.fromCharCode(
|
||||||
|
sys.peek(filePtr + p) & 0xFF, sys.peek(filePtr + p + 1) & 0xFF,
|
||||||
|
sys.peek(filePtr + p + 2) & 0xFF, sys.peek(filePtr + p + 3) & 0xFF)
|
||||||
|
const secLen = _peekU32LE(filePtr, p + 4)
|
||||||
|
const payload = p + 8
|
||||||
|
if (payload + secLen > fileSize) break
|
||||||
|
if (fc === 'Ixmp') {
|
||||||
|
// Each entry: Uint8 instId + Uint24 patchCount + (patchCount × PATCH_SIZE) bytes.
|
||||||
|
let q = payload
|
||||||
|
const qEnd = payload + secLen
|
||||||
|
while (q + 4 <= qEnd) {
|
||||||
|
const instId = sys.peek(filePtr + q) & 0xFF; q++
|
||||||
|
const cntLo = sys.peek(filePtr + q) & 0xFF; q++
|
||||||
|
const cntMid = sys.peek(filePtr + q) & 0xFF; q++
|
||||||
|
const cntHi = sys.peek(filePtr + q) & 0xFF; q++
|
||||||
|
const patchCnt = cntLo | (cntMid << 8) | (cntHi << 16)
|
||||||
|
const blobLen = patchCnt * PATCH_SIZE
|
||||||
|
if (q + blobLen > qEnd) break
|
||||||
|
let buf = new Array(blobLen)
|
||||||
|
for (let k = 0; k < blobLen; k++) buf[k] = sys.peek(filePtr + q + k) & 0xFF
|
||||||
|
audio.uploadInstrumentPatches(instId, buf)
|
||||||
|
q += blobLen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p = payload + secLen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
fileHandle.close()
|
fileHandle.close()
|
||||||
sys.free(filePtr)
|
sys.free(filePtr)
|
||||||
@@ -173,14 +223,19 @@ function captureTrackerDataToFile(outFile) {
|
|||||||
const baseAddr = audio.getBaseAddr()
|
const baseAddr = audio.getBaseAddr()
|
||||||
|
|
||||||
// -- 1. Compress sample+instrument bin ------------------------------------
|
// -- 1. Compress sample+instrument bin ------------------------------------
|
||||||
// sys.memcpy(negative_src, positive_dst, len) copies peripheral byte k from
|
// The 8256 kB raw image (8 MB samples + 64 K instruments) cannot fit in the
|
||||||
// (memBase - k) into (sampleInstBuf + k).
|
// 8 MB user space, so we hand the entire compress step to a hardware helper
|
||||||
let sampleInstBuf = sys.malloc(SAMPLEINST_SIZE)
|
// that reads directly out of the adapter's native sample/instrument storage.
|
||||||
sys.memcpy(memBase, sampleInstBuf, SAMPLEINST_SIZE)
|
// Realistic sample data compresses well under both gzip and zstd; we cap the
|
||||||
|
// destination at "uncompressed size + 8 K" headroom which suffices for any
|
||||||
let compBuf = sys.malloc(SAMPLEINST_SIZE + 4096) // headroom for incompressible data
|
// sane musical content.
|
||||||
let compressedSize = gzip.compFromTo(sampleInstBuf, SAMPLEINST_SIZE, compBuf)
|
const COMP_BUF_CAP = 1024 * 1024 * 4 // 4 MiB cap for compressed sample+inst blob
|
||||||
sys.free(sampleInstBuf)
|
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) --
|
// -- 2. Find last non-empty pattern in bank 0 (all-zero = uninitialized) --
|
||||||
let numPatsActual = 0
|
let numPatsActual = 0
|
||||||
@@ -201,7 +256,7 @@ function captureTrackerDataToFile(outFile) {
|
|||||||
// -- 3. BPM / tick-rate / volumes from playhead 0 -------------------------
|
// -- 3. BPM / tick-rate / volumes from playhead 0 -------------------------
|
||||||
let bpm = audio.getBPM(0) || 125
|
let bpm = audio.getBPM(0) || 125
|
||||||
let tickRate = audio.getTickRate(0) || 6
|
let tickRate = audio.getTickRate(0) || 6
|
||||||
let bpmStored = (bpm - 24) & 0xFF
|
let bpmStored = (bpm - 25) & 0xFF
|
||||||
let songGlobalVolume = audio.getSongGlobalVolume(0)
|
let songGlobalVolume = audio.getSongGlobalVolume(0)
|
||||||
let songMixingVolume = audio.getSongMixingVolume(0)
|
let songMixingVolume = audio.getSongMixingVolume(0)
|
||||||
if (songGlobalVolume === undefined || songGlobalVolume === null) songGlobalVolume = 0x80
|
if (songGlobalVolume === undefined || songGlobalVolume === null) songGlobalVolume = 0x80
|
||||||
@@ -263,7 +318,7 @@ function captureTrackerDataToFile(outFile) {
|
|||||||
(songOffset >>> 24) & 0xFF,
|
(songOffset >>> 24) & 0xFF,
|
||||||
20, // numVoices
|
20, // numVoices
|
||||||
numPats & 0xFF, (numPats >>> 8) & 0xFF, // numPatterns Uint16 LE
|
numPats & 0xFF, (numPats >>> 8) & 0xFF, // numPatterns Uint16 LE
|
||||||
bpmStored, // BPM with −24 bias
|
bpmStored, // BPM with −25 bias
|
||||||
tickRate, // initial tick-rate
|
tickRate, // initial tick-rate
|
||||||
0x00,0xA0, // basenote (0xA000 -- C9)
|
0x00,0xA0, // basenote (0xA000 -- C9)
|
||||||
0x00,0xAC,0x02,0x46, // basefreq (8363 Hz)
|
0x00,0xAC,0x02,0x46, // basefreq (8363 Hz)
|
||||||
|
|||||||
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)) },
|
||||||
|
}
|
||||||
337
assets/disk0/tvdos/include/typesetter.mjs
Normal file
337
assets/disk0/tvdos/include/typesetter.mjs
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
* <b>...</b> de-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_DEEMPH = 248 // <s>...</s> unhighlight
|
||||||
|
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 ESC_DEEMPH = fgEsc(COL_DEEMPH)
|
||||||
|
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 }
|
||||||
|
if (line.slice(i, i + 3) === '<s>') { buf += ESC_DEEMPH; i += 3; continue }
|
||||||
|
if (line.slice(i, i + 4) === '</s>') { 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,
|
||||||
|
ESC_DEEMPH,
|
||||||
|
MICROTONE,
|
||||||
|
}
|
||||||
@@ -49,28 +49,22 @@ class WindowObject {
|
|||||||
let tt = ''+this.title
|
let tt = ''+this.title
|
||||||
con.move(this.y, this.x + ((this.width - 2 - tt.length) >>> 1))
|
con.move(this.y, this.x + ((this.width - 2 - tt.length) >>> 1))
|
||||||
if (this.titleBack !== undefined) print(`\x1B[48;5;${this.titleBack}m`)
|
if (this.titleBack !== undefined) print(`\x1B[48;5;${this.titleBack}m`)
|
||||||
print(`\x84${charset[6]}u`)
|
|
||||||
print(`\x1B[38;5;${colourText}m${tt}`)
|
print(`\x1B[38;5;${colourText}m${tt}`)
|
||||||
print(`\x1B[38;5;${colour}m\x84${charset[7]}u`)
|
|
||||||
if (this.titleBack !== undefined) print(`\x1B[48;5;${oldBack}m`)
|
if (this.titleBack !== undefined) print(`\x1B[48;5;${oldBack}m`)
|
||||||
}
|
}
|
||||||
if (this.titleLeft !== undefined) {
|
if (this.titleLeft !== undefined) {
|
||||||
let tt = ''+this.titleLeft
|
let tt = ''+this.titleLeft
|
||||||
con.move(this.y, this.x)
|
con.move(this.y, this.x + 1)
|
||||||
print(`\x84${charset[0]}u`)
|
|
||||||
if (this.titleBackLeft !== undefined) print(`\x1B[48;5;${this.titleBackLeft}m`)
|
if (this.titleBackLeft !== undefined) print(`\x1B[48;5;${this.titleBackLeft}m`)
|
||||||
print(`\x1B[38;5;${colourText}m`);print(tt)
|
print(`\x1B[38;5;${colourText}m`);print(tt)
|
||||||
if (this.titleBackLeft !== undefined) print(`\x1B[48;5;${oldBack}m`)
|
if (this.titleBackLeft !== undefined) print(`\x1B[48;5;${oldBack}m`)
|
||||||
print(`\x1B[38;5;${colour}m`);print(`\x84${charset[4]}u`)
|
|
||||||
}
|
}
|
||||||
if (this.titleRight !== undefined) {
|
if (this.titleRight !== undefined) {
|
||||||
let tt = ''+this.titleRight
|
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 - 1)
|
||||||
print(`\x84${charset[4]}u`)
|
|
||||||
if (this.titleBackRight !== undefined) print(`\x1B[48;5;${this.titleBackRight}m`)
|
if (this.titleBackRight !== undefined) print(`\x1B[48;5;${this.titleBackRight}m`)
|
||||||
print(`\x1B[38;5;${colourText}m${tt}`)
|
print(`\x1B[38;5;${colourText}m${tt}`)
|
||||||
if (this.titleBackRight !== undefined) print(`\x1B[48;5;${oldBack}m`)
|
if (this.titleBackRight !== undefined) print(`\x1B[48;5;${oldBack}m`)
|
||||||
print(`\x1B[38;5;${colour}m\x84${charset[1]}u`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -180,4 +174,769 @@ function scrollHorz(dx, stringSize, stringViewSize, currentCursorPos, currentScr
|
|||||||
return [currentCursorPos, currentScrollPos]
|
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).
|
||||||
|
// scrollbarChars: number[6]?, -- glyph codes for the scrollbar:
|
||||||
|
// [troughTopEmpty, troughMidEmpty,
|
||||||
|
// troughBotEmpty, troughTopFilled,
|
||||||
|
// troughMidFilled, troughBotFilled].
|
||||||
|
// Default [0xBA,0xBA,0xBA,0xDB,0xDB,0xDB]
|
||||||
|
// (CP437-safe). Callers with a custom
|
||||||
|
// charset (e.g. taut) pass their own.
|
||||||
|
// drawWell: bool?, -- draw the list background
|
||||||
|
// 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 list = opts.list || null
|
||||||
|
const drawWell = list?.drawWell ?? true
|
||||||
|
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 : (drawWell) ? 243 : bg
|
||||||
|
const listSelBg = (c.listSelBg != null) ? c.listSelBg : focusBg
|
||||||
|
|
||||||
|
// List state
|
||||||
|
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
|
||||||
|
// Scrollbar glyphs: [trough top/mid/bottom empty, then top/mid/bottom filled].
|
||||||
|
// Default is CP437-safe (0xBA track, 0xDB thumb); callers with their own
|
||||||
|
// charset (e.g. taut's 0xBA..0xBF) pass a 6-item override.
|
||||||
|
const listScrollbarChars = (list && Array.isArray(list.scrollbarChars) && list.scrollbarChars.length >= 6)
|
||||||
|
? list.scrollbarChars
|
||||||
|
: [0xBA, 0xBA, 0xBA, 0xDB, 0xDB, 0xDB]
|
||||||
|
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 = 2+Math.max(maxFieldW + 6, titleW + 4, longestMsg + 6, btnRowW + 4, listMinW, 22)
|
||||||
|
|
||||||
|
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)
|
||||||
|
if (drawWell) {
|
||||||
|
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++) {
|
||||||
|
if (drawWell) {
|
||||||
|
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)
|
||||||
|
// seg: 0 = top cap, 1 = middle, 2 = bottom cap; +3 selects the
|
||||||
|
// filled (thumb) variant over the empty (trough) one.
|
||||||
|
const seg = (r === 0) ? 0 : (r === listHeight - 1) ? 2 : 1
|
||||||
|
con.addch(listScrollbarChars[(r === indPos) ? seg + 3 : seg])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom border
|
||||||
|
if (drawWell) {
|
||||||
|
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 }
|
||||||
|
|||||||
12
assets/disk0/tvdos/synopsis/cat.synopsis
Normal file
12
assets/disk0/tvdos/synopsis/cat.synopsis
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "cat",
|
||||||
|
"summary": "Print a file, or pipe its contents onward",
|
||||||
|
"symbols": {
|
||||||
|
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "File to read; reads from the pipe when omitted" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "optional",
|
||||||
|
"child": { "type": "reference", "symbol": "file" }
|
||||||
|
}
|
||||||
|
}
|
||||||
12
assets/disk0/tvdos/synopsis/cd.synopsis
Normal file
12
assets/disk0/tvdos/synopsis/cd.synopsis
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "cd",
|
||||||
|
"summary": "Change the current working directory",
|
||||||
|
"symbols": {
|
||||||
|
"dir": { "kind": "positional", "type": "directory", "name": "DIR", "summary": "Directory to change into; prints the current directory when omitted" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "optional",
|
||||||
|
"child": { "type": "reference", "symbol": "dir" }
|
||||||
|
}
|
||||||
|
}
|
||||||
22
assets/disk0/tvdos/synopsis/chvt.synopsis
Normal file
22
assets/disk0/tvdos/synopsis/chvt.synopsis
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "chvt",
|
||||||
|
"summary": "Switch to virtual console N (1-6)",
|
||||||
|
"symbols": {
|
||||||
|
"console": {
|
||||||
|
"kind": "positional",
|
||||||
|
"type": "enum",
|
||||||
|
"name": "N",
|
||||||
|
"summary": "Target virtual console",
|
||||||
|
"values": [
|
||||||
|
{ "value": "1", "summary": "Virtual console 1" },
|
||||||
|
{ "value": "2", "summary": "Virtual console 2" },
|
||||||
|
{ "value": "3", "summary": "Virtual console 3" },
|
||||||
|
{ "value": "4", "summary": "Virtual console 4" },
|
||||||
|
{ "value": "5", "summary": "Virtual console 5" },
|
||||||
|
{ "value": "6", "summary": "Virtual console 6" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"synopsis": { "type": "reference", "symbol": "console" }
|
||||||
|
}
|
||||||
7
assets/disk0/tvdos/synopsis/cls.synopsis
Normal file
7
assets/disk0/tvdos/synopsis/cls.synopsis
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "cls",
|
||||||
|
"summary": "Clear the screen",
|
||||||
|
"symbols": {},
|
||||||
|
"synopsis": { "type": "sequence", "children": [] }
|
||||||
|
}
|
||||||
16
assets/disk0/tvdos/synopsis/cp.synopsis
Normal file
16
assets/disk0/tvdos/synopsis/cp.synopsis
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "cp",
|
||||||
|
"summary": "Copy a file",
|
||||||
|
"symbols": {
|
||||||
|
"source": { "kind": "positional", "type": "file", "name": "SOURCE", "summary": "File to copy from" },
|
||||||
|
"dest": { "kind": "positional", "type": "path", "name": "DEST", "summary": "Destination path" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "sequence",
|
||||||
|
"children": [
|
||||||
|
{ "type": "reference", "symbol": "source" },
|
||||||
|
{ "type": "reference", "symbol": "dest" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
7
assets/disk0/tvdos/synopsis/date.synopsis
Normal file
7
assets/disk0/tvdos/synopsis/date.synopsis
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "date",
|
||||||
|
"summary": "Print the system date and time",
|
||||||
|
"symbols": {},
|
||||||
|
"synopsis": { "type": "sequence", "children": [] }
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user