mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-06 13:38:30 +09:00
Compare commits
121 Commits
94e3ce55ce
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
937d3e27ed | ||
|
|
e64e335db3 | ||
|
|
0124b062d0 | ||
|
|
18881a6d16 | ||
|
|
5a4d200fdc | ||
|
|
75ddfcde0f | ||
|
|
d058f11329 | ||
|
|
60b07a325a | ||
|
|
1e482e32a8 | ||
|
|
4ff48bba1c | ||
|
|
2dcdff83c8 | ||
|
|
89d3c5d776 | ||
|
|
517d0ad9a7 | ||
|
|
9524bf36e0 | ||
|
|
8e17256224 | ||
|
|
ac409bf961 |
12
.gitignore
vendored
12
.gitignore
vendored
@@ -62,11 +62,15 @@ tsvmman.pdf
|
||||
*.ilg
|
||||
*.ind
|
||||
|
||||
assets/disk0/tvdos/bin/tautfont.png
|
||||
|
||||
video_encoder/*
|
||||
|
||||
.idea/vcs.xml
|
||||
|
||||
# in-dev stuffs
|
||||
assets/disk0/home/basic/*
|
||||
assets/disk0/movtestimg/*.jpg
|
||||
assets/disk0/*.mov
|
||||
assets/diskMediabin/*
|
||||
|
||||
video_encoder/*
|
||||
|
||||
assets/disk0/tvdos/bin/tautfont.png
|
||||
assets/disk0/hopper/*
|
||||
|
||||
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>
|
||||
14
2taud.sh
14
2taud.sh
@@ -1,6 +1,12 @@
|
||||
#!/usr/bin/env fish
|
||||
|
||||
for f in *.mod; python3 mod2taud.py $f assets/disk0/(basename $f .mod).taud; end
|
||||
for f in *.s3m; python3 s3m2taud.py $f assets/disk0/(basename $f .s3m).taud; end
|
||||
for f in *.it; python3 it2taud.py $f assets/disk0/(basename $f .it).taud; end
|
||||
for f in *.xm; python3 xm2taud.py $f assets/disk0/(basename $f .xm).taud; end
|
||||
for f in *.mod; python3 mod2taud.py $f assets/disk0/home/music/(basename $f .mod).taud; end
|
||||
for f in *.MOD; python3 mod2taud.py $f assets/disk0/home/music/(basename $f .MOD).taud; end
|
||||
for f in *.s3m; python3 s3m2taud.py $f assets/disk0/home/music/(basename $f .s3m).taud; end
|
||||
for f in *.S3M; python3 s3m2taud.py $f assets/disk0/home/music/(basename $f .S3M).taud; end
|
||||
for f in *.it; python3 it2taud.py $f assets/disk0/home/music/(basename $f .it).taud; end
|
||||
for f in *.IT; python3 it2taud.py $f assets/disk0/home/music/(basename $f .IT).taud; end
|
||||
for f in *.xm; python3 xm2taud.py $f assets/disk0/home/music/(basename $f .xm).taud; end
|
||||
for f in *.XM; python3 xm2taud.py $f assets/disk0/home/music/(basename $f .XM).taud; end
|
||||
for f in *.mon; python3 mon2taud.py $f assets/disk0/home/music/(basename $f .mon).taud; end
|
||||
for f in *.MON; python3 mon2taud.py $f assets/disk0/home/music/(basename $f .MON).taud; end
|
||||
|
||||
128
CLAUDE.md
128
CLAUDE.md
@@ -37,6 +37,7 @@ Current topics:
|
||||
- `reference_materials/impulse-tracker` — The original source code for ImpulseTracker
|
||||
- `reference_materials/MilkyTracker` — FastTracker 2 compatible tracker
|
||||
- `reference_materials/schismtracker` — Open-source re-implementation of ImpulseTracker
|
||||
- `reference_materials/pt2-clone` — Open-source re-implementation of ProTracker 2
|
||||
|
||||
When fetching new references, copy the relevant upstream files verbatim into
|
||||
a topic folder, write a `README.md` summarising the relevant maths /
|
||||
@@ -115,6 +116,16 @@ Use the build scripts in `buildapp/`:
|
||||
- `My_BASIC_Programs/`: Example BASIC programs for testing
|
||||
- TVDOS filesystem uses custom format with specialised drivers
|
||||
|
||||
### TSVM JavaScript Source Encoding
|
||||
|
||||
**Do not normalise `\uXXXX` or `\xXX` escapes in .js / .mjs files that run inside
|
||||
TSVM.** TSVM's character set is not Unicode, and the JS string literal parser
|
||||
behaves differently for raw bytes vs. escape sequences. Both forms appear in
|
||||
existing code intentionally — leave each one as-is. When writing new content,
|
||||
prefer raw UTF-8 characters in string literals (e.g. write the character `ù`
|
||||
directly, rather than a `\uXXXX`-style escape) unless you are matching a
|
||||
pattern already established in the surrounding code.
|
||||
|
||||
## Videotron2K
|
||||
|
||||
The Videotron2K is a specialised video display controller with:
|
||||
@@ -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.
|
||||
|
||||
## 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 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)
|
||||
- Incorrect stereo imaging and extreme side channel 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/sbin/vtmgr.js`. 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/sbin/vtmgr.js` (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.
|
||||
|
||||
224
README.md
224
README.md
@@ -1,8 +1,222 @@
|
||||

|
||||
|
||||
**tsvm** /tiː.ɛs.viː.ɛm/ is a virtual machine with the architecture that mimics the 8-bit era of
|
||||
computers, and runs programs written in Javascript.
|
||||
# tsvm
|
||||
|
||||
**tsvm** repository includes the virtual machine itself, the reference BIOS
|
||||
implementation and a DOS; BASIC is provided by the [TerranBASIC](https://github.com/curioustorvald/TerranBASIC)
|
||||
repository.
|
||||
**tsvm** /tiː.ɛs.viː.ɛm/ is a fantasy computer platform: a virtual machine whose
|
||||
architecture is inspired by the 8-bit and early 16-bit home computers, built
|
||||
from the ground up around running JavaScript as its native machine code.
|
||||
|
||||
What started as "an 8-bit-flavoured VM that runs JS" has grown into a complete,
|
||||
self-hosted retro computing ecosystem — with its own BIOS, operating system,
|
||||
filesystem, video and audio codecs, video display coprocessor with its own
|
||||
assembly language, tracker music format, and a stack of userland tools that
|
||||
together come closer to a small alternate-history computer line than a
|
||||
single-binary emulator.
|
||||
|
||||
This repository contains the virtual machine core, the reference BIOS
|
||||
implementations, the **TVDOS** operating system, the **Videotron2K** video
|
||||
display controller, hardware-accelerated codec backends for the **TEV / TAV /
|
||||
TAD** media formats, and the multi-platform packaging scripts. The
|
||||
[TerranBASIC](https://github.com/curioustorvald/TerranBASIC) repository
|
||||
provides the matching BASIC dialect that ships on the system disk.
|
||||
|
||||
## What's actually in here
|
||||
|
||||
### The virtual machine
|
||||
|
||||
- **VM core** (`tsvm_core/`) — memory model, peripheral bus, MMIO, JS
|
||||
sandboxing through GraalVM, watchdog, DMA engine, and cooperative scheduling.
|
||||
Up to 8 hot-pluggable peripheral slots, each with a dedicated MMIO window
|
||||
and memory-space window mapped into the VM's negative address range.
|
||||
- **Multiple BIOS implementations** (`assets/bios/`) — including the reference
|
||||
`tsvmbios.js`, an OpenBIOS variant, the TBM-BIOS for TerranBASIC machines,
|
||||
and the Pip-Boy-style `pipboot.rom`. BIOSes are first-class swappable
|
||||
components, not a fixed boot blob.
|
||||
- **Reference monitor / debugger** (`mon.js`) for poking at memory and
|
||||
peripherals from a running machine.
|
||||
- **Multi-platform packaging** (`buildapp/`) — scripts to produce Linux x86_64
|
||||
/ ARM64 AppImages, macOS Intel / Apple Silicon bundles, and Windows builds,
|
||||
each with its own `jlink`-trimmed JDK 21 runtime.
|
||||
|
||||
### Peripherals (the "hardware")
|
||||
|
||||
Living under `tsvm_core/src/net/torvald/tsvm/peripheral/`:
|
||||
|
||||
- **Graphics adapters** — the standard `GraphicsAdapter`, plus `TexticsAdapter`
|
||||
for text-mode framebuffers, `ExtDisp` for external displays, and a
|
||||
`RemoteGraphicsAdapter` for networked rendering.
|
||||
- **Audio devices** — `AudioAdapter` (the main programmable sound chip with
|
||||
PCM channels, an Impulse Tracker-style resonant low-pass filter, and a
|
||||
hardware-accelerated **TAD** decoder), `OpenALBufferedAudioDevice`, and the
|
||||
`MP2Env` MPEG audio environment.
|
||||
- **Disk drives** — `TevdDiskDrive` (TEVD custom filesystem),
|
||||
`ClusteredDiskDrive`, `TestDiskDrive`, and a latency-simulator script for
|
||||
testing slow-storage behaviour.
|
||||
- **Networking and serial** — `HttpModem`, `HSDPA` / `HostFileHSDPA` for
|
||||
high-speed packet I/O, `SerialStdioHost`, `BlockTransferInterface` /
|
||||
`BlockTransferPort`.
|
||||
- **Terminals and displays** — `TTY`, `GlassTty`, `TermSim`, and a
|
||||
`CharacterLCDdisplay` for HD44780-flavoured projects.
|
||||
- **Memory expansion** — `RamBank` for bank-switched memory, plus a
|
||||
programmable `TestFunctionGenerator`.
|
||||
|
||||
### Videotron2K — the video coprocessor
|
||||
|
||||
Videotron2K is a programmable video display controller with its **own
|
||||
assembly-like language**, six general registers (`r1`–`r6`), special registers
|
||||
(`tmr`, `frm`, `px`, `py`, `c1`–`c6`), a scene-based programming model, and
|
||||
conditional postfixes (`zr`, `nz`, `gt`, `ls`, `ge`, `le`). Programs declare
|
||||
`SCENE` blocks and dispatch them with `perform`. Drawing primitives include
|
||||
`plot`, `fillin`, `fillscr`, and `goto`. See `Videotron2K.md` and the VDC
|
||||
implementation under `tsvm_core/.../vdc/`.
|
||||
|
||||
### TVDOS — the operating system
|
||||
|
||||
`assets/disk0/tvdos/` is a complete DOS-style userland:
|
||||
|
||||
- **Kernel and drivers** — `TVDOS.SYS`, `HSDPADRV.SYS`, `hyve.SYS`,
|
||||
installable drivers under `moviedev/` and `tuidev/`.
|
||||
- **Custom filesystem** — TEVD, with the on-disk format documented in
|
||||
`tvdos/filesystem.md`.
|
||||
- **Internationalisation** — Colemak / Dvorak / QWERTY keymaps and an `i18n/`
|
||||
resource tree.
|
||||
- **Userland binaries** (`tvdos/bin/`) — a shell (`command.js`), file tools
|
||||
(`hexdump`, `less`, `tee`, `touch`, `printfile`, `writeto`, `defrag`,
|
||||
`lfs`, `drives`), an editor (`edit.js`), a file manager (`zfm.js`), a
|
||||
network fetcher (`geturl`), gzip/Zstd helpers, palette tools, and a battery
|
||||
of media players (`playmp2`, `playpcm`, `playwav`, `playmv1`, `playtev`,
|
||||
`playtav`, `playtad`, `playucf`).
|
||||
- **Taut tracker** — a full in-VM tracker (`taut.js`,
|
||||
`taut_instredit.js`, `taut_sampleedit.js`, `taut_notationedit.js`,
|
||||
`taut_fileop.js`) with its own font and chrome assets.
|
||||
|
||||
### Codecs and media formats
|
||||
|
||||
tsvm ships a small but serious codec lab. Encoders are written in C and live
|
||||
in `video_encoder/`; decoders are split between JavaScript players in TVDOS
|
||||
and hardware-accelerated Kotlin backends in the VM core.
|
||||
|
||||
- **iPF (Type 1 / 2 / 1-delta)** — picture and legacy movie format. Encoders:
|
||||
`encodeipf.js`, `encodemov.js`, `encodemov2.js`. Documented in
|
||||
`terranmon.txt`.
|
||||
- **TEV (TSVM Enhanced Video)** — modern DCT codec with motion compensation,
|
||||
16×16 blocks, YCoCg-R 4:2:0, and either quality-mode or bitrate-mode rate
|
||||
control. Encoder: `video_encoder/encoder_tev.c`. Decoder: `playtev.js`,
|
||||
with `tevDecode` / `tevIdct8x8` / `tevMotionCopy8x8` accelerated in
|
||||
`GraphicsJSR223Delegate.kt`.
|
||||
- **TAV (TSVM Advanced Video)** — successor to TEV based on the Discrete
|
||||
Wavelet Transform. Five wavelet types (5/3 reversible, 9/7 irreversible,
|
||||
CDF 13/7, DD-4, Haar), 6-level decomposition, EZBC sparsity coding,
|
||||
perceptual quantisation, and an optional **3D temporal DWT** that encodes
|
||||
whole groups of pictures as one unified wavelet tree. Includes a packet
|
||||
inspector (`tav_inspector.c`) and coefficient visualiser
|
||||
(`tav_visualise_coefficients.c`).
|
||||
- **TAD (TSVM Advanced Audio)** — perceptual audio codec at 32 kHz stereo,
|
||||
using CDF 9/7 wavelets, M/S decorrelation, gamma compression, pre-emphasis,
|
||||
EZBC, and Zstd. Achieves ~2.5:1 compression vs. PCMu8 at quality 3 while
|
||||
preserving the full 0–16 kHz band. Designed to be embeddable inside TAV so
|
||||
audio chunks can align with video GOP boundaries.
|
||||
- **Taud** — tracker module format with conversion tools from
|
||||
the major formats: `it2taud.py` (Impulse Tracker), `mod2taud.py`
|
||||
(ProTracker / FastTracker), `s3m2taud.py` (Scream Tracker 3), plus
|
||||
`2taud.sh` and shared helpers in `taud_common.py`. Note effects are
|
||||
documented in `TAUD_NOTE_EFFECTS.md`. The `AudioAdapter` runs the same
|
||||
IIR-only 2-pole resonant low-pass topology used by Impulse Tracker /
|
||||
OpenMPT / Schism.
|
||||
- **MP2** — reference MPEG-1 Layer II environment via `MP2Env.kt` and
|
||||
`playmp2.js`.
|
||||
|
||||
### Languages and runtimes
|
||||
|
||||
- **JavaScript** is the VM's native code, executed by GraalVM in a sandboxed
|
||||
context with a curated set of host bindings (graphics, audio, filesystem,
|
||||
DMA, compression, networking, low-level peek/poke).
|
||||
- **TerranBASIC** is provided by the
|
||||
[TerranBASIC](https://github.com/curioustorvald/TerranBASIC) repository and
|
||||
shipped as `tbas` on the system disk. The `TerranBASICexecutable/` subproject
|
||||
packages a BASIC-only flavour of the machine.
|
||||
- **Videotron2K assembly** for VDC programs.
|
||||
|
||||
### Documentation
|
||||
|
||||
- `terranmon.txt` — the architecture reference (memory map, peripheral
|
||||
protocol, codec bitstreams).
|
||||
- `doc/*.tex` — machine-readable LaTeX sources for the TSVM and TVDOS manuals,
|
||||
built with `doc/makepdf.sh`.
|
||||
- `Videotron2K.md` — VDC programming guide.
|
||||
- `TAUD_NOTE_EFFECTS.md` — tracker effect reference.
|
||||
- `CLAUDE.md` — a condensed map of the project for collaborators (and
|
||||
language-model assistants) working in the tree.
|
||||
|
||||
## Building and running
|
||||
|
||||
### Prerequisites
|
||||
|
||||
JDK 21 runtimes laid out under `~/Documents/openjdk/` with platform-specific
|
||||
names:
|
||||
|
||||
- `jdk-21.0.1-x86` — Linux AMD64
|
||||
- `jdk-21.0.1-arm` — Linux Aarch64
|
||||
- `jdk-21.0.1-windows` — Windows AMD64
|
||||
- `jdk-21.0.1.jdk-x86` — macOS Intel
|
||||
- `jdk-21.0.1.jdk-arm` — macOS Apple Silicon
|
||||
|
||||
`jlink` is then used to produce trimmed runtimes under `out/runtime-*`.
|
||||
|
||||
### Common entry points
|
||||
|
||||
- **Run the emulator** — `TsvmEmulator.java` (in `tsvm_executable/`).
|
||||
- **Run TerranBASIC-only build** — `TerranBASIC.java` (in
|
||||
`TerranBASICexecutable/`).
|
||||
- **Package an installable bundle** — pick the right script in `buildapp/`:
|
||||
- `build_app_linux_x86.sh`
|
||||
- `build_app_linux_arm.sh`
|
||||
- `build_app_mac_x86.sh`
|
||||
- `build_app_mac_arm.sh`
|
||||
- `build_app_windows_x86.sh`
|
||||
- **Build C encoders** — in `video_encoder/`: `make` (TEV), `make tav`,
|
||||
`make tad`.
|
||||
|
||||
### Encoding sample media
|
||||
|
||||
```bash
|
||||
# Quality-mode TEV encode
|
||||
./encoder_tev -i input.mp4 -o clip.tev -q 3
|
||||
|
||||
# TAV with 9/7 wavelet, quality 4
|
||||
./encoder_tav -i input.mp4 -w 1 -q 4 -o clip.tav
|
||||
|
||||
# TAV with 3D temporal DWT (GOP-unified encoding)
|
||||
./encoder_tav -i input.mp4 --temporal-dwt -o clip.tav
|
||||
|
||||
# TAD audio at the highest quality
|
||||
./encoder_tad -i input.mp4 -o track.tad -q 5
|
||||
```
|
||||
|
||||
Then, inside TVDOS:
|
||||
|
||||
```
|
||||
A:\> playtev clip.tev
|
||||
A:\> playtav clip.tav
|
||||
A:\> playtad track.tad
|
||||
```
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
tsvm_core/ VM core, peripherals, VDC, JS bindings (Kotlin)
|
||||
tsvm_executable/ Main emulator GUI (LibGDX)
|
||||
TerranBASICexecutable/ For creatingTerranBASIC executable
|
||||
assets/bios/ BIOS ROMs and source
|
||||
assets/disk0/ Boot disk image, including all of TVDOS
|
||||
video_encoder/ C encoders, decoder libs, inspectors (TEV / TAV / TAD)
|
||||
ipf_encoder/ Reference iPF encoder
|
||||
doc/ LaTeX sources for the TSVM / TVDOS manuals
|
||||
buildapp/ Per-platform packaging scripts
|
||||
My_BASIC_Programs/ Example BASIC programs
|
||||
*.py, *.sh, *.kts Conversion tools and ad-hoc utilities
|
||||
```
|
||||
|
||||
## Licence
|
||||
|
||||
See `COPYING`.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,5 +10,7 @@
|
||||
<orderEntry type="module" module-name="tsvm_core" />
|
||||
<orderEntry type="library" name="TerranVirtualDisk" level="project" />
|
||||
<orderEntry type="library" name="lib" level="project" />
|
||||
<orderEntry type="library" name="badlogicgames.gdx" level="project" />
|
||||
<orderEntry type="library" name="badlogicgames.gdx.backend.lwjgl3" level="project" />
|
||||
</component>
|
||||
</module>
|
||||
@@ -122,8 +122,39 @@ class VMGUI(val loaderInfo: EmulInstance, val viewportWidth: Int, val viewportHe
|
||||
private var rebootRequested = false
|
||||
|
||||
private fun reboot() {
|
||||
vmRunner.close()
|
||||
coroutineJob.interrupt()
|
||||
// Order is critical: stop ALL execution first, then dispose peripherals
|
||||
// before re-initialising. Without this, the old JS thread races the new
|
||||
// one on shared VM memory / IO state and can SIGSEGV on disposed peripherals.
|
||||
|
||||
// 1. Stop parallel/child contexts. park() interrupts and joins them.
|
||||
vm.park()
|
||||
vm.poke(-90L, -128)
|
||||
|
||||
// 2. Interrupt the main runner thread and cancel the GraalVM context.
|
||||
if (::coroutineJob.isInitialized) coroutineJob.interrupt()
|
||||
try { if (::vmRunner.isInitialized) vmRunner.close() } catch (_: Throwable) {}
|
||||
|
||||
// 3. Wait for the main runner thread to actually finish.
|
||||
if (::coroutineJob.isInitialized && coroutineJob !== Thread.currentThread()) {
|
||||
try {
|
||||
coroutineJob.join(2000L)
|
||||
if (coroutineJob.isAlive) {
|
||||
System.err.println("[VMGUI] runner ${vm.id} did not exit within 2s; proceeding anyway")
|
||||
coroutineJob.interrupt()
|
||||
}
|
||||
}
|
||||
catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Now it's safe to release native resources held by peripherals.
|
||||
for (i in 1 until vm.peripheralTable.size) {
|
||||
try {
|
||||
vm.peripheralTable[i].peripheral?.dispose()
|
||||
}
|
||||
catch (_: Throwable) {}
|
||||
}
|
||||
|
||||
vm.init()
|
||||
init()
|
||||
|
||||
@@ -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 put set-xxx commands here:
|
||||
set PATH=\tvdos\installer;\tvdos\tuidev;$PATH
|
||||
set KEYBOARD=us_colemak
|
||||
|
||||
rem this line specifies which shell to be presented after the boot precess:
|
||||
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 shell runs it as the fallback once vtmgr exits (Alt-0). Environment setup
|
||||
rem (`set` commands) lives in \commandrc, which TVDOS.SYS runs before this.
|
||||
rem
|
||||
rem Korean IME registers a per-CONTEXT handler (unicode.uniprint), so it must
|
||||
rem run per-console here rather than once at boot.
|
||||
tvdos/i18n/korean
|
||||
zfm
|
||||
|
||||
rem The interactive shell for this console.
|
||||
command -fancy
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Copyright (c) 2020-2024 CuriousTorvald
|
||||
Copyright (c) 2020-2026 CuriousTorvald
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
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.
|
||||
@@ -1,24 +1,181 @@
|
||||
graphics.setBackground(2,1,3);
|
||||
graphics.resetPalette();
|
||||
graphics.setBackground(2,1,3)
|
||||
graphics.resetPalette()
|
||||
const GL = require("gl")
|
||||
const win = require("wintex")
|
||||
const keysym = require("keysym")
|
||||
|
||||
function captureUserInput() {
|
||||
sys.poke(-40, 1);
|
||||
sys.poke(-40, 1)
|
||||
}
|
||||
|
||||
function getKeyPushed(keyOrder) {
|
||||
return sys.peek(-41 - keyOrder);
|
||||
return sys.peek(-41 - keyOrder)
|
||||
}
|
||||
|
||||
let _fsh = {};
|
||||
_fsh.titlebarTex = new GL.Texture(2, 14, base64.atob("/u/+/v3+/f39/f39/f39/f39/P39/Pz8/Pv7+w=="));
|
||||
_fsh.scrdim = con.getmaxyx();
|
||||
_fsh.scrwidth = _fsh.scrdim[1];
|
||||
_fsh.scrheight = _fsh.scrdim[0];
|
||||
_fsh.brandName = "f\xb3Sh";
|
||||
function readMousePos() {
|
||||
let lx = sys.peek(-33) & 0xFF
|
||||
let hx = sys.peek(-34) & 0xFF
|
||||
let ly = sys.peek(-35) & 0xFF
|
||||
let hy = sys.peek(-36) & 0xFF
|
||||
return [(hx << 8) | lx, (hy << 8) | ly]
|
||||
}
|
||||
|
||||
function readMouseButtons() {
|
||||
return sys.peek(-37) & 0xFF
|
||||
}
|
||||
|
||||
// Returns true if any of the eight key event buffer slots holds keycode `kc`.
|
||||
function isKeyDown(kc) {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
if ((sys.peek(-41 - i) & 0xFF) === kc) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
let _fsh = {}
|
||||
|
||||
// Config file path
|
||||
_fsh.CONFIG_PATH = "A:/home/config/fshrc"
|
||||
|
||||
// Widget row caps (must match the loop bounds in draw())
|
||||
_fsh.TODO_MAX_ROWS = 13 // todoWidget draws i = 0..12
|
||||
_fsh.QA_MAX_ROWS = 22 // quickAccessWidget draws i = 0..21
|
||||
_fsh.TODO_TEXT_WIDTH = 24 // visible characters per todo row
|
||||
_fsh.QA_LABEL_WIDTH = 24 // visible characters per QA label
|
||||
_fsh.QA_CMD_WIDTH = 60 // command path field width in dialog
|
||||
|
||||
// Highlight foreground for keyboard focus on widget lists. The background
|
||||
// stays transparent (255) so the wallpaper continues to show through.
|
||||
_fsh.HL_FG = 230
|
||||
_fsh.HL_BG = 255
|
||||
|
||||
// Default Quick Access entries when fshrc is missing or empty
|
||||
_fsh.DEFAULT_QA = [
|
||||
["Files", "/tvdos/bin/zsh.js"],
|
||||
["Editor", "/tvdos/bin/edit.js"],
|
||||
["BASIC", "/tbas/basic.js"],
|
||||
["DOS Shell", "/tvdos/bin/command.js /fancy"]
|
||||
]
|
||||
|
||||
// Mouse button bits (MMIO[36] layout per IOSpace.kt)
|
||||
_fsh.MB_LEFT = 1
|
||||
_fsh.MB_RIGHT = 2
|
||||
|
||||
// Current focus: null or {widgetId: string, index: number}.
|
||||
// Index uses the same convention as hitTest: 0..length-1 are entries,
|
||||
// `length` is the "+ Click to add" row.
|
||||
_fsh.focus = null
|
||||
|
||||
// Parse fshrc text into {todos: [[text, done], ...], qa: [[label, cmd], ...]}.
|
||||
// Returns null for both arrays when input is empty/whitespace.
|
||||
_fsh.parseConfig = function(text) {
|
||||
let todos = []
|
||||
let qa = []
|
||||
let section = null
|
||||
if (!text) return {todos: todos, qa: qa}
|
||||
let lines = text.split("\n")
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let line = lines[i]
|
||||
// strip trailing \r if any
|
||||
if (line.length && line.charCodeAt(line.length - 1) === 13) {
|
||||
line = line.substring(0, line.length - 1)
|
||||
}
|
||||
if (line.length === 0) continue
|
||||
if (line.charAt(0) === "[") {
|
||||
let close = line.indexOf("]")
|
||||
if (close > 0) {
|
||||
let name = line.substring(1, close).trim().toUpperCase()
|
||||
if (name === "TODO" || name === "QUICK_ACCESS") section = name
|
||||
else section = null // unknown section: ignore until next header
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (section === "TODO") {
|
||||
if (line.length < 2) continue
|
||||
let marker = line.charAt(0)
|
||||
if ((marker === "+" || marker === "-") && line.charAt(1) === " ") {
|
||||
todos.push([line.substring(2), marker === "+"])
|
||||
}
|
||||
} else if (section === "QUICK_ACCESS") {
|
||||
let comma = line.indexOf(",")
|
||||
if (comma <= 0) continue // need a non-empty label
|
||||
let label = line.substring(0, comma)
|
||||
let cmd = line.substring(comma + 1)
|
||||
qa.push([label, cmd])
|
||||
}
|
||||
}
|
||||
return {todos: todos, qa: qa}
|
||||
}
|
||||
|
||||
// Build fshrc text from in-memory model. Inverse of parseConfig.
|
||||
_fsh.serializeConfig = function(todos, qa) {
|
||||
let out = "[TODO]\n"
|
||||
for (let i = 0; i < todos.length; i++) {
|
||||
let t = todos[i]
|
||||
out += (t[1] ? "+ " : "- ") + t[0] + "\n"
|
||||
}
|
||||
out += "\n[QUICK_ACCESS]\n"
|
||||
for (let i = 0; i < qa.length; i++) {
|
||||
out += qa[i][0] + "," + qa[i][1] + "\n"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Read fshrc; populate todoWidget.todoList and quickAccessWidget.entries.
|
||||
// Falls back to defaults on missing/empty/malformed file.
|
||||
_fsh.loadConfig = function() {
|
||||
let f = files.open(_fsh.CONFIG_PATH)
|
||||
let parsed = {todos: [], qa: []}
|
||||
if (f.exists) {
|
||||
try {
|
||||
parsed = _fsh.parseConfig(f.sread())
|
||||
} catch (e) {
|
||||
serial.printerr("fsh.loadConfig: parse failed: " + e)
|
||||
parsed = {todos: [], qa: []}
|
||||
}
|
||||
}
|
||||
todoWidget.todoList = parsed.todos
|
||||
quickAccessWidget.entries = (parsed.qa.length > 0)
|
||||
? parsed.qa
|
||||
: _fsh.DEFAULT_QA.slice() // copy so saves don't mutate the constant
|
||||
}
|
||||
|
||||
// Persist the current in-memory todos + QA entries to fshrc.
|
||||
_fsh.saveConfig = function() {
|
||||
try {
|
||||
let f = files.open(_fsh.CONFIG_PATH)
|
||||
if (!f.exists) f.mkFile()
|
||||
f.swrite(_fsh.serializeConfig(todoWidget.todoList, quickAccessWidget.entries))
|
||||
} catch (e) {
|
||||
serial.printerr("fsh.saveConfig: write failed: " + e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Map (mouse char x, mouse char y) to a row index for a widget drawn at
|
||||
// (xoff, yoff) with `length` existing entries and `maxRows` total rows.
|
||||
// Returns null / {kind:"add"} / {kind:"item", index: i}.
|
||||
_fsh.hitTestList = function(charX, charY, xoff, yoff, textWidth, length, maxRows) {
|
||||
// Each row sits at (yoff + i + 2, xoff..xoff + textWidth + 1).
|
||||
// Column range: icon at xoff, text at xoff+2 .. xoff+1+textWidth.
|
||||
// Allow clicks anywhere on the row's char cells (icon + text region).
|
||||
let relY = charY - yoff - 2
|
||||
if (relY < 0 || relY >= maxRows) return null
|
||||
if (charX < xoff || charX > xoff + 1 + textWidth) return null
|
||||
if (relY < length) return {kind: "item", index: relY}
|
||||
if (relY === length) return {kind: "add"}
|
||||
return null
|
||||
}
|
||||
|
||||
_fsh.titlebarTex = new GL.Texture(2, 14, base64.atob("/u/+/v3+/f39/f39/f39/f39/P39/Pz8/Pv7+w=="))
|
||||
_fsh.scrdim = con.getmaxyx()
|
||||
_fsh.scrwidth = _fsh.scrdim[1]
|
||||
_fsh.scrheight = _fsh.scrdim[0]
|
||||
_fsh.brandName = "f\xb3Sh"
|
||||
_fsh.brandLogoTexSmall = new GL.Texture(24, 14, gzip.decomp(base64.atob(
|
||||
"H4sIAAAAAAAAAPv/Hy/4Qbz458+fIeILQQBIwoSh6qECuMVBukCmIJkDVQ+RQNgLE0MX/w+1lyhxqIUwTLJ/sQMAcIXsbVABAAA="
|
||||
)));
|
||||
_fsh.scrlayout = ["com.fsh.clock","com.fsh.calendar","com.fsh.todo_list", "com.fsh.quick_access"];
|
||||
)))
|
||||
_fsh.scrlayout = ["com.fsh.clock","com.fsh.calendar","com.fsh.todo_list", "com.fsh.quick_access"]
|
||||
|
||||
_fsh.drawWallpaper = function() {
|
||||
let wp = files.open("A:/home/wall.bytes")
|
||||
@@ -28,85 +185,85 @@ _fsh.drawWallpaper = function() {
|
||||
wp.pread(b, 250880, 0)
|
||||
dma.ramToFrame(b, 0, 250880)
|
||||
sys.free(b)
|
||||
};
|
||||
}
|
||||
|
||||
_fsh.drawTitlebar = function(titletext) {
|
||||
GL.drawTexPattern(_fsh.titlebarTex, 0, 0, 560, 14);
|
||||
GL.drawTexPattern(_fsh.titlebarTex, 0, 0, 560, 14)
|
||||
if (titletext === undefined || titletext.length == 0) {
|
||||
con.move(1,1);
|
||||
print(" ".repeat(_fsh.scrwidth));
|
||||
GL.drawTexImageOver(_fsh.brandLogoTexSmall, 268, 0);
|
||||
con.move(1,1)
|
||||
print(" ".repeat(_fsh.scrwidth))
|
||||
GL.drawTexImageOver(_fsh.brandLogoTexSmall, 268, 0)
|
||||
}
|
||||
else {
|
||||
con.color_pair(240, 255);
|
||||
GL.drawTexPattern(_fsh.titlebarTex, 268, 0, 24, 14);
|
||||
con.move(1, 1 + (_fsh.scrwidth - titletext.length) / 2);
|
||||
print(titletext);
|
||||
con.color_pair(240, 255)
|
||||
GL.drawTexPattern(_fsh.titlebarTex, 268, 0, 24, 14)
|
||||
con.move(1, 1 + (_fsh.scrwidth - titletext.length) / 2)
|
||||
print(titletext)
|
||||
}
|
||||
con.color_pair(254, 255);
|
||||
};
|
||||
con.color_pair(254, 255)
|
||||
}
|
||||
|
||||
|
||||
_fsh.Widget = function(id, w, h) {
|
||||
this.identifier = id;
|
||||
this.width = w;
|
||||
this.height = h;
|
||||
this.identifier = id
|
||||
this.width = w
|
||||
this.height = h
|
||||
|
||||
if (!this.identifier) {
|
||||
this.identifier = "";
|
||||
this.identifier = ""
|
||||
}
|
||||
|
||||
//this.update = function() {};
|
||||
//this.update = function() {}
|
||||
/**
|
||||
* Params charXoff and charYoff are ZERO-BASED!
|
||||
*/
|
||||
this.draw = function(charXoff, charYoff) {};
|
||||
this.draw = function(charXoff, charYoff) {}
|
||||
}
|
||||
|
||||
_fsh.widgets = {}
|
||||
_fsh.registerNewWidget = function(widget) {
|
||||
_fsh.widgets[widget.identifier] = widget;
|
||||
_fsh.widgets[widget.identifier] = widget
|
||||
}
|
||||
|
||||
let clockWidget = new _fsh.Widget("com.fsh.clock", _fsh.scrwidth - 8, 7*2);
|
||||
let clockWidget = new _fsh.Widget("com.fsh.clock", _fsh.scrwidth - 8, 7*2)
|
||||
clockWidget.numberSheet = new GL.SpriteSheet(19, 22, new GL.Texture(190, 22, gzip.decomp(base64.atob(
|
||||
"H4sIAAAAAAAAAMWVW3LEMAgE739aHcFJJV5ZMD2I9ToVfcl4GBr80HF8r/FaR1ozMuIyoUu87lEXI0al5qVR5AebSwchSaNE6Nyo1Nw5HXF3SfPT4Bshl"+
|
||||
"EycA8RD96mLlHbuhTgOrfLnUDZspafbSQWk56WEGvQEtWaWwgb8iz7a8AOXhsraO/q9Qw2/GnXovfVN+q2wM/p/oddn2cjF239GX3y11+SWCtc6FTHC1v"+
|
||||
"TVPkDPWWn0w+DDz93UX9v9mF5KIsQ6OdN2KJoB4ui1bXXr0AMp0YfiQo//4XhpK8555dsNehAqVS5uhb5iHn3Kko769J59KmLBe/TSR7hcsd+hr+HnrwR"+
|
||||
"9uvRF9+D3MP14gN7lqx+8OuNT+uqt3NFX3SN9fTbeeHNq+C29pRWzX5+Rcm7SZyjOKJ/2hkSPqul4xN279DrSYvCrNu2NI7ZMp1ouBxK3KBVVnEeAUWbK"+
|
||||
"MUDn5DPsPxmUqHZQjGpy2hergM3EVBAAAA=="
|
||||
))));
|
||||
))))
|
||||
|
||||
clockWidget.clockColon = new GL.Texture(4, 3, base64.atob("7+/v7+/v7+/v7+/v"));
|
||||
clockWidget.monthNames = ["Spring", "Summer", "Autumn", "Winter"];
|
||||
clockWidget.dayNames = ["Mondag ", "Tysdag ", "Midtveke", "Torsdag ", "Fredag ", "Laurdag ", "Sundag ", "Verddag "];
|
||||
clockWidget.clockColon = new GL.Texture(4, 3, base64.atob("7+/v7+/v7+/v7+/v"))
|
||||
clockWidget.monthNames = ["Spring", "Summer", "Autumn", "Winter"]
|
||||
clockWidget.dayNames = ["Mondag ", "Tysdag ", "Midtveke", "Torsdag ", "Fredag ", "Laurdag ", "Sundag ", "Verddag "]
|
||||
clockWidget.draw = function(charXoff, charYoff) {
|
||||
con.color_pair(254, 255);
|
||||
let xoff = charXoff * 7;
|
||||
let yoff = charYoff * 14 + 3;
|
||||
let timeInMinutes = ((sys.currentTimeInMills() / 60000)|0);
|
||||
let mins = timeInMinutes % 60;
|
||||
let hours = ((timeInMinutes / 60)|0) % 24;
|
||||
let ordinalDay = ((timeInMinutes / (60*24))|0) % 120;
|
||||
let visualDay = (ordinalDay % 30) + 1;
|
||||
let months = ((timeInMinutes / (60*24*30))|0) % 4;
|
||||
let dayName = ordinalDay % 7; // 0 for Mondag
|
||||
if (ordinalDay == 119) dayName = 7; // Verddag
|
||||
let years = ((timeInMinutes / (60*24*30*120))|0) + 125;
|
||||
con.color_pair(254, 255)
|
||||
let xoff = charXoff * 7
|
||||
let yoff = charYoff * 14 + 3
|
||||
let timeInMinutes = ((sys.currentTimeInMills() / 60000)|0)
|
||||
let mins = timeInMinutes % 60
|
||||
let hours = ((timeInMinutes / 60)|0) % 24
|
||||
let ordinalDay = ((timeInMinutes / (60*24))|0) % 120
|
||||
let visualDay = (ordinalDay % 30) + 1
|
||||
let months = ((timeInMinutes / (60*24*30))|0) % 4
|
||||
let dayName = ordinalDay % 7 // 0 for Mondag
|
||||
if (ordinalDay == 119) dayName = 7 // Verddag
|
||||
let years = ((timeInMinutes / (60*24*30*120))|0) + 125
|
||||
// draw timepiece
|
||||
GL.drawSprite(clockWidget.numberSheet, (hours / 10)|0, 0, xoff, yoff, 1);
|
||||
GL.drawSprite(clockWidget.numberSheet, hours % 10, 0, xoff + 24, yoff, 1);
|
||||
GL.drawTexImage(clockWidget.clockColon, xoff + 48, yoff + 5, 1);
|
||||
GL.drawTexImage(clockWidget.clockColon, xoff + 48, yoff + 14, 1);
|
||||
GL.drawSprite(clockWidget.numberSheet, (mins / 10)|0, 0, xoff + 57, yoff, 1);
|
||||
GL.drawSprite(clockWidget.numberSheet, mins % 10, 0, xoff + 81, yoff, 1);
|
||||
GL.drawSprite(clockWidget.numberSheet, (hours / 10)|0, 0, xoff, yoff, 1)
|
||||
GL.drawSprite(clockWidget.numberSheet, hours % 10, 0, xoff + 24, yoff, 1)
|
||||
GL.drawTexImage(clockWidget.clockColon, xoff + 48, yoff + 5, 1)
|
||||
GL.drawTexImage(clockWidget.clockColon, xoff + 48, yoff + 14, 1)
|
||||
GL.drawSprite(clockWidget.numberSheet, (mins / 10)|0, 0, xoff + 57, yoff, 1)
|
||||
GL.drawSprite(clockWidget.numberSheet, mins % 10, 0, xoff + 81, yoff, 1)
|
||||
// print month and date
|
||||
con.move(1 + charYoff, 17 + charXoff);
|
||||
print(clockWidget.monthNames[months]+" "+visualDay);
|
||||
con.move(1 + charYoff, 17 + charXoff)
|
||||
print(clockWidget.monthNames[months]+" "+visualDay)
|
||||
// print year and dayname
|
||||
con.move(2 + charYoff, 17 + charXoff);
|
||||
print("\xE7"+years+" "+clockWidget.dayNames[dayName]);
|
||||
};
|
||||
con.move(2 + charYoff, 17 + charXoff)
|
||||
print("\xE7"+years+" "+clockWidget.dayNames[dayName])
|
||||
}
|
||||
|
||||
|
||||
let calendarWidget = new _fsh.Widget("com.fsh.calendar", (_fsh.scrwidth - 8) / 2, 7*6)
|
||||
@@ -171,70 +328,284 @@ calendarWidget.draw = function(charXoff, charYoff) {
|
||||
let todoWidget = new _fsh.Widget("com.fsh.todo_list", (_fsh.scrwidth - 8) / 2, 7*10)
|
||||
todoWidget.todoList = [["Hello, world!", true]]
|
||||
todoWidget.draw = function(charXoff, charYoff) {
|
||||
let focusIndex = (_fsh.focus && _fsh.focus.widgetId === todoWidget.identifier)
|
||||
? _fsh.focus.index : -1
|
||||
|
||||
con.color_pair(254, 255)
|
||||
let xoff = charXoff * 7
|
||||
let yoff = charYoff * 14 + 3
|
||||
|
||||
con.move(charYoff, charXoff)
|
||||
print("========== TODO ==========")
|
||||
print('\u00CD'.repeat(10)+" TODO "+'\u00CD'.repeat(10))
|
||||
|
||||
for (let i = 0; i <= 12; i++) {
|
||||
let list = todoWidget.todoList[i] || ["Click to add", null]
|
||||
let list = todoWidget.todoList[i] || ["Click to add"+" ".repeat(_fsh.TODO_TEXT_WIDTH - 12), null]
|
||||
let isFocused = (i === focusIndex)
|
||||
|
||||
if (list[1] === null) con.color_pair(249, 255)
|
||||
if (isFocused) con.color_pair(_fsh.HL_FG, _fsh.HL_BG)
|
||||
else if (list[1] === null) con.color_pair(249, 255)
|
||||
else con.color_pair(254, 255)
|
||||
|
||||
con.move(charYoff + i + 2, charXoff)
|
||||
con.addch((list[1] === null) ? 43 : (list[1]) ? 0x9F : 0x9E)
|
||||
|
||||
if (i > todoWidget.todoList.length) {
|
||||
// Filler row \u2014 keep underscores but don't highlight (can't focus here)
|
||||
con.color_pair(254, 255)
|
||||
for (let k = 0; k < 24; k++) {
|
||||
con.mvaddch(charYoff + i + 2, charXoff + 2 + k, 95)
|
||||
}
|
||||
}
|
||||
else {
|
||||
con.move(charYoff + i + 2, charXoff + 2)
|
||||
print(`${list[0]}`)
|
||||
// Pad text to TODO_TEXT_WIDTH so the highlight bar covers full row
|
||||
let text = `${list[0]}`
|
||||
if (text.length > _fsh.TODO_TEXT_WIDTH) text = text.substring(0, _fsh.TODO_TEXT_WIDTH)
|
||||
if (isFocused) text = text + " ".repeat(_fsh.TODO_TEXT_WIDTH - text.length)
|
||||
print(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let quickAccessWidget = new _fsh.Widget("com.fsh.quick_access", (_fsh.scrwidth - 8) / 2, 7*20)
|
||||
quickAccessWidget.entries = [
|
||||
["Files", "/tvdos/bin/explorer.js"],
|
||||
quickAccessWidget.entries = [ // TODO read from /home/config/fshrc
|
||||
["Files", "/tvdos/bin/zfm.js"],
|
||||
["Editor", "/tvdos/bin/edit.js"],
|
||||
["BASIC", "/tbas/basic.js"],
|
||||
["DOS Shell", "/tvdos/bin/command.js /fancy"]
|
||||
["DOS Shell", "/tvdos/bin/command.js -fancy"]
|
||||
]
|
||||
quickAccessWidget.draw = function(charXoff, charYoff) {
|
||||
let focusIndex = (_fsh.focus && _fsh.focus.widgetId === quickAccessWidget.identifier)
|
||||
? _fsh.focus.index : -1
|
||||
|
||||
con.color_pair(254, 255)
|
||||
let xoff = charXoff * 7
|
||||
let yoff = charYoff * 14 + 3
|
||||
|
||||
con.move(charYoff, charXoff)
|
||||
print("====== QUICK ACCESS ======")
|
||||
print('\u00CD'.repeat(6)+" QUICK ACCESS "+'\u00CD'.repeat(6))
|
||||
|
||||
for (let i = 0; i <= 21; i++) {
|
||||
let list = quickAccessWidget.entries[i] || ["Click to add", null]
|
||||
let list = quickAccessWidget.entries[i] || ["Click to add"+" ".repeat(_fsh.QA_LABEL_WIDTH - 12), null]
|
||||
let isFocused = (i === focusIndex)
|
||||
|
||||
if (list[1] === null) con.color_pair(249, 255)
|
||||
if (isFocused) con.color_pair(_fsh.HL_FG, _fsh.HL_BG)
|
||||
else if (list[1] === null) con.color_pair(249, 255)
|
||||
else con.color_pair(254, 255)
|
||||
|
||||
con.move(charYoff + i + 2, charXoff)
|
||||
con.addch((list[1] === null) ? 0xF9 : (list[1]) ? 7 : 0x7F)
|
||||
|
||||
if (i > quickAccessWidget.entries.length) {
|
||||
con.color_pair(254, 255)
|
||||
for (let k = 0; k < 24; k++) {
|
||||
con.mvaddch(charYoff + i + 2, charXoff + 2 + k, 95)
|
||||
}
|
||||
}
|
||||
else {
|
||||
con.move(charYoff + i + 2, charXoff + 2)
|
||||
print(`${list[0]}`)
|
||||
let text = `${list[0]}`
|
||||
if (text.length > _fsh.QA_LABEL_WIDTH) text = text.substring(0, _fsh.QA_LABEL_WIDTH)
|
||||
if (isFocused) text = text + " ".repeat(_fsh.QA_LABEL_WIDTH - text.length)
|
||||
print(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
todoWidget.hitTest = function(charX, charY, xoff, yoff) {
|
||||
return _fsh.hitTestList(charX, charY, xoff, yoff,
|
||||
_fsh.TODO_TEXT_WIDTH, todoWidget.todoList.length, _fsh.TODO_MAX_ROWS)
|
||||
}
|
||||
|
||||
quickAccessWidget.hitTest = function(charX, charY, xoff, yoff) {
|
||||
return _fsh.hitTestList(charX, charY, xoff, yoff,
|
||||
_fsh.QA_LABEL_WIDTH, quickAccessWidget.entries.length, _fsh.QA_MAX_ROWS)
|
||||
}
|
||||
|
||||
|
||||
// Re-render the whole shell. Use after a dialog closes (which clobbered
|
||||
// the underlying char cells) or after execApp returns.
|
||||
_fsh.redrawAll = function() {
|
||||
con.color_pair(254, 255)
|
||||
con.clear()
|
||||
graphics.clearPixels(255)
|
||||
graphics.clearPixels2(255)
|
||||
graphics.setFramebufferScroll(0, 0)
|
||||
_fsh.drawWallpaper()
|
||||
_fsh.drawTitlebar()
|
||||
_fsh.widgets["com.fsh.clock"].draw(25, 3)
|
||||
_fsh.widgets["com.fsh.calendar"].draw(12, 8)
|
||||
_fsh.widgets["com.fsh.todo_list"].draw(10, 17)
|
||||
_fsh.widgets["com.fsh.quick_access"].draw(47, 8)
|
||||
}
|
||||
|
||||
_fsh.openAddTodoDialog = function() {
|
||||
let res = win.showDialog({
|
||||
title: "New Todo",
|
||||
fields: [{label: "Text:", initial: "", width: _fsh.TODO_TEXT_WIDTH}],
|
||||
allowDelete: false
|
||||
})
|
||||
_fsh.redrawAll()
|
||||
if (res.action !== "ok") return
|
||||
let text = res.values[0].trim()
|
||||
if (text.length === 0) return
|
||||
if (todoWidget.todoList.length >= _fsh.TODO_MAX_ROWS) return
|
||||
todoWidget.todoList.push([text, false])
|
||||
_fsh.saveConfig()
|
||||
}
|
||||
|
||||
_fsh.openEditTodoDialog = function(index) {
|
||||
let entry = todoWidget.todoList[index]
|
||||
if (!entry) return
|
||||
let res = win.showDialog({
|
||||
title: "Edit Todo",
|
||||
fields: [{label: "Text:", initial: entry[0], width: _fsh.TODO_TEXT_WIDTH}],
|
||||
allowDelete: true
|
||||
})
|
||||
_fsh.redrawAll()
|
||||
if (res.action === "cancel") return
|
||||
if (res.action === "delete") {
|
||||
todoWidget.todoList.splice(index, 1)
|
||||
_fsh.saveConfig()
|
||||
return
|
||||
}
|
||||
let text = res.values[0].trim()
|
||||
if (text.length === 0) return
|
||||
todoWidget.todoList[index] = [text, entry[1]]
|
||||
_fsh.saveConfig()
|
||||
}
|
||||
|
||||
_fsh.openAddQaDialog = function() {
|
||||
let res = win.showDialog({
|
||||
title: "New Quick Access",
|
||||
fields: [
|
||||
{label: "Label:", initial: "", width: _fsh.QA_LABEL_WIDTH},
|
||||
{label: "Command:", initial: "", width: _fsh.QA_CMD_WIDTH}
|
||||
],
|
||||
allowDelete: false
|
||||
})
|
||||
_fsh.redrawAll()
|
||||
if (res.action !== "ok") return
|
||||
let label = res.values[0].trim()
|
||||
let cmd = res.values[1].trim()
|
||||
if (label.length === 0 || cmd.length === 0) return
|
||||
if (quickAccessWidget.entries.length >= _fsh.QA_MAX_ROWS) return
|
||||
quickAccessWidget.entries.push([label, cmd])
|
||||
_fsh.saveConfig()
|
||||
}
|
||||
|
||||
_fsh.openEditQaDialog = function(index) {
|
||||
let entry = quickAccessWidget.entries[index]
|
||||
if (!entry) return
|
||||
let res = win.showDialog({
|
||||
title: "Edit Quick Access",
|
||||
fields: [
|
||||
{label: "Label:", initial: entry[0], width: _fsh.QA_LABEL_WIDTH},
|
||||
{label: "Command:", initial: entry[1], width: _fsh.QA_CMD_WIDTH}
|
||||
],
|
||||
allowDelete: true
|
||||
})
|
||||
_fsh.redrawAll()
|
||||
if (res.action === "cancel") return
|
||||
if (res.action === "delete") {
|
||||
quickAccessWidget.entries.splice(index, 1)
|
||||
_fsh.saveConfig()
|
||||
return
|
||||
}
|
||||
let label = res.values[0].trim()
|
||||
let cmd = res.values[1].trim()
|
||||
if (label.length === 0 || cmd.length === 0) return
|
||||
quickAccessWidget.entries[index] = [label, cmd]
|
||||
_fsh.saveConfig()
|
||||
}
|
||||
|
||||
_fsh.toggleTodoDone = function(index) {
|
||||
let entry = todoWidget.todoList[index]
|
||||
if (!entry) return
|
||||
entry[1] = !entry[1]
|
||||
_fsh.saveConfig()
|
||||
}
|
||||
|
||||
// Launch a Quick Access entry. cmd is the verbatim string the user typed.
|
||||
// We split on first space to derive a program path + args; if the path
|
||||
// has no leading "/", we treat it as relative to the current drive.
|
||||
_fsh.launchEntry = function(label, cmd) {
|
||||
let firstSpace = cmd.indexOf(" ")
|
||||
let progPath = (firstSpace >= 0) ? cmd.substring(0, firstSpace) : cmd
|
||||
let argTail = (firstSpace >= 0) ? cmd.substring(firstSpace + 1) : ""
|
||||
let fullPath = progPath.startsWith("/") ? ("A:" + progPath) : progPath
|
||||
|
||||
try {
|
||||
let f = files.open(fullPath)
|
||||
if (!f.exists) {
|
||||
serial.printerr("fsh.launchEntry: not found: " + fullPath)
|
||||
return
|
||||
}
|
||||
let code = f.sread()
|
||||
let tokens = [progPath].concat(argTail.length ? argTail.split(" ") : [])
|
||||
|
||||
// erase all pixels and draw wallpaper
|
||||
con.reset_graphics()
|
||||
con.clear()
|
||||
graphics.clearPixels(255)
|
||||
graphics.clearPixels2(255)
|
||||
_fsh.drawWallpaper()
|
||||
con.curs_set(1)
|
||||
|
||||
execApp(code, tokens)
|
||||
} catch (e) {
|
||||
serial.printerr("fsh.launchEntry: " + label + " failed: " + e)
|
||||
}
|
||||
con.curs_set(0)
|
||||
graphics.setBackground(2,1,3)
|
||||
graphics.resetPalette()
|
||||
// Apps (e.g. zfm) may switch to graphics mode 0; restore mode 3 so the
|
||||
// clock widget on framebuffer 2 is composited again.
|
||||
graphics.setGraphicsMode(3)
|
||||
_fsh.redrawAll()
|
||||
}
|
||||
|
||||
// Layout map: widget positions hard-coded to match the draw calls below.
|
||||
_fsh.layouts = {
|
||||
"com.fsh.todo_list": {xoff: 10, yoff: 17, widget: null},
|
||||
"com.fsh.quick_access": {xoff: 47, yoff: 8, widget: null}
|
||||
}
|
||||
|
||||
// Find which widget (if any) was hit by (charX, charY). Returns
|
||||
// {widgetId, hit} or null.
|
||||
_fsh.findHit = function(charX, charY) {
|
||||
let ids = ["com.fsh.todo_list", "com.fsh.quick_access"]
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
let id = ids[i]
|
||||
let layout = _fsh.layouts[id]
|
||||
let widget = _fsh.widgets[id]
|
||||
let hit = widget.hitTest(charX, charY, layout.xoff, layout.yoff)
|
||||
if (hit) return {widgetId: id, hit: hit}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
_fsh.dispatchLeft = function(widgetId, hit) {
|
||||
if (hit.kind === "add") {
|
||||
if (widgetId === "com.fsh.todo_list") _fsh.openAddTodoDialog()
|
||||
else _fsh.openAddQaDialog()
|
||||
return
|
||||
}
|
||||
// hit.kind === "item"
|
||||
if (widgetId === "com.fsh.todo_list") {
|
||||
_fsh.toggleTodoDone(hit.index)
|
||||
} else {
|
||||
let entry = quickAccessWidget.entries[hit.index]
|
||||
if (entry) _fsh.launchEntry(entry[0], entry[1])
|
||||
}
|
||||
}
|
||||
|
||||
_fsh.dispatchRight = function(widgetId, hit) {
|
||||
if (hit.kind !== "item") return
|
||||
if (widgetId === "com.fsh.todo_list") _fsh.openEditTodoDialog(hit.index)
|
||||
else _fsh.openEditQaDialog(hit.index)
|
||||
}
|
||||
|
||||
|
||||
// change graphics mode and check if it's supported
|
||||
graphics.setGraphicsMode(3)
|
||||
@@ -260,29 +631,130 @@ _fsh.drawWallpaper()
|
||||
_fsh.drawTitlebar()
|
||||
|
||||
|
||||
// TEST
|
||||
con.move(2,1);
|
||||
print("fSh is very much in-dev! Hit backspace to exit")
|
||||
// Load persisted state before the first draw
|
||||
_fsh.loadConfig();
|
||||
|
||||
// keyEventBuffers (read via sys.peek(-41-i)) holds *raw libGDX keycodes*,
|
||||
// not the cooked TSVM scancodes that con.getch() returns. Existing fsh.js
|
||||
// already uses 67 for Backspace (libGDX DEL); follow the same scheme here.
|
||||
const KEY_ESC = keysym.ESCAPE
|
||||
const KEY_ENTER = keysym.ENTER
|
||||
const KEY_UP = keysym.UP
|
||||
const KEY_DOWN = keysym.DOWN
|
||||
const KEY_LEFT = keysym.LEFT
|
||||
const KEY_RIGHT = keysym.RIGHT
|
||||
const KEY_LSHIFT = keysym.SHIFT_LEFT
|
||||
const KEY_RSHIFT = keysym.SHIFT_RIGHT
|
||||
|
||||
let prevButtons = 0
|
||||
let prevMouseCharX = -1
|
||||
let prevMouseCharY = -1
|
||||
let keyLatch = {} // {keycode: true} while the key is held — debounces "just pressed"
|
||||
|
||||
// TODO update for events: key down (updates some widgets), timer (updates clock and calendar widgets)
|
||||
while (true) {
|
||||
captureUserInput();
|
||||
if (getKeyPushed(0) == 67) break;
|
||||
captureUserInput()
|
||||
|
||||
_fsh.widgets["com.fsh.clock"].draw(25, 3);
|
||||
_fsh.widgets["com.fsh.calendar"].draw(12, 8);
|
||||
_fsh.widgets["com.fsh.todo_list"].draw(10, 17);
|
||||
_fsh.widgets["com.fsh.quick_access"].draw(47, 8);
|
||||
// -- keyboard --
|
||||
if (isKeyDown(KEY_ESC)) break;
|
||||
|
||||
sys.spin();sys.spin()
|
||||
let shiftDown = isKeyDown(KEY_LSHIFT) || isKeyDown(KEY_RSHIFT)
|
||||
let enterPressed = false
|
||||
|
||||
// Edge-detect each navigation key
|
||||
function edge(kc) {
|
||||
let down = isKeyDown(kc)
|
||||
let was = !!keyLatch[kc]
|
||||
keyLatch[kc] = down
|
||||
return down && !was
|
||||
}
|
||||
|
||||
if (edge(KEY_ENTER)) enterPressed = true;
|
||||
let navUp = edge(KEY_UP)
|
||||
let navDown = edge(KEY_DOWN)
|
||||
let navLeft = edge(KEY_LEFT)
|
||||
let navRight = edge(KEY_RIGHT)
|
||||
|
||||
// -- mouse --
|
||||
// MMIO returns VM-screen pixel coords (origin at the top-left of the framebuffer).
|
||||
// Widget xoff/yoff are passed straight into con.move(y, x), which is 1-indexed, so
|
||||
// we offset by +1 here. Without this the click registers one cell up-and-left from
|
||||
// where the user's pointer is, because pixel 0 = con.move(1, 1).
|
||||
let pos = readMousePos()
|
||||
let charX = (pos[0] / 7 | 0) + 1
|
||||
let charY = (pos[1] / 14 | 0) + 1
|
||||
let mouseMoved = (charX !== prevMouseCharX || charY !== prevMouseCharY)
|
||||
prevMouseCharX = charX
|
||||
prevMouseCharY = charY
|
||||
|
||||
let buttons = readMouseButtons()
|
||||
let leftEdge = ((buttons & _fsh.MB_LEFT) !== 0) && ((prevButtons & _fsh.MB_LEFT) === 0)
|
||||
let rightEdge = ((buttons & _fsh.MB_RIGHT) !== 0) && ((prevButtons & _fsh.MB_RIGHT) === 0)
|
||||
prevButtons = buttons
|
||||
|
||||
// -- focus update --
|
||||
if (navUp || navDown || navLeft || navRight) {
|
||||
if (!_fsh.focus) _fsh.focus = {widgetId: "com.fsh.todo_list", index: 0}
|
||||
if (navUp || navDown) {
|
||||
let layout = _fsh.layouts[_fsh.focus.widgetId]
|
||||
let maxRows = (_fsh.focus.widgetId === "com.fsh.todo_list")
|
||||
? _fsh.TODO_MAX_ROWS : _fsh.QA_MAX_ROWS
|
||||
let length = (_fsh.focus.widgetId === "com.fsh.todo_list")
|
||||
? todoWidget.todoList.length : quickAccessWidget.entries.length
|
||||
let maxIdx = Math.min(length, maxRows - 1)
|
||||
let next = _fsh.focus.index + (navDown ? 1 : -1)
|
||||
if (next < 0) next = 0
|
||||
if (next > maxIdx) next = maxIdx
|
||||
_fsh.focus.index = next
|
||||
} else {
|
||||
// Left/right switches widget
|
||||
let other = (_fsh.focus.widgetId === "com.fsh.todo_list")
|
||||
? "com.fsh.quick_access" : "com.fsh.todo_list"
|
||||
let otherLength = (other === "com.fsh.todo_list")
|
||||
? todoWidget.todoList.length : quickAccessWidget.entries.length
|
||||
let otherMaxRows = (other === "com.fsh.todo_list")
|
||||
? _fsh.TODO_MAX_ROWS : _fsh.QA_MAX_ROWS
|
||||
let otherMaxIdx = Math.min(otherLength, otherMaxRows - 1)
|
||||
_fsh.focus = {widgetId: other, index: Math.min(_fsh.focus.index, otherMaxIdx)}
|
||||
}
|
||||
} else if (mouseMoved) {
|
||||
let h = _fsh.findHit(charX, charY)
|
||||
_fsh.focus = h ? {widgetId: h.widgetId, index: h.hit.kind === "add"
|
||||
? ((h.widgetId === "com.fsh.todo_list")
|
||||
? todoWidget.todoList.length
|
||||
: quickAccessWidget.entries.length)
|
||||
: h.hit.index} : null
|
||||
}
|
||||
|
||||
// -- mouse click dispatch --
|
||||
if (leftEdge) {
|
||||
let h = _fsh.findHit(charX, charY)
|
||||
if (h) _fsh.dispatchLeft(h.widgetId, h.hit)
|
||||
} else if (rightEdge) {
|
||||
let h = _fsh.findHit(charX, charY)
|
||||
if (h) _fsh.dispatchRight(h.widgetId, h.hit)
|
||||
}
|
||||
|
||||
// -- keyboard dispatch (synthesise click at focus) --
|
||||
if (enterPressed && _fsh.focus) {
|
||||
let length = (_fsh.focus.widgetId === "com.fsh.todo_list")
|
||||
? todoWidget.todoList.length : quickAccessWidget.entries.length
|
||||
let hit = (_fsh.focus.index < length)
|
||||
? {kind: "item", index: _fsh.focus.index}
|
||||
: (_fsh.focus.index === length ? {kind: "add"} : null)
|
||||
if (hit) {
|
||||
if (shiftDown) _fsh.dispatchRight(_fsh.focus.widgetId, hit)
|
||||
else _fsh.dispatchLeft(_fsh.focus.widgetId, hit)
|
||||
}
|
||||
}
|
||||
|
||||
// -- redraw --
|
||||
_fsh.widgets["com.fsh.clock"].draw(25, 3)
|
||||
_fsh.widgets["com.fsh.calendar"].draw(12, 8)
|
||||
_fsh.widgets["com.fsh.todo_list"].draw(10, 17)
|
||||
_fsh.widgets["com.fsh.quick_access"].draw(47, 8)
|
||||
|
||||
sys.spin(); sys.spin()
|
||||
}
|
||||
|
||||
con.move(3,1);
|
||||
con.color_pair(201,255);
|
||||
print("cya!");
|
||||
|
||||
let konsht = 3412341241;
|
||||
println(konsht);
|
||||
|
||||
let pppp = graphics.getCursorYX();
|
||||
println(pppp.toString());
|
||||
con.reset_graphics()
|
||||
con.clear()
|
||||
@@ -1,11 +1,13 @@
|
||||
let url="http:localhost/testnet/test.txt"
|
||||
/*let url="https:raw.githubusercontent.com/curioustorvald/hopper-mirror/refs/heads/master/aa.hop.per"
|
||||
|
||||
let file = files.open("B:\\"+url)
|
||||
|
||||
if (!file.exists) {
|
||||
printerrln("No such URL: "+url)
|
||||
return 1
|
||||
}
|
||||
}*/
|
||||
|
||||
let text = file.sread()
|
||||
let net = require("A:/tvdos/include/net.mjs")
|
||||
let text = net.fetchText("https://raw.githubusercontent.com/curioustorvald/hopper-mirror/refs/heads/master/aa.hop.per")
|
||||
if (text === null) { printerrln("No such URL"); return 1 }
|
||||
println(text)
|
||||
|
||||
@@ -32,7 +32,7 @@ if (exec_args !== undefined && exec_args[1] !== undefined && exec_args[1].starts
|
||||
return 0
|
||||
}
|
||||
|
||||
const THEVERSION = "1.2.1"
|
||||
const THEVERSION = "1.2.2"
|
||||
|
||||
const PROD = true
|
||||
let INDEX_BASE = 0
|
||||
@@ -4197,7 +4197,7 @@ bF.load = function(args) { // LOAD function
|
||||
if (args[1] === undefined) throw lang.missingOperand
|
||||
var fileOpened = fs.open(args[1], "R")
|
||||
|
||||
|
||||
serial.printerr('load '+args[1])
|
||||
if (replUsrConfirmed || cmdbuf.length == 0) {
|
||||
if (!fileOpened) {
|
||||
fileOpened = fs.open(args[1]+".BAS", "R")
|
||||
@@ -4241,7 +4241,7 @@ bF.yes = function() {
|
||||
}
|
||||
}
|
||||
bF.catalog = function(args) { // CATALOG function
|
||||
if (args[1] === undefined) args[1] = "\\"
|
||||
if (args[1] === undefined) args[1] = BASIC_HOME_PATH
|
||||
var pathOpened = fs.open(args[1], 'R')
|
||||
if (!pathOpened) {
|
||||
throw lang.noSuchFile
|
||||
@@ -4251,6 +4251,57 @@ bF.catalog = function(args) { // CATALOG function
|
||||
com.sendMessage(port, "LIST")
|
||||
println(com.pullMessage(port))
|
||||
}
|
||||
// Load a file by absolute disk path (bypasses BASIC_HOME_PATH).
|
||||
// Used by COMPILE to fetch /tbas/compile.js.
|
||||
bF._slurpAbsolute = function(path) {
|
||||
var port = _BIOS.FIRST_BOOTABLE_PORT
|
||||
com.sendMessage(port[0], "FLUSH")
|
||||
com.sendMessage(port[0], "CLOSE")
|
||||
com.sendMessage(port[0], 'OPENR"' + path + '",' + port[1])
|
||||
if (com.getStatusCode(port[0]) != 0) return undefined
|
||||
com.sendMessage(port[0], "READ")
|
||||
if (com.getStatusCode(port[0]) >= 128) return undefined
|
||||
var s = com.pullMessage(port[0])
|
||||
com.sendMessage(port[0], "FLUSH"); com.sendMessage(port[0], "CLOSE")
|
||||
return s
|
||||
}
|
||||
bF.compile = function(args) { // COMPILE "OUT.JS" -- transpile cmdbuf to JS
|
||||
if (args[1] === undefined) {
|
||||
println("Usage: COMPILE \"out.js\""); return
|
||||
}
|
||||
if (cmdbuf.length === 0) {
|
||||
println("No program loaded"); return
|
||||
}
|
||||
if (bS._compileImpl === undefined) {
|
||||
// Lazy-load compile.js from /tbas/compile.js
|
||||
var src = bF._slurpAbsolute("/tbas/compile.js")
|
||||
if (src === undefined) {
|
||||
println("Cannot load /tbas/compile.js")
|
||||
return
|
||||
}
|
||||
try { eval(src) } catch (e) {
|
||||
println("Failed to load compiler: " + e); return
|
||||
}
|
||||
if (bS._compileImpl === undefined) {
|
||||
println("compile.js loaded but did not define bS._compileImpl"); return
|
||||
}
|
||||
}
|
||||
var outpath = args[1]
|
||||
// Strip surrounding quotes if any
|
||||
if ((outpath.charAt(0) === '"' || outpath.charAt(0) === "'") &&
|
||||
outpath.charAt(outpath.length - 1) === outpath.charAt(0)) {
|
||||
outpath = outpath.substring(1, outpath.length - 1)
|
||||
}
|
||||
// Default to .js extension if missing
|
||||
if (!/\.[A-Za-z0-9]+$/.test(outpath)) outpath += ".js"
|
||||
try {
|
||||
var n = bS._compileImpl(outpath)
|
||||
println("Wrote " + n + " bytes to " + outpath)
|
||||
} catch (e) {
|
||||
serial.printerr(e + "\n" + (e.stack || ""))
|
||||
println("Compile error: " + e)
|
||||
}
|
||||
}
|
||||
Object.freeze(bF)
|
||||
|
||||
if (exec_args !== undefined && exec_args[1] !== undefined) {
|
||||
|
||||
564
assets/disk0/tbas/compile.js
Normal file
564
assets/disk0/tbas/compile.js
Normal file
@@ -0,0 +1,564 @@
|
||||
// Terran BASIC -> JavaScript compiler
|
||||
// Loaded into basic.js's context by `bF.compile`. Re-uses bF._interpretLine
|
||||
// (tokeniser + elaborator + parser + pruner) verbatim and emits a self-
|
||||
// contained JS program that does its work via `let bS = require("tbas")`.
|
||||
//
|
||||
// On load, attaches `bS._compileImpl` to the live bS object.
|
||||
|
||||
;(function() {
|
||||
|
||||
// ---------- helpers ----------------------------------------------------------
|
||||
|
||||
function isValidJsId(s) {
|
||||
return /^[A-Z_][A-Z0-9_]*$/i.test(s)
|
||||
}
|
||||
function varRef(name) {
|
||||
const u = String(name).toUpperCase()
|
||||
return isValidJsId(u) ? `bS.__state.vars.${u}` : `bS.__state.vars[${JSON.stringify(u)}]`
|
||||
}
|
||||
function jsLit(v) { return JSON.stringify(v) }
|
||||
|
||||
// Resolve a literal AST node down to a raw JS value at compile time. Used
|
||||
// for harvesting DATA constants. Only constant-propagatable types are
|
||||
// permitted; otherwise compile-time evaluation fails.
|
||||
function literalValue(node) {
|
||||
if (!node) return undefined
|
||||
switch (node.astType) {
|
||||
case "num": return Number(node.astValue)
|
||||
case "string": return String(node.astValue)
|
||||
case "bool": return Boolean(node.astValue)
|
||||
case "null": return undefined
|
||||
case "lit": return String(node.astValue) // bare identifier in DATA: keep as string
|
||||
default:
|
||||
throw Error("DATA: unsupported literal node type: " + node.astType)
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the maximum varIndex used at the immediate scope of a lambda body,
|
||||
// hence its arity.
|
||||
function lambdaArity(body) {
|
||||
let maxIdx = -1
|
||||
function walk(t, level) {
|
||||
if (!t || !t.astType) return
|
||||
if (t.astType === "defun_args" && t.astValue[0] === level) {
|
||||
if (t.astValue[1] > maxIdx) maxIdx = t.astValue[1]
|
||||
}
|
||||
// descend into nested usrdefun (its body lives in astValue, not leaves)
|
||||
if (t.astType === "usrdefun" && t.astValue && t.astValue.astLeaves !== undefined) {
|
||||
walk(t.astValue, level + 1)
|
||||
}
|
||||
// generic descent
|
||||
if (t.astLeaves) {
|
||||
for (let i = 0; i < t.astLeaves.length; i++) walk(t.astLeaves[i], level)
|
||||
}
|
||||
}
|
||||
walk(body, 0)
|
||||
return maxIdx + 1
|
||||
}
|
||||
|
||||
// ---------- expression lowering ---------------------------------------------
|
||||
|
||||
// `depth` tracks the number of enclosing lambdas during emission. When we
|
||||
// emit a lambda we increment it; defun_args [d, i] becomes _aN_i where
|
||||
// N = depth - 1 - d (the absolute lambda index of the binding scope).
|
||||
function compileExpr(tree, depth) {
|
||||
if (tree === undefined || tree === null) return "undefined"
|
||||
|
||||
// Empty parens / wrapper node: descend into the single child
|
||||
if (tree.astType === "null") {
|
||||
if (tree.astLeaves && tree.astLeaves[0] !== undefined) return compileExpr(tree.astLeaves[0], depth)
|
||||
return "undefined"
|
||||
}
|
||||
if (tree.astValue === undefined && tree.astLeaves && tree.astLeaves.length === 1) {
|
||||
return compileExpr(tree.astLeaves[0], depth)
|
||||
}
|
||||
|
||||
switch (tree.astType) {
|
||||
case "num": return String(Number(tree.astValue))
|
||||
case "string": return jsLit(String(tree.astValue))
|
||||
case "bool": return tree.astValue ? "true" : "false"
|
||||
case "lit": return compileLit(tree)
|
||||
case "defun_args": {
|
||||
const d = tree.astValue[0], i = tree.astValue[1]
|
||||
const scope = depth - 1 - d
|
||||
if (scope < 0) throw Error("defun_args refers to a scope outside the program (depth=" + depth + ", d=" + d + ")")
|
||||
return "_a" + scope + "_" + i
|
||||
}
|
||||
case "usrdefun": return compileLambdaExpr(tree, depth)
|
||||
case "array": return compileArrayRef(tree, depth)
|
||||
case "function": return compileFunctionExpr(tree, depth)
|
||||
case "op": return compileOpExpr(tree, depth)
|
||||
default:
|
||||
throw Error("Cannot compile expression node of type: " + tree.astType + " (value=" + tree.astValue + ")")
|
||||
}
|
||||
}
|
||||
|
||||
function compileLit(tree) {
|
||||
const name = String(tree.astValue).toUpperCase()
|
||||
// Built-in zero-arg / pass-as-value functions: when a builtin name is
|
||||
// referenced as a value (e.g. assigned to a variable for later use as a
|
||||
// higher-order arg), emit a JS function reference. For a plain variable
|
||||
// read, emit the vars table lookup.
|
||||
// Heuristic: if the name matches a builtin we know about, prefer the
|
||||
// function; otherwise, vars lookup.
|
||||
if (RUNTIME_BUILTINS.has(name)) {
|
||||
return "bS." + (isValidJsId(name) ? name : `[${jsLit(name)}]`)
|
||||
}
|
||||
return varRef(name)
|
||||
}
|
||||
|
||||
function compileArrayRef(tree, depth) {
|
||||
// tree.astValue = array variable name; tree.astLeaves = index expressions
|
||||
if (!tree.astLeaves || tree.astLeaves.length === 0) {
|
||||
return varRef(tree.astValue)
|
||||
}
|
||||
const indices = tree.astLeaves.map(l => compileExpr(l, depth))
|
||||
return `bS.__arrGet(${varRef(tree.astValue)}, [${indices.join(",")}])`
|
||||
}
|
||||
|
||||
function compileFunctionExpr(tree, depth) {
|
||||
const name = String(tree.astValue).toUpperCase()
|
||||
|
||||
if (name === "PRINT" || name === "EMIT") {
|
||||
// PRINT/EMIT used as expression — emit as IIFE returning undefined
|
||||
return "(" + compilePrintLike(tree, name, depth) + ", undefined)"
|
||||
}
|
||||
// user function call by name: <varname>(args) — when astType is "function"
|
||||
// and astValue is a string that matches a variable, the parser may have
|
||||
// generated this. Treat it as: invoke the var.
|
||||
if (!RUNTIME_BUILTINS.has(name)) {
|
||||
// Not a known builtin: treat as a user defined function call
|
||||
const args = (tree.astLeaves || []).map(l => compileExpr(l, depth))
|
||||
return `bS.__runFn(${varRef(name)}, [${args.join(",")}])`
|
||||
}
|
||||
|
||||
const args = (tree.astLeaves || []).map(l => compileExpr(l, depth))
|
||||
return `bS.${isValidJsId(name) ? name : `[${jsLit(name)}]`}(${args.join(",")})`
|
||||
}
|
||||
|
||||
const ARITH_OP = {
|
||||
"+": (l,r) => `bS.__add(${l},${r})`,
|
||||
"-": (l,r) => `((${l})-(${r}))`,
|
||||
"*": (l,r) => `((${l})*(${r}))`,
|
||||
"/": (l,r) => `bS.__div(${l},${r})`,
|
||||
"\\": (l,r) => `bS.__intdiv(${l},${r})`,
|
||||
"MOD":(l,r) => `bS.__mod(${l},${r})`,
|
||||
"^": (l,r) => `bS.__pow(${l},${r})`,
|
||||
"==": (l,r) => `((${l})==(${r}))`,
|
||||
"<>": (l,r) => `((${l})!=(${r}))`,
|
||||
"><": (l,r) => `((${l})!=(${r}))`,
|
||||
"<": (l,r) => `((${l})<(${r}))`,
|
||||
">": (l,r) => `((${l})>(${r}))`,
|
||||
"<=": (l,r) => `((${l})<=(${r}))`,
|
||||
"=<": (l,r) => `((${l})<=(${r}))`,
|
||||
">=": (l,r) => `((${l})>=(${r}))`,
|
||||
"=>": (l,r) => `((${l})>=(${r}))`,
|
||||
"AND":(l,r) => `bS.AND(${l},${r})`,
|
||||
"OR": (l,r) => `bS.OR(${l},${r})`,
|
||||
"<<": (l,r) => `((${l})<<(${r}))`,
|
||||
">>": (l,r) => `((${l})>>>(${r}))`,
|
||||
"BAND":(l,r) => `((${l})&(${r}))`,
|
||||
"BOR": (l,r) => `((${l})|(${r}))`,
|
||||
"BXOR":(l,r) => `((${l})^(${r}))`,
|
||||
}
|
||||
const UNARY_OP = {
|
||||
"UNARYMINUS": (a) => `(-(${a}))`,
|
||||
"UNARYPLUS": (a) => `(+(${a}))`,
|
||||
"UNARYLOGICNOT":(a) => `(!(${a}))`,
|
||||
"UNARYBNOT": (a) => `(~(${a}))`,
|
||||
}
|
||||
|
||||
function compileOpExpr(tree, depth) {
|
||||
const op = String(tree.astValue)
|
||||
const leaves = tree.astLeaves || []
|
||||
|
||||
// Unary
|
||||
if (UNARY_OP[op] && (leaves.length === 1 || leaves[1] === undefined)) {
|
||||
return UNARY_OP[op](compileExpr(leaves[0], depth))
|
||||
}
|
||||
|
||||
// Binary arithmetic / comparison / logic
|
||||
if (ARITH_OP[op] && leaves.length === 2) {
|
||||
return ARITH_OP[op](compileExpr(leaves[0], depth), compileExpr(leaves[1], depth))
|
||||
}
|
||||
|
||||
// Generator / range
|
||||
if (op === "TO" && leaves.length === 2) {
|
||||
return `new bS.__ForGen(${compileExpr(leaves[0], depth)}, ${compileExpr(leaves[1], depth)}, 1)`
|
||||
}
|
||||
if (op === "STEP" && leaves.length === 2) {
|
||||
return `bS.STEP(${compileExpr(leaves[0], depth)}, ${compileExpr(leaves[1], depth)})`
|
||||
}
|
||||
|
||||
// List ops
|
||||
if ((op === "!" || op === "~" || op === "#") && leaves.length === 2) {
|
||||
const fn = (op === "!") ? "['!']" : (op === "~") ? "['~']" : "['#']"
|
||||
return `bS${fn}(${compileExpr(leaves[0], depth)}, ${compileExpr(leaves[1], depth)})`
|
||||
}
|
||||
|
||||
// Assignment as expression — returns the assigned value
|
||||
if (op === "=" && leaves.length === 2) {
|
||||
return "(" + compileAssignExpr(tree, depth) + ")"
|
||||
}
|
||||
if (op === "IN" && leaves.length === 2) {
|
||||
// Used inside FOR/FOREACH; compileFor unwraps these. As a value, treat
|
||||
// as { asgnVarName, asgnValue } so a stray IN still works.
|
||||
const name = jsLit(String(leaves[0].astValue).toUpperCase())
|
||||
const rhs = compileExpr(leaves[1], depth)
|
||||
return `({asgnVarName: ${name}, asgnValue: ${rhs}})`
|
||||
}
|
||||
|
||||
// Functional / monad ops
|
||||
if ((op === ">>=" || op === ">>~" || op === "." || op === "$" ||
|
||||
op === "&" || op === "~<" || op === "<*>" || op === "<$>" ||
|
||||
op === "<~>") && leaves.length === 2) {
|
||||
return `bS[${jsLit(op)}](${compileExpr(leaves[0], depth)}, ${compileExpr(leaves[1], depth)})`
|
||||
}
|
||||
if (op === "@" && leaves.length === 1) {
|
||||
// Monad return as prefix
|
||||
return `bS.MRET(${compileExpr(leaves[0], depth)})`
|
||||
}
|
||||
if (op === "~>") {
|
||||
throw Error("Compiler: bare ~> survived prune (should be usrdefun)")
|
||||
}
|
||||
|
||||
throw Error("Cannot compile op '" + op + "' with " + leaves.length + " operand(s)")
|
||||
}
|
||||
|
||||
function compileLambdaExpr(tree, depth) {
|
||||
// tree.astType === "usrdefun"; tree.astValue holds the body AST; if
|
||||
// tree.astLeaves is non-empty, this is an immediate application.
|
||||
const body = tree.astValue
|
||||
if (!body || !body.astType) throw Error("Malformed usrdefun")
|
||||
|
||||
const arity = lambdaArity(body)
|
||||
const newDepth = depth + 1
|
||||
const params = []
|
||||
for (let i = 0; i < arity; i++) params.push("_a" + (newDepth - 1) + "_" + i)
|
||||
const bodyJs = compileExpr(body, newDepth)
|
||||
const arrow = `((${params.join(",")}) => (${bodyJs}))`
|
||||
|
||||
if (tree.astLeaves && tree.astLeaves.length > 0) {
|
||||
const args = tree.astLeaves.map(l => compileExpr(l, depth))
|
||||
return `${arrow}(${args.join(",")})`
|
||||
}
|
||||
return arrow
|
||||
}
|
||||
|
||||
function compileAssignExpr(tree, depth) {
|
||||
// op "=" with leaves[0] as target, leaves[1] as RHS
|
||||
const lhs = tree.astLeaves[0]
|
||||
const rhs = compileExpr(tree.astLeaves[1], depth)
|
||||
|
||||
if (lhs.astType === "lit") {
|
||||
const name = String(lhs.astValue).toUpperCase()
|
||||
return `(${varRef(name)} = ${rhs})`
|
||||
}
|
||||
// The parser emits "function" or "array" for `A(i,j) = ...` — both mean
|
||||
// "store into element of A".
|
||||
if (lhs.astType === "array" || lhs.astType === "function") {
|
||||
const indices = lhs.astLeaves.map(l => compileExpr(l, depth))
|
||||
return `(bS.__arrSet(${varRef(lhs.astValue)}, [${indices.join(",")}], ${rhs}), ${rhs})`
|
||||
}
|
||||
throw Error("Cannot assign to LHS of type " + lhs.astType)
|
||||
}
|
||||
|
||||
// ---------- statement lowering ----------------------------------------------
|
||||
|
||||
function compilePrintLike(tree, fname, depth) {
|
||||
const leaves = (tree.astLeaves || []).slice()
|
||||
const seps = (tree.astSeps || []).slice()
|
||||
|
||||
let suppressNewline = false
|
||||
if (leaves.length > 0 && leaves[leaves.length - 1] !== undefined &&
|
||||
leaves[leaves.length - 1].astType === "null") {
|
||||
suppressNewline = true
|
||||
leaves.pop()
|
||||
}
|
||||
|
||||
const valueExprs = leaves.map(l => compileExpr(l, depth))
|
||||
if (suppressNewline) valueExprs.push("bS.__PRINT_NONL")
|
||||
const sepArr = seps.slice(0, leaves.length - 1)
|
||||
|
||||
return `bS.${fname}([${valueExprs.join(", ")}], ${jsLit(sepArr)})`
|
||||
}
|
||||
|
||||
function setPc(pc) {
|
||||
if (pc[0] === Infinity) return "pc=[Infinity,0];"
|
||||
return "pc=[" + pc[0] + "," + pc[1] + "];"
|
||||
}
|
||||
|
||||
function compileStatement(tree, lnum, stmt, nextPc) {
|
||||
if (!tree) return setPc(nextPc)
|
||||
if (tree.astType === "null" && tree.astLeaves && tree.astLeaves[0]) {
|
||||
return compileStatement(tree.astLeaves[0], lnum, stmt, nextPc)
|
||||
}
|
||||
|
||||
const isFn = (tree.astType === "function" || tree.astType === "op")
|
||||
const fname = isFn ? String(tree.astValue).toUpperCase() : null
|
||||
|
||||
switch (fname) {
|
||||
case "GOTO": {
|
||||
const target = compileGotoTarget(tree.astLeaves[0])
|
||||
return `pc=${target};`
|
||||
}
|
||||
case "GOSUB": {
|
||||
const target = compileGotoTarget(tree.astLeaves[0])
|
||||
return `gosubStack.push([${nextPc[0]},${nextPc[1]}]); pc=${target};`
|
||||
}
|
||||
case "RETURN":
|
||||
return `pc=gosubStack.pop(); if(!pc) throw new Error("RETURN without GOSUB");`
|
||||
case "END":
|
||||
return "pc=[Infinity,0];"
|
||||
case "IF":
|
||||
return compileIf(tree, lnum, stmt, nextPc)
|
||||
case "ON":
|
||||
return compileOn(tree, lnum, stmt, nextPc)
|
||||
case "FOR":
|
||||
case "FOREACH":
|
||||
return compileFor(tree, lnum, stmt, nextPc, fname === "FOREACH")
|
||||
case "NEXT":
|
||||
return compileNext(tree, lnum, stmt, nextPc)
|
||||
case "READ": {
|
||||
const target = tree.astLeaves[0]
|
||||
if (target.astType !== "lit") throw Error("READ: target must be a variable")
|
||||
return `${varRef(target.astValue)}=bS.__readData(); ${setPc(nextPc)}`
|
||||
}
|
||||
case "RESTORE":
|
||||
return `bS.__state.dataCursor=0; ${setPc(nextPc)}`
|
||||
case "DATA":
|
||||
case "LABEL":
|
||||
return setPc(nextPc) // harvested at compile time
|
||||
case "DIM":
|
||||
return compileDim(tree, lnum, stmt, nextPc)
|
||||
case "PRINT":
|
||||
case "EMIT":
|
||||
return `${compilePrintLike(tree, fname, 0)}; ${setPc(nextPc)}`
|
||||
case "OPTIONBASE":
|
||||
return `bS.OPTIONBASE(${compileExpr(tree.astLeaves[0], 0)}); ${setPc(nextPc)}`
|
||||
case "OPTIONDEBUG":
|
||||
return `bS.OPTIONDEBUG(${compileExpr(tree.astLeaves[0], 0)}); ${setPc(nextPc)}`
|
||||
case "OPTIONTRACE":
|
||||
return `bS.OPTIONTRACE(${compileExpr(tree.astLeaves[0], 0)}); ${setPc(nextPc)}`
|
||||
case "INPUT": {
|
||||
// INPUT <var> -> read into var
|
||||
const target = tree.astLeaves[tree.astLeaves.length - 1]
|
||||
if (target.astType !== "lit") throw Error("INPUT: target must be a variable")
|
||||
return `${varRef(target.astValue)}=bS.INPUT(); ${setPc(nextPc)}`
|
||||
}
|
||||
case "=":
|
||||
return `${compileAssignExpr(tree, 0)}; ${setPc(nextPc)}`
|
||||
case "IN":
|
||||
// bare IN as a statement is unusual but harmless
|
||||
return `${compileExpr(tree, 0)}; ${setPc(nextPc)}`
|
||||
case "REM":
|
||||
return setPc(nextPc)
|
||||
}
|
||||
|
||||
// Default: evaluate as an expression for side effect, then advance
|
||||
return `${compileExpr(tree, 0)}; ${setPc(nextPc)}`
|
||||
}
|
||||
|
||||
function compileGotoTarget(leaf) {
|
||||
// Always route through __resolveTarget so non-existent line numbers snap
|
||||
// upward to the next existing line — matching basic.js's main loop,
|
||||
// which increments lnum until it finds a populated cmdbuf entry.
|
||||
if (leaf.astType === "num") return `bS.__resolveTarget(${Number(leaf.astValue)})`
|
||||
if (leaf.astType === "string") return `bS.__resolveTarget(${jsLit(leaf.astValue)})`
|
||||
if (leaf.astType === "lit") {
|
||||
const name = String(leaf.astValue)
|
||||
return `bS.__resolveTarget(bS.__state.gotoLabels[${jsLit(name)}]!==undefined ? ${jsLit(name)} : ${varRef(name)})`
|
||||
}
|
||||
return `bS.__resolveTarget(${compileExpr(leaf, 0)})`
|
||||
}
|
||||
|
||||
function compileIf(tree, lnum, stmt, nextPc) {
|
||||
const test = compileExpr(tree.astLeaves[0], 0)
|
||||
const thenStmt = compileStatement(tree.astLeaves[1], lnum, stmt, nextPc)
|
||||
const elseStmt = (tree.astLeaves[2])
|
||||
? compileStatement(tree.astLeaves[2], lnum, stmt, nextPc)
|
||||
: setPc(nextPc)
|
||||
return `if(bS.__test(${test})){${thenStmt}}else{${elseStmt}}`
|
||||
}
|
||||
|
||||
function compileOn(tree, lnum, stmt, nextPc) {
|
||||
// children: testExpr, jumpFnLit, target0, target1, ...
|
||||
const testExpr = compileExpr(tree.astLeaves[0], 0)
|
||||
const jmpFn = String(tree.astLeaves[1].astValue).toUpperCase()
|
||||
const targets = tree.astLeaves.slice(2)
|
||||
|
||||
const cases = targets.map((t, i) => {
|
||||
const tgt = compileGotoTarget(t)
|
||||
if (jmpFn === "GOSUB") {
|
||||
return `case ${i}: gosubStack.push([${nextPc[0]},${nextPc[1]}]); pc=${tgt}; break;`
|
||||
}
|
||||
return `case ${i}: pc=${tgt}; break;`
|
||||
})
|
||||
return `{const _o=(${testExpr})-bS.__state.indexBase; switch(_o){${cases.join(" ")} default: ${setPc(nextPc)}}}`
|
||||
}
|
||||
|
||||
function compileFor(tree, lnum, stmt, nextPc, isForEach) {
|
||||
const child = tree.astLeaves[0]
|
||||
if (child.astType !== "op" || (child.astValue !== "=" && child.astValue !== "IN")) {
|
||||
throw Error("FOR/FOREACH: expected = or IN, got " + child.astType + ":" + child.astValue)
|
||||
}
|
||||
const varname = String(child.astLeaves[0].astValue).toUpperCase()
|
||||
let iter = compileExpr(child.astLeaves[1], 0)
|
||||
if (isForEach) {
|
||||
// ensure we coerce generators into arrays for FOREACH semantics
|
||||
iter = `(function(_x){return bS.__isGenerator(_x)?bS.__genToArray(_x):_x})(${iter})`
|
||||
}
|
||||
// Pass nextPc — the PC of the loop body's first statement — so NEXT can
|
||||
// jump straight back without relying on fall-through.
|
||||
return `bS.__forSetup(${jsLit(varname)}, ${iter}, ${nextPc[0]}, ${nextPc[1]}); ${setPc(nextPc)}`
|
||||
}
|
||||
|
||||
function compileNext(tree, lnum, stmt, nextPc) {
|
||||
let argExpr = "undefined"
|
||||
const leaves = tree.astLeaves || []
|
||||
if (leaves.length === 1 && leaves[0] && leaves[0].astType === "lit") {
|
||||
argExpr = jsLit(String(leaves[0].astValue).toUpperCase())
|
||||
}
|
||||
return `{const _n=bS.__forNext(${argExpr}); if(_n){pc=_n;}else{${setPc(nextPc)}}}`
|
||||
}
|
||||
|
||||
function compileDim(tree, lnum, stmt, nextPc) {
|
||||
// tree.astLeaves contains array constructor calls: each leaf is either
|
||||
// an `array` node OR a `function` node (the parser doesn't distinguish
|
||||
// `A(5)` from a function call until runtime). astValue is the variable
|
||||
// name and astLeaves are the dimension expressions.
|
||||
const stmts = []
|
||||
for (let i = 0; i < tree.astLeaves.length; i++) {
|
||||
const leaf = tree.astLeaves[i]
|
||||
if (leaf.astType !== "array" && leaf.astType !== "function") {
|
||||
throw Error("DIM: expected array decl, got " + leaf.astType)
|
||||
}
|
||||
const name = String(leaf.astValue).toUpperCase()
|
||||
const dims = leaf.astLeaves.map(l => compileExpr(l, 0))
|
||||
stmts.push(`${varRef(name)}=bS.__dim([${dims.join(",")}]);`)
|
||||
}
|
||||
return stmts.join(" ") + " " + setPc(nextPc)
|
||||
}
|
||||
|
||||
// ---------- top-level entry --------------------------------------------------
|
||||
|
||||
// Set of builtin names exposed by tbas.mjs. Used to decide whether a `lit`
|
||||
// in expression position is a variable or a function reference.
|
||||
const RUNTIME_BUILTINS = new Set([
|
||||
"PRINT","EMIT","INPUT","CIN",
|
||||
"ABS","SGN","INT","FLOOR","CEIL","FIX","ROUND","SQR","CBR",
|
||||
"SIN","COS","TAN","ASN","ACO","ATN","SINH","COSH","TANH",
|
||||
"EXP","LOG","MIN","MAX","RND",
|
||||
"SPC","LEFT","RIGHT","MID","CHR",
|
||||
"LEN","HEAD","TAIL","INIT","LAST","MAP","FOLD","FILTER","ARRAY",
|
||||
"CLS","CLPX","PLOT","GOTOYX","TEXTFORE","TEXTBACK",
|
||||
"POKE","PEEK","GETKEYSDOWN","CPUT","CGET","CSTA",
|
||||
"TYPEOF","OPTIONBASE","OPTIONDEBUG","OPTIONTRACE",
|
||||
"MRET","MLIST","MJOIN",
|
||||
"AND","OR","NOT",
|
||||
"DO","CLEAR","END","TO","STEP",
|
||||
"FOR","FOREACH","NEXT","IF","ON","GOTO","GOSUB","RETURN",
|
||||
"DIM","DATA","READ","RESTORE","LABEL","REM",
|
||||
"TEST",
|
||||
])
|
||||
|
||||
bS._compileImpl = function(outpath) {
|
||||
if (typeof cmdbuf === "undefined") throw Error("compile.js: cmdbuf not available")
|
||||
if (typeof bF === "undefined") throw Error("compile.js: bF not available")
|
||||
if (typeof bF._interpretLine !== "function") throw Error("compile.js: bF._interpretLine not available")
|
||||
|
||||
// Reset parser-side state so we don't pollute the live interpreter
|
||||
if (typeof lambdaBoundVars !== "undefined") lambdaBoundVars.length = 0
|
||||
const savedPrescan = (typeof prescan !== "undefined") ? prescan : false
|
||||
if (typeof prescan !== "undefined") prescan = true // suppress execution of LABEL/DATA prescan side-effects
|
||||
|
||||
// ---- pass 1: parse every line ----
|
||||
const programTrees = [] // [lnum] -> array of statements
|
||||
for (let lnum = 0; lnum < cmdbuf.length; lnum++) {
|
||||
const linestr = cmdbuf[lnum]
|
||||
if (linestr === undefined) continue
|
||||
const trees = bF._interpretLine(lnum, String(linestr).trim())
|
||||
if (trees !== undefined) programTrees[lnum] = trees
|
||||
}
|
||||
if (typeof prescan !== "undefined") prescan = savedPrescan
|
||||
|
||||
// ---- pass 2: ordered list of populated lnums and successor table ----
|
||||
const linenums = []
|
||||
for (let lnum = 0; lnum < programTrees.length; lnum++) {
|
||||
if (programTrees[lnum] !== undefined) linenums.push(lnum)
|
||||
}
|
||||
|
||||
function nextPcOf(idx, stmtIdx) {
|
||||
const lnum = linenums[idx]
|
||||
const stmts = programTrees[lnum]
|
||||
if (stmtIdx + 1 < stmts.length) return [lnum, stmtIdx + 1]
|
||||
if (idx + 1 < linenums.length) return [linenums[idx + 1], 0]
|
||||
return [Infinity, 0]
|
||||
}
|
||||
|
||||
// ---- pass 3: harvest DATA constants and LABEL definitions ----
|
||||
const dataConsts = []
|
||||
const labelMap = {}
|
||||
for (let i = 0; i < linenums.length; i++) {
|
||||
const lnum = linenums[i]
|
||||
const stmts = programTrees[lnum]
|
||||
for (let s = 0; s < stmts.length; s++) {
|
||||
const t = stmts[s]
|
||||
if (!t) continue
|
||||
if (t.astValue === "DATA") {
|
||||
for (let k = 0; k < t.astLeaves.length; k++) {
|
||||
dataConsts.push(literalValue(t.astLeaves[k]))
|
||||
}
|
||||
} else if (t.astValue === "LABEL") {
|
||||
const lblNode = t.astLeaves[0]
|
||||
if (!lblNode) throw Error("LABEL with no name on line " + lnum)
|
||||
const lblName = String(lblNode.astValue)
|
||||
labelMap[lblName] = [lnum, s]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- pass 4: emit case bodies ----
|
||||
const cases = []
|
||||
for (let i = 0; i < linenums.length; i++) {
|
||||
const lnum = linenums[i]
|
||||
const stmts = programTrees[lnum]
|
||||
for (let s = 0; s < stmts.length; s++) {
|
||||
const next = nextPcOf(i, s)
|
||||
const body = compileStatement(stmts[s], lnum, s, next)
|
||||
cases.push(` case ${lnum}*32+${s}: { ${body} break; }`)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- pass 5: assemble final output ----
|
||||
const firstPc = (linenums.length > 0) ? `[${linenums[0]},0]` : `[Infinity,0]`
|
||||
const labelMapJs = "{" + Object.keys(labelMap).map(k =>
|
||||
`${jsLit(k)}: [${labelMap[k][0]}, ${labelMap[k][1]}]`
|
||||
).join(", ") + "}"
|
||||
|
||||
const out =
|
||||
`// Compiled by Terran BASIC -> JS compiler (assets/disk0/tbas/compile.js)
|
||||
// Source line count: ${linenums.length}
|
||||
let bS = require("tbas")
|
||||
bS.__reset()
|
||||
bS.__data(${jsLit(dataConsts)})
|
||||
bS.__labels(${labelMapJs})
|
||||
bS.__setLines(${jsLit(linenums)})
|
||||
let pc = ${firstPc}
|
||||
const gosubStack = []
|
||||
while (pc[0] !== Infinity) {
|
||||
switch (pc[0]*32 + pc[1]) {
|
||||
${cases.join("\n")}
|
||||
default: pc = [Infinity, 0]; break;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// ---- write to disk via basic.js's fs (writes under BASIC_HOME_PATH) ----
|
||||
const opened = fs.open(outpath, "W")
|
||||
if (!opened) throw Error("Cannot open " + outpath + " for writing")
|
||||
fs.write(out)
|
||||
return out.length
|
||||
}
|
||||
|
||||
})();
|
||||
@@ -19,9 +19,9 @@ var Note = (function() {
|
||||
if (flats[s] !== names[s]) t[flats[s] + oct] = n(oct, s);
|
||||
}
|
||||
}
|
||||
t.OFF = 0x0000; // key-off
|
||||
t.CUT = 0xFFFE; // note cut (immediate)
|
||||
t.NOP = 0xFFFF; // no-op (empty row)
|
||||
t.NOP = 0x0000; // no-op (empty row)
|
||||
t.OFF = 0x0001; // key-off
|
||||
t.CUT = 0x0002; // note cut (immediate)
|
||||
return t;
|
||||
}());
|
||||
|
||||
|
||||
@@ -55,10 +55,12 @@ class PmemFSfile {
|
||||
// string representation (preferable)
|
||||
if (typeof bytes === 'string' || bytes instanceof String) {
|
||||
this.data = bytes
|
||||
this.length = bytes.length
|
||||
}
|
||||
// Javascript array OR JVM byte[]
|
||||
else if (Array.isArray(bytes) || bytes.toString().startsWith("[B")) {
|
||||
this.bdata = bytes[i]
|
||||
this.bdata = bytes
|
||||
this.length = bytes.length
|
||||
}
|
||||
else {
|
||||
throw Error("Invalid type for directory")
|
||||
@@ -76,10 +78,10 @@ class PmemFSfile {
|
||||
|
||||
dataAsBytes() {
|
||||
if (this.bdata !== undefined) return this.bdata
|
||||
this.bdata = new Int8Array(this.data.length)
|
||||
this.bdata = new Uint8Array(this.data.length)
|
||||
for (let i = 0; i < this.data.length; i++) {
|
||||
let p = this.data.charCodeAt(i)
|
||||
this.bdata[i] = (p > 127) ? p - 255 : p
|
||||
this.bdata[i] = p
|
||||
}
|
||||
return this.bdata
|
||||
}
|
||||
@@ -147,10 +149,12 @@ _TVDOS.variables = {
|
||||
LANG: "EN",
|
||||
KEYBOARD: "us_qwerty",
|
||||
PATH: "\\tvdos\\bin;\\home",
|
||||
INCLPATH: "\\tvdos\\include;\\home",
|
||||
PATHEXT: ".com;.bat;.app;.js;.alias",
|
||||
HELPPATH: "\\tvdos\\help",
|
||||
OS_NAME: "TSVM Disk Operating System",
|
||||
OS_VERSION: _TVDOS.VERSION
|
||||
OS_VERSION: _TVDOS.VERSION,
|
||||
USERCONFIGPATH: "\\home\\config",
|
||||
};
|
||||
Object.freeze(_TVDOS);
|
||||
|
||||
@@ -162,16 +166,16 @@ class TVDOSFileDescriptor {
|
||||
|
||||
constructor(path0, driverID) {
|
||||
if (path0.startsWith("$")) {
|
||||
let path1 = path0.substring(3)
|
||||
let slashPos = path1.indexOf("/")
|
||||
let path1 = path0.replaceAll("/", "\\").substring(3)
|
||||
let slashPos = path1.indexOf("\\")
|
||||
let devName = path1.substring(0, (slashPos < 0) ? path1.length : slashPos)
|
||||
|
||||
if (!files.reservedNames.includes(devName)) {
|
||||
throw Error(`${devName} is not a valid device file`)
|
||||
}
|
||||
|
||||
this._driveLetter = undefined
|
||||
this._path = path0
|
||||
this._driveLetter = '$'
|
||||
this._path = '\\' + path1
|
||||
this._driverID = `DEV${devName}`
|
||||
this._driver = _TVDOS.DRV.FS[`DEV${devName}`] // can't just put `driverID` here
|
||||
}
|
||||
@@ -937,8 +941,9 @@ _TVDOS.DRV.FS.DEVTMP.bread = (fd) => {
|
||||
_TVDOS.DRV.FS.DEVTMP.pread = (fd, ptr, count, offset) => {
|
||||
if (_TVDOS.TMPFS[fd.path] === undefined) throw Error(`No such file: ${fd.fullPath}`)
|
||||
let str = _TVDOS.TMPFS[fd.path].dataAsString()
|
||||
for (let i = 0; i < count - (offset || 0); i++) {
|
||||
sys.poke(ptr + i, String.charCodeAt(i + (offset || 0)))
|
||||
let off = offset || 0
|
||||
for (let i = 0; i < count; i++) {
|
||||
sys.poke(ptr + i, str.charCodeAt(off + i))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -986,6 +991,7 @@ _TVDOS.DRV.FS.DEVTMP.remove = (fd) => {
|
||||
return true
|
||||
}
|
||||
_TVDOS.DRV.FS.DEVTMP.exists = (fd) => (_TVDOS.TMPFS[fd.path] !== undefined)
|
||||
_TVDOS.DRV.FS.DEVTMP.getFileLen = (fd) => (_TVDOS.TMPFS[fd.path].length)
|
||||
|
||||
Object.freeze(_TVDOS.DRV.FS.DEVTMP)
|
||||
|
||||
@@ -1108,13 +1114,18 @@ inputwork.repeatCount = 0;
|
||||
* where:
|
||||
* "key_down", <key symbol string>, <repeat count>, keycode0, keycode1 .. keycode7
|
||||
* "key_change", <key symbol string (what went up)>, 0, keycode0, keycode1 .. keycode7 (remaining keys that are held down)
|
||||
* "mouse_down", pos-x, pos-y, 1 // yes there's only one mouse button :p
|
||||
* "mouse_up", pos-x, pos-y, 0
|
||||
* "mouse_move", pos-x, pos-y, <button down?>, oldpos-x, oldpos-y
|
||||
* "mouse_down", pos-x, pos-y, <button mask: 1=left, 2=right, 4=middle>, keycode0..keycode7
|
||||
* "mouse_up", pos-x, pos-y, <button mask of the released button>, keycode0..keycode7
|
||||
* "mouse_move", pos-x, pos-y, <currently-held button mask>, oldpos-x, oldpos-y, keycode0..keycode7
|
||||
* "mouse_wheel", pos-x, pos-y, <-1 for wheel up, +1 for wheel down>, keycode0..keycode7
|
||||
*
|
||||
* Button mask values come from MMIO[36] bits 0..2 (terranmon.txt:52-58). The wheel
|
||||
* bits (6, 7) latch in hardware and clear on read, so a one-shot detent fires once.
|
||||
* Every mouse event carries the currently-held key buffer (same shape as key_down)
|
||||
* so handlers can detect modifiers like Shift+wheel via `event.includes(<keysym>)`.
|
||||
*/
|
||||
input.withEvent = function(callback) {
|
||||
|
||||
// TODO mouse event
|
||||
function arrayEq(a,b) {
|
||||
for (let i = 0; i < a.length; ++i) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
@@ -1135,7 +1146,33 @@ input.withEvent = function(callback) {
|
||||
|
||||
sys.poke(-40, 255);
|
||||
let keys = [sys.peek(-41),sys.peek(-42),sys.peek(-43),sys.peek(-44),sys.peek(-45),sys.peek(-46),sys.peek(-47),sys.peek(-48)];
|
||||
let mouse = [sys.peek(-33) | (sys.peek(-34) << 8), sys.peek(-35) | (sys.peek(-36) << 8), sys.peek(-37)];
|
||||
let mx = (sys.peek(-33) & 0xFF) | ((sys.peek(-34) & 0xFF) << 8);
|
||||
let my = (sys.peek(-35) & 0xFF) | ((sys.peek(-36) & 0xFF) << 8);
|
||||
let mb = sys.peek(-37) & 0xFF; // bits 0..2 = L/R/M held, bit 6 = wheel up, bit 7 = wheel down
|
||||
let mouse = [mx, my, mb];
|
||||
|
||||
// --- mouse dispatch ---
|
||||
let oldMouse = inputwork.oldMouse;
|
||||
let hasOld = oldMouse && oldMouse.length === 3;
|
||||
let oldBtns = hasOld ? (oldMouse[2] & 0x07) : 0;
|
||||
let curBtns = mb & 0x07;
|
||||
let wheelUp = (mb & 0x40) !== 0;
|
||||
let wheelDn = (mb & 0x80) !== 0;
|
||||
|
||||
if (wheelUp) callback(["mouse_wheel", mx, my, -1].concat(keys));
|
||||
if (wheelDn) callback(["mouse_wheel", mx, my, 1].concat(keys));
|
||||
|
||||
let pressed = curBtns & ~oldBtns;
|
||||
let released = oldBtns & ~curBtns;
|
||||
for (let b = 1; b <= 4; b <<= 1) {
|
||||
if (pressed & b) callback(["mouse_down", mx, my, b].concat(keys));
|
||||
if (released & b) callback(["mouse_up", mx, my, b].concat(keys));
|
||||
}
|
||||
if (hasOld && (mx !== oldMouse[0] || my !== oldMouse[1])) {
|
||||
callback(["mouse_move", mx, my, curBtns, oldMouse[0], oldMouse[1]].concat(keys));
|
||||
}
|
||||
// --- end mouse dispatch ---
|
||||
|
||||
let keyChanged = !arrayEq(keys, inputwork.oldKeys)
|
||||
let keyDiff = arrayDiff(keys, inputwork.oldKeys)
|
||||
|
||||
@@ -1405,9 +1442,6 @@ let requireFromMemory = (ptr) => {
|
||||
}*/
|
||||
|
||||
|
||||
var GL = require("A:/tvdos/include/gl.mjs")
|
||||
|
||||
|
||||
// @param cmdsrc JS source code
|
||||
// @param args arguments for the program, must be Array, and args[0] is always the name of the program, e.g.
|
||||
// for command line 'echo foo bar', args[0] must be 'echo'
|
||||
@@ -1420,7 +1454,7 @@ var execApp = (cmdsrc, args, appname) => {
|
||||
`var ${appname}=function(exec_args){${injectIntChk(cmdsrc, intchkFunName)}\n};` +
|
||||
`${appname}`); // making 'exec_args' a app-level global
|
||||
|
||||
execAppPrg(args);
|
||||
return execAppPrg(args);
|
||||
}
|
||||
|
||||
|
||||
@@ -1437,9 +1471,40 @@ try {
|
||||
serial.println("Warning: Could not load HSDPA driver: " + e.message)
|
||||
}
|
||||
|
||||
// Boot script
|
||||
serial.println(`TVDOS.SYS initialised on VM ${sys.getVmId()}, running boot script...`);
|
||||
// Boot script. The work is split across two files:
|
||||
// \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")
|
||||
eval(`var _AUTOEXEC=function(exec_args){${cmdfile.sread()}\n};` +
|
||||
`_AUTOEXEC`)(["", "-c", "\\AUTOEXEC.BAT"])
|
||||
// Environment first, boot and pane alike. Gives every pane the same
|
||||
// PATH / KEYBOARD / etc. natively, with no env-snapshot replay needed.
|
||||
// \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/sbin/vtmgr")
|
||||
runBatch("\\AUTOEXEC.BAT")
|
||||
}
|
||||
else {
|
||||
serial.println(`TVDOS.SYS re-initialised in VT pane on VM ${sys.getVmId()}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,18 @@ function makeHash() {
|
||||
const shellID = makeHash()
|
||||
|
||||
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 (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)
|
||||
print(" "+CURRENT_DRIVE+":")
|
||||
con.color_pair(161,253)
|
||||
@@ -49,9 +60,9 @@ function print_prompt_text() {
|
||||
else {
|
||||
// con.color_pair(253,255)
|
||||
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
|
||||
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 width = con.getmaxyx()[1]
|
||||
|
||||
let ts = require("typesetter")
|
||||
|
||||
if (goFancy) {
|
||||
let margin = 4
|
||||
let internalWidth = width - 2*margin
|
||||
let textWidth = internalWidth - 2 // one space of padding inside each ribbon edge
|
||||
|
||||
con.color_pair(255,253) // white text, transparent back (initial ribbon)
|
||||
|
||||
let [cy, cx] = con.getyx()
|
||||
|
||||
con.mvaddch(cy, 4, 16);con.curs_right();print(' ')
|
||||
|
||||
const PCX_INIT = margin - 2
|
||||
let tcnt = 0
|
||||
let pcx = PCX_INIT
|
||||
con.color_pair(240,253) // black text, white back (first line of text)
|
||||
while (tcnt <= motd.length) {
|
||||
let char = motd.charAt(tcnt)
|
||||
|
||||
if (char != '\n') {
|
||||
// prevent the line starting from ' '
|
||||
if (pcx != PCX_INIT || char != ' ') {
|
||||
print(motd.charAt(tcnt))
|
||||
}
|
||||
pcx += 1
|
||||
}
|
||||
|
||||
if ('\n' == char || pcx % internalWidth == 0 && pcx != 0 || tcnt == motd.length) {
|
||||
// current line ending
|
||||
let [_, ncx] = con.getyx()
|
||||
for (let k = 0; k < width - margin - ncx + 1; k++) print(' ')
|
||||
con.color_pair(255,253) // white text, transparent back
|
||||
con.addch(17);println()
|
||||
|
||||
if (tcnt == motd.length) break
|
||||
|
||||
// next line header
|
||||
let [ncy, __] = con.getyx()
|
||||
con.color_pair(255,253) // white text, transparent back
|
||||
con.mvaddch(ncy, 4, 16);con.curs_right();print(' ');con.color_pair(240,253) // black text, white back (subsequent lines of the text)
|
||||
pcx = PCX_INIT
|
||||
}
|
||||
|
||||
tcnt += 1
|
||||
}
|
||||
|
||||
let lines = ts.typeset(motd, textWidth)
|
||||
lines.forEach(line => {
|
||||
let [cy, _cx] = con.getyx()
|
||||
con.color_pair(255,253) // ribbon edge: white text, transparent back
|
||||
con.mvaddch(cy, margin, 16); con.curs_right()
|
||||
print(' ')
|
||||
con.color_pair(240,253) // body: black text, white back
|
||||
print(line)
|
||||
con.color_pair(255,253)
|
||||
print(' ')
|
||||
con.addch(17); println()
|
||||
})
|
||||
con.reset_graphics()
|
||||
}
|
||||
else {
|
||||
println()
|
||||
println(motd)
|
||||
let lines = ts.typeset(motd, width)
|
||||
lines.forEach(line => println(line))
|
||||
}
|
||||
|
||||
println()
|
||||
@@ -203,6 +189,19 @@ shell.replaceVarCall = function(value) {
|
||||
shell.getPwd = function() { return shell_pwd; }
|
||||
shell.getPwdString = function() { return "\\" + (shell_pwd.concat([""])).join("\\"); }
|
||||
shell.getCurrentDrive = function() { return CURRENT_DRIVE; }
|
||||
shell.runningScriptPaths = []
|
||||
shell.getFilePath = function() {
|
||||
return shell.runningScriptPaths[shell.runningScriptPaths.length - 1]
|
||||
}
|
||||
shell.getFileDir = function() {
|
||||
let p = shell.runningScriptPaths[shell.runningScriptPaths.length - 1]
|
||||
if (p === undefined) return undefined
|
||||
let lastSlash = Math.max(p.lastIndexOf('\\'), p.lastIndexOf('/'))
|
||||
if (lastSlash < 0) return p
|
||||
// root of a drive (e.g. "A:\foo.js" -> "A:\")
|
||||
if (lastSlash === 2 && p[1] === ':') return p.substring(0, 3)
|
||||
return p.substring(0, lastSlash)
|
||||
}
|
||||
// example input: echo "the string" > subdir\test.txt
|
||||
shell.parse = function(input) {
|
||||
let tokens = []
|
||||
@@ -577,8 +576,76 @@ shell.coreutils = {
|
||||
ver: function(args) {
|
||||
println(welcome_text)
|
||||
},
|
||||
which: function(args) {
|
||||
if (args[1] === undefined) {
|
||||
printerrln(`Usage: ${args[0].toUpperCase()} program_name`)
|
||||
return 1
|
||||
}
|
||||
let cmd = args[1]
|
||||
|
||||
if (shell.coreutils[cmd.toLowerCase()] !== undefined) {
|
||||
println(`${cmd}: shell built-in command`)
|
||||
return 0
|
||||
}
|
||||
|
||||
var fileExists = false
|
||||
var searchFile
|
||||
var searchPath = ""
|
||||
|
||||
if (shell.isValidDriveLetter(cmd[0]) && cmd[1] == ':') {
|
||||
searchFile = files.open(cmd)
|
||||
searchPath = trimStartRevSlash(searchFile.path)
|
||||
fileExists = searchFile.exists
|
||||
}
|
||||
else {
|
||||
var searchDir = (cmd.startsWith("/")) ? [""] : ["/"+shell_pwd.join("/")].concat(_TVDOS.getPath())
|
||||
|
||||
var pathExt = []
|
||||
if (cmd.split(".")[1] === undefined)
|
||||
_TVDOS.variables.PATHEXT.split(';').forEach(function(it) { pathExt.push(it); pathExt.push(it.toUpperCase()); })
|
||||
else
|
||||
pathExt.push("")
|
||||
|
||||
searchLoop:
|
||||
for (var i = 0; i < searchDir.length; i++) {
|
||||
for (var j = 0; j < pathExt.length; j++) {
|
||||
let search = searchDir[i]; if (!search.endsWith('\\')) search += '\\'
|
||||
searchPath = trimStartRevSlash(search + cmd + pathExt[j])
|
||||
|
||||
searchFile = files.open(`${CURRENT_DRIVE}:\\${searchPath}`)
|
||||
if (searchFile.exists) {
|
||||
fileExists = true
|
||||
break searchLoop
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!fileExists) {
|
||||
printerrln(`${cmd}: not found`)
|
||||
return 1
|
||||
}
|
||||
|
||||
println(searchFile.fullPath)
|
||||
return 0
|
||||
},
|
||||
panic: function(args) {
|
||||
throw Error("Panicking command.js")
|
||||
},
|
||||
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
|
||||
@@ -590,14 +657,19 @@ shell.coreutils.ls = shell.coreutils.dir
|
||||
shell.coreutils.time = shell.coreutils.date
|
||||
shell.coreutils.md = shell.coreutils.mkdir
|
||||
shell.coreutils.move = shell.coreutils.mv
|
||||
shell.coreutils.where = shell.coreutils.which
|
||||
// end of command aliases
|
||||
Object.freeze(shell.coreutils)
|
||||
shell.stdio = {
|
||||
out: {
|
||||
print: function(s) { sys.print(s) },
|
||||
println: function(s) { if (s === undefined) sys.print("\n"); else sys.print(s+"\n") },
|
||||
printerr: function(s) { sys.print("\x1B[31m"+s+"\x1B[m") },
|
||||
printerrln: function(s) { if (s === undefined) sys.print("\n"); else sys.print("\x1B[31m"+s+"\x1B[m\n") },
|
||||
// When running inside a vtmgr virtual console, __VT_OUT routes output
|
||||
// to the pane's text-plane buffer instead of the physical GPU (which
|
||||
// the compositor would otherwise overwrite). Outside a VT the hook is
|
||||
// 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: {
|
||||
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)
|
||||
else {
|
||||
// if the path starts with ".", look for the current directory
|
||||
// if the path starts with [A-Za-z0-9], look for the DOSDIR/includes
|
||||
// if the path starts with [A-Za-z0-9], search through INCLPATH
|
||||
if (path[0] == '.') return shell.require(shell.resolvePathInput(path).full + ".mjs")
|
||||
else return shell.require(`A:${_TVDOS.variables.DOSDIR}/include/${path}.mjs`)
|
||||
else {
|
||||
let inclDirs = (_TVDOS.variables.INCLPATH || "").split(';').filter(function(it) { return it.length > 0 })
|
||||
for (let i = 0; i < inclDirs.length; i++) {
|
||||
let dir = inclDirs[i]
|
||||
if (!dir.endsWith('\\') && !dir.endsWith('/')) dir += '\\'
|
||||
let candidate = `${CURRENT_DRIVE}:${dir}${path}.mjs`
|
||||
if (files.open(candidate).exists) return shell.require(candidate)
|
||||
}
|
||||
// no match found; defer to shell.require with the first entry so the error mentions a sensible path
|
||||
let firstDir = inclDirs[0] || `${_TVDOS.variables.DOSDIR}\\include`
|
||||
if (!firstDir.endsWith('\\') && !firstDir.endsWith('/')) firstDir += '\\'
|
||||
return shell.require(`${CURRENT_DRIVE}:${firstDir}${path}.mjs`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shell.execute = function(line) {
|
||||
shell.execute = function(line, nameOverride) {
|
||||
if (0 == line.size) return
|
||||
let parsedTokens = shell.parse(line) // echo, "hai", |, less
|
||||
let statements = [] // [[echo, "hai"], [less]]
|
||||
@@ -746,6 +830,8 @@ shell.execute = function(line) {
|
||||
let programCode = searchFile.sread()
|
||||
let extension = searchFile.extension.toUpperCase()
|
||||
|
||||
shell.runningScriptPaths.push(searchFile.fullPath)
|
||||
try {
|
||||
if ("BAT" == extension) {
|
||||
// parse and run as batch file
|
||||
var lines = programCode.split('\n').filter(function(it) { return it.length > 0 }) // this return is not shell's return!
|
||||
@@ -757,19 +843,28 @@ shell.execute = function(line) {
|
||||
// parse alias
|
||||
// $0: all arguments
|
||||
// $1..9: specific arguments
|
||||
// Tokens that contain whitespace or shell metacharacters must be re-quoted
|
||||
// before re-execution, otherwise the re-parse splits them on spaces.
|
||||
var quoteAliasArg = function(s) {
|
||||
if (s === undefined || s === null) return ""
|
||||
s = ''+s
|
||||
if (s.length === 0) return ""
|
||||
if (/[\s"|><&]/.test(s)) return '"' + s.replaceAll('"', '^"') + '"'
|
||||
return s
|
||||
}
|
||||
var lines = programCode.split('\n').filter(function(it) { return it.length > 0 }) // this return is not shell's return!
|
||||
lines.forEach(function(line) {
|
||||
var newLine = line
|
||||
|
||||
// replace $1..$9
|
||||
for (let j = 1; j < 9; j++) {
|
||||
newLine = newLine.replaceAll('$'+j, tokens[j])
|
||||
for (let j = 1; j <= 9; j++) {
|
||||
newLine = newLine.replaceAll('$'+j, quoteAliasArg(tokens[j]))
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -786,6 +881,10 @@ shell.execute = function(line) {
|
||||
errorlevel = 0 // reset the number
|
||||
|
||||
if (_G.shellProgramTitles === undefined) _G.shellProgramTitles = []
|
||||
if (nameOverride !== undefined) {
|
||||
tokens[0] = (''+nameOverride)
|
||||
cmd = tokens[0]
|
||||
}
|
||||
_G.shellProgramTitles.push(cmd.toUpperCase())
|
||||
sendLcdMsg(_G.shellProgramTitles[_G.shellProgramTitles.length - 1])
|
||||
//serial.println(_G.shellProgramTitles)
|
||||
@@ -825,6 +924,9 @@ shell.execute = function(line) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
shell.runningScriptPaths.pop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -884,6 +986,192 @@ Object.freeze(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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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 isFirstWord = (line.substring(0, wordStart).trim().length === 0)
|
||||
let hasPathSep = (word.indexOf('\\') >= 0 || word.indexOf('/') >= 0 || word.indexOf(':') >= 0)
|
||||
let candidates = (isFirstWord && !hasPathSep) ? _acCommandCandidates(word) : _acPathCandidates(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) {
|
||||
// only meaningful switches would be either -c or -k anyway
|
||||
@@ -928,23 +1216,133 @@ if (goInteractive) {
|
||||
print_prompt_text()
|
||||
|
||||
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) {
|
||||
let key = con.getch()
|
||||
|
||||
// printable chars
|
||||
if (key >= 32 && key <= 126) {
|
||||
var s = String.fromCharCode(key)
|
||||
cmdbuf += s
|
||||
print(s)
|
||||
let s = String.fromCharCode(key)
|
||||
let atEnd = (caret === cmdbuf.length)
|
||||
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
|
||||
else if (key === con.KEY_BACKSPACE && cmdbuf.length > 0) {
|
||||
cmdbuf = cmdbuf.substring(0, cmdbuf.length - 1)
|
||||
print(String.fromCharCode(key))
|
||||
// TAB: autocomplete (fancy mode + wintex only; otherwise a no-op)
|
||||
else if (key === con.KEY_TAB) {
|
||||
tryAutocomplete()
|
||||
}
|
||||
// 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
|
||||
else if (key === 10 || key === con.KEY_RETURN) {
|
||||
caret = cmdbuf.length; gotoCaret()
|
||||
println()
|
||||
|
||||
errorlevel = shell.execute(cmdbuf)
|
||||
@@ -960,32 +1358,17 @@ if (goInteractive) {
|
||||
// up arrow
|
||||
else if (key === con.KEY_UP && cmdHistory.length > 0 && cmdHistoryScroll < cmdHistory.length) {
|
||||
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)
|
||||
|
||||
setBuf(cmdHistory[cmdHistory.length - cmdHistoryScroll])
|
||||
}
|
||||
// down arrow
|
||||
else if (key === con.KEY_DOWN) {
|
||||
if (cmdHistoryScroll > 0) {
|
||||
// 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)
|
||||
|
||||
if (cmdHistoryScroll > 1) {
|
||||
cmdHistoryScroll -= 1
|
||||
setBuf(cmdHistory[cmdHistory.length - cmdHistoryScroll])
|
||||
}
|
||||
else {
|
||||
// back the cursor in order to type new cmd
|
||||
var x = 0
|
||||
for (x = 0; x < cmdbuf.length; x++) print(String.fromCharCode(8))
|
||||
cmdbuf = ""
|
||||
else if (cmdHistoryScroll === 1) {
|
||||
cmdHistoryScroll = 0
|
||||
setBuf("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1
assets/disk0/tvdos/bin/hop.alias
Normal file
1
assets/disk0/tvdos/bin/hop.alias
Normal file
@@ -0,0 +1 @@
|
||||
hopper $0
|
||||
@@ -1,5 +1,956 @@
|
||||
/**
|
||||
* Hopper is a package manager for TSVM
|
||||
* Hopper is a package manager for TVDOS
|
||||
* Created by CuriousTorvald on 2026-04-16
|
||||
*/
|
||||
|
||||
const SYSTEM_PACKEAGE_DEF_DIR = "A:/tvdos/hopper"
|
||||
const USER_BASE_DIR = "A:/hopper"
|
||||
const USER_PACKAGE_DEF_DIR = `${USER_BASE_DIR}/manifests`
|
||||
const USER_PACKAGE_BIN_DIR = `${USER_BASE_DIR}/bin`
|
||||
const USER_PACKAGE_INCLUDE_DIR = `${USER_BASE_DIR}/include`
|
||||
const MANIFEST_EXT = "hop.per"
|
||||
const MIRROR_LIST_PATH = `${SYSTEM_PACKEAGE_DEF_DIR}/mirrors.list`
|
||||
|
||||
const net = require("net")
|
||||
|
||||
// SYNOPSIS
|
||||
// hopper {search,se} [--provides, --requires, --description, --author] query
|
||||
//// default searches from ProperName
|
||||
// hopper {install,in} query [-v version]
|
||||
// hopper {remove,rm} query
|
||||
|
||||
// ============================================================
|
||||
// Manifest parsing
|
||||
// ============================================================
|
||||
|
||||
function splitList(s) {
|
||||
if (!s) return []
|
||||
return s.split(";").map(it => it.trim()).filter(it => it.length > 0)
|
||||
}
|
||||
|
||||
function parseManifest(text) {
|
||||
const m = {}
|
||||
text.split("\n").forEach(rawLine => {
|
||||
const line = rawLine.replace(/\r$/, "")
|
||||
if (line.length === 0) return
|
||||
const idx = line.indexOf(":")
|
||||
if (idx < 0) return
|
||||
const key = line.substring(0, idx).trim()
|
||||
const value = line.substring(idx + 1).trim()
|
||||
m[key] = value
|
||||
})
|
||||
return m
|
||||
}
|
||||
|
||||
function readManifestFile(path) {
|
||||
const f = files.open(path)
|
||||
if (!f.exists || f.isDirectory) return undefined
|
||||
const m = parseManifest(f.sread())
|
||||
m._manifestPath = path
|
||||
return m
|
||||
}
|
||||
|
||||
function _listManifestsFrom(dirPath, origin) {
|
||||
const dir = files.open(dirPath)
|
||||
if (!dir.exists || !dir.isDirectory) return []
|
||||
const out = []
|
||||
dir.list().forEach(entry => {
|
||||
if (entry.isDirectory) return
|
||||
if (!entry.name.toLowerCase().endsWith(MANIFEST_EXT)) return
|
||||
const m = readManifestFile(entry.fullPath)
|
||||
if (m !== undefined) {
|
||||
m._origin = origin
|
||||
out.push(m)
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// System packages (shipped with TVDOS) live in SYSTEM_PACKAGE_DEF_DIR
|
||||
// and are read-only as far as hopper is concerned. User packages,
|
||||
// installed by `hopper install`, live under USER_PACKAGE_DEF_DIR. The
|
||||
// resolver treats both as "installed", but the install/remove paths
|
||||
// refuse to modify anything tagged `_origin === "system"`.
|
||||
function listInstalledManifests() {
|
||||
return _listManifestsFrom(SYSTEM_PACKEAGE_DEF_DIR, "system")
|
||||
.concat(_listManifestsFrom(USER_PACKAGE_DEF_DIR, "user"))
|
||||
}
|
||||
|
||||
function findInstalledManifest(name) {
|
||||
// Prefer user-installed copy when a system package with the same name
|
||||
// also exists -- but that combination is normally refused at install.
|
||||
const userDirect = `${USER_PACKAGE_DEF_DIR}/${name}.${MANIFEST_EXT}`
|
||||
let m = readManifestFile(userDirect)
|
||||
if (m !== undefined) { m._origin = "user"; return m }
|
||||
|
||||
const sysDirect = `${SYSTEM_PACKEAGE_DEF_DIR}/${name}.${MANIFEST_EXT}`
|
||||
m = readManifestFile(sysDirect)
|
||||
if (m !== undefined) { m._origin = "system"; return m }
|
||||
|
||||
const all = listInstalledManifests()
|
||||
for (let i = 0; i < all.length; i++) {
|
||||
if ((all[i].HopperPackageName || "") === name) return all[i]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Yes/no prompt. Empty input falls back to `defaultYes`.
|
||||
function confirm(prompt, defaultYes) {
|
||||
const hint = defaultYes ? "[Y/n]" : "[y/N]"
|
||||
print(`${prompt} ${hint} `)
|
||||
const ans = (read() || "").trim().toLowerCase()
|
||||
if (ans === "") return !!defaultYes
|
||||
return ans === "y" || ans === "yes"
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Install layout helpers
|
||||
// ============================================================
|
||||
//
|
||||
// User-installed packages live under `A:/hopper/`. Files are routed
|
||||
// by extension: `.mjs` includes go under `include/`, everything else
|
||||
// (`.js`, `.alias`, `.lfs`, data blobs, ...) lands in `bin/`. The
|
||||
// downloaded manifest is saved under `manifests/` with a
|
||||
// `SystemPackagePath` field appended that lists the resulting paths.
|
||||
|
||||
// Strip query/fragment and take the last `/`-separated component of `url`.
|
||||
function urlBasename(url) {
|
||||
let s = String(url || "")
|
||||
const qm = s.indexOf("?"); if (qm >= 0) s = s.substring(0, qm)
|
||||
const hash = s.indexOf("#"); if (hash >= 0) s = s.substring(0, hash)
|
||||
const slash = s.lastIndexOf("/")
|
||||
return (slash < 0) ? s : s.substring(slash + 1)
|
||||
}
|
||||
|
||||
function routeForBasename(name) {
|
||||
return (String(name || "").toLowerCase().endsWith(".mjs"))
|
||||
? USER_PACKAGE_INCLUDE_DIR
|
||||
: USER_PACKAGE_BIN_DIR
|
||||
}
|
||||
|
||||
// Convert a USER_BASE_DIR-relative absolute path ("A:/hopper/bin/foo.js")
|
||||
// into its declarable form ("/hopper/bin/foo.js"), matching the
|
||||
// `SystemPackagePath` convention used by the system manifests.
|
||||
function declarablePath(absPath) {
|
||||
let p = String(absPath || "").replace(/\\/g, "/")
|
||||
if (/^[A-Za-z]:/.test(p)) p = p.substring(2)
|
||||
return p
|
||||
}
|
||||
|
||||
// Parse PackageFileList (semicolon-separated full URLs) into a list of
|
||||
// download descriptors: { url, basename, localPath }.
|
||||
function parsePackageFileList(s) {
|
||||
const out = []
|
||||
splitList(s || "").forEach(url => {
|
||||
const base = urlBasename(url)
|
||||
if (base.length === 0) return
|
||||
const dir = routeForBasename(base)
|
||||
out.push({ url: url, basename: base, localPath: `${dir}/${base}` })
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
function ensureUserDirs() {
|
||||
[USER_BASE_DIR, USER_PACKAGE_BIN_DIR, USER_PACKAGE_INCLUDE_DIR, USER_PACKAGE_DEF_DIR].forEach(p => {
|
||||
const d = files.open(p)
|
||||
if (!d.exists) d.mkDir()
|
||||
})
|
||||
}
|
||||
|
||||
// Re-emit a parsed manifest, preserving insertion order, dropping
|
||||
// internal `_*` keys, and replacing any pre-existing SystemPackagePath
|
||||
// with the locally-computed one so the field always reflects what is
|
||||
// actually on disk.
|
||||
function serializeManifest(manifestObj, installedPathStr) {
|
||||
const lines = []
|
||||
Object.keys(manifestObj).forEach(k => {
|
||||
if (k.length > 0 && k[0] === "_") return
|
||||
if (k === "SystemPackagePath") return
|
||||
lines.push(`${k}:${manifestObj[k]}`)
|
||||
})
|
||||
lines.push(`SystemPackagePath:${installedPathStr}`)
|
||||
return lines.join("\n") + "\n"
|
||||
}
|
||||
|
||||
// Delete every file declared in `manifest.SystemPackagePath` plus the
|
||||
// manifest file itself. Wildcards are expanded via `expandSystemPath`.
|
||||
function deleteInstalledFiles(manifest) {
|
||||
const removed = []
|
||||
splitList(manifest.SystemPackagePath || "").forEach(p => {
|
||||
expandSystemPath(p).forEach(abs => {
|
||||
const fd = files.open(abs)
|
||||
if (!fd.exists) return
|
||||
try { fd.remove(); removed.push(abs) }
|
||||
catch (e) { printerrln(` ! failed to remove ${abs}: ${e}`) }
|
||||
})
|
||||
})
|
||||
if (manifest._manifestPath) {
|
||||
const mfd = files.open(manifest._manifestPath)
|
||||
if (mfd.exists) {
|
||||
try { mfd.remove(); removed.push(manifest._manifestPath) }
|
||||
catch (e) { printerrln(` ! failed to remove ${manifest._manifestPath}: ${e}`) }
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SemVer (strict X.Y.Z) and constraint matching
|
||||
// ============================================================
|
||||
//
|
||||
// Versions are strict Semantic Versioning: three non-negative integer
|
||||
// components MAJOR.MINOR.PATCH. No pre-release / build metadata.
|
||||
//
|
||||
// Constraint grammar (intentionally small, expandable later):
|
||||
// * any version
|
||||
// X.* major X, any minor/patch
|
||||
// X.Y.* major X, minor Y, any patch
|
||||
// X.Y.Z exact
|
||||
// ^X.Y.Z >= X.Y.Z and < (X+1).0.0 (major-compatible)
|
||||
// ~X.Y.Z >= X.Y.Z and < X.(Y+1).0 (minor-compatible)
|
||||
// >=X.Y.Z / >X.Y.Z / <=X.Y.Z / <X.Y.Z / =X.Y.Z
|
||||
//
|
||||
// Multiple comma-separated constraints are AND-ed: "^1.2.0,<1.5.0".
|
||||
|
||||
function parseVersion(v) {
|
||||
const m = String(v || "0.0.0").trim().match(/^(\d+)\.(\d+)\.(\d+)$/)
|
||||
if (!m) return [0, 0, 0]
|
||||
return [parseInt(m[1], 10), parseInt(m[2], 10), parseInt(m[3], 10)]
|
||||
}
|
||||
|
||||
function compareVersion(a, b) {
|
||||
const A = parseVersion(a), B = parseVersion(b)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (A[i] !== B[i]) return (A[i] < B[i]) ? -1 : 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function _matchSingleConstraint(version, c) {
|
||||
c = c.trim()
|
||||
if (c === "" || c === "*") return true
|
||||
|
||||
// Operator form: ^, ~, >=, <=, >, <, =
|
||||
let opMatch = c.match(/^(\^|~|>=|<=|>|<|=)\s*(\d+\.\d+\.\d+)$/)
|
||||
if (opMatch) {
|
||||
const op = opMatch[1]
|
||||
const target = opMatch[2]
|
||||
const cmp = compareVersion(version, target)
|
||||
const [tM, tm] = parseVersion(target)
|
||||
switch (op) {
|
||||
case "=": return cmp === 0
|
||||
case ">": return cmp > 0
|
||||
case ">=": return cmp >= 0
|
||||
case "<": return cmp < 0
|
||||
case "<=": return cmp <= 0
|
||||
case "^": return cmp >= 0 && compareVersion(version, `${tM + 1}.0.0`) < 0
|
||||
case "~": return cmp >= 0 && compareVersion(version, `${tM}.${tm + 1}.0`) < 0
|
||||
}
|
||||
}
|
||||
|
||||
// Wildcard form: X.*, X.Y.*, X.x, X.Y.x, or exact X.Y.Z
|
||||
const parts = c.split(".")
|
||||
const vparts = parseVersion(version)
|
||||
for (let i = 0; i < parts.length && i < 3; i++) {
|
||||
if (parts[i] === "*" || parts[i] === "x" || parts[i] === "X") return true
|
||||
const expected = parseInt(parts[i], 10)
|
||||
if (isNaN(expected) || vparts[i] !== expected) return false
|
||||
}
|
||||
// All listed parts matched literally; remaining parts (if any) must be 0
|
||||
for (let i = parts.length; i < 3; i++) {
|
||||
if (vparts[i] !== 0) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function satisfies(version, constraint) {
|
||||
if (!constraint) return true
|
||||
return constraint.split(",").every(c => _matchSingleConstraint(version, c))
|
||||
}
|
||||
|
||||
function parseRequires(s) {
|
||||
const out = []
|
||||
splitList(s || "").forEach(entry => {
|
||||
// "<name>" or "<name> <constraint>"
|
||||
const idx = entry.search(/\s+/)
|
||||
if (idx < 0) {
|
||||
out.push({ name: entry, constraint: "*" })
|
||||
} else {
|
||||
out.push({ name: entry.substring(0, idx), constraint: entry.substring(idx + 1).trim() })
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// HopperProvides entries are "<name>" or "<name> <version>". A bare name
|
||||
// falls back to the package's own HopperPackageVersion — the same idea
|
||||
// as RPM's `Provides: aalib = 1.2.0` (where the package's real name and
|
||||
// version may differ from the virtual identity it exposes).
|
||||
function parseProvides(s, fallbackVersion) {
|
||||
const out = []
|
||||
splitList(s || "").forEach(entry => {
|
||||
const idx = entry.search(/\s+/)
|
||||
if (idx < 0) {
|
||||
out.push({ name: entry, version: fallbackVersion })
|
||||
} else {
|
||||
const v = entry.substring(idx + 1).trim()
|
||||
out.push({ name: entry.substring(0, idx), version: v || fallbackVersion })
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Look up the version a candidate exposes for `name`. If `name` matches
|
||||
// the package's own name (or isn't declared in HopperProvides at all),
|
||||
// returns the package's own version.
|
||||
function providedVersionOf(candidate, name) {
|
||||
if (candidate.provides) {
|
||||
for (let i = 0; i < candidate.provides.length; i++) {
|
||||
if (candidate.provides[i].name === name) return candidate.provides[i].version
|
||||
}
|
||||
}
|
||||
return candidate.version
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Candidate index (installed + upstream)
|
||||
// ============================================================
|
||||
|
||||
function _manifestToCandidate(m, source) {
|
||||
const name = m.HopperPackageName || ""
|
||||
const version = m.HopperPackageVersion || "0.0.0"
|
||||
const provides = parseProvides(m.HopperProvides || "", version)
|
||||
// Every package implicitly provides itself at its own version. Only
|
||||
// synthesise this when the manifest didn't declare it explicitly.
|
||||
if (name && !provides.some(p => p.name === name)) {
|
||||
provides.unshift({ name: name, version: version })
|
||||
}
|
||||
return {
|
||||
name: name,
|
||||
version: version,
|
||||
requires: parseRequires(m.HopperRequires || ""),
|
||||
provides: provides,
|
||||
source: source, // "installed" | "upstream"
|
||||
manifest: m
|
||||
}
|
||||
}
|
||||
|
||||
// Returns map: packageName -> array<Candidate>
|
||||
function buildCandidateIndex() {
|
||||
const idx = new Map()
|
||||
function add(c) {
|
||||
if (!idx.has(c.name)) idx.set(c.name, [])
|
||||
// De-dupe (name+version+source)
|
||||
const arr = idx.get(c.name)
|
||||
if (arr.some(x => x.version === c.version && x.source === c.source)) return
|
||||
arr.push(c)
|
||||
}
|
||||
|
||||
listInstalledManifests().forEach(m => add(_manifestToCandidate(m, "installed")))
|
||||
fetchRemoteCandidates().forEach(m => add(_manifestToCandidate(m, "upstream")))
|
||||
|
||||
return idx
|
||||
}
|
||||
|
||||
// Anything that satisfies a requirement on `name`: a package whose own
|
||||
// HopperPackageName matches OR whose HopperProvides declares `name`.
|
||||
// Each candidate now carries `provides` as {name, version} pairs; the
|
||||
// package's own (name, version) is always present (see
|
||||
// _manifestToCandidate), so a single pass over `provides` is enough.
|
||||
function findProviders(idx, name) {
|
||||
const out = []
|
||||
const seen = new Set()
|
||||
idx.forEach(candidates => {
|
||||
candidates.forEach(c => {
|
||||
if (seen.has(c)) return
|
||||
if (c.provides.some(p => p.name === name)) {
|
||||
out.push(c)
|
||||
seen.add(c)
|
||||
}
|
||||
})
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Sort: installed first (no churn), then highest version, then upstream order.
|
||||
function sortCandidates(cands) {
|
||||
return cands.slice().sort((a, b) => {
|
||||
if (a.source !== b.source) return (a.source === "installed") ? -1 : 1
|
||||
return -compareVersion(a.version, b.version)
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Resolver (snapshot-based backtracking; precursor to a SAT solver)
|
||||
// ============================================================
|
||||
//
|
||||
// State: chosen :: Map<packageName, Candidate>
|
||||
// At every choice point we snapshot the whole map so that backtracking
|
||||
// also undoes any transitive picks. The candidate ordering encodes the
|
||||
// preference policy:
|
||||
//
|
||||
// 1. Keep installed if it satisfies the constraint.
|
||||
// 2. Otherwise pick the newest upstream version that satisfies.
|
||||
// 3. If newer versions cause downstream conflicts, walk older versions
|
||||
// (downgrade) until either something fits or candidates are exhausted.
|
||||
//
|
||||
// The structure is intentionally close to DPLL: each "decision" is the
|
||||
// candidate we assign to a variable, and "unit propagation" is the
|
||||
// recursive resolve() call over each requirement. Replacing this with
|
||||
// clause learning / a watched-literals scheme later would be local.
|
||||
|
||||
function resolveAll(idx, requirements) {
|
||||
const chosen = new Map()
|
||||
const issues = []
|
||||
|
||||
function snapshot() { return new Map(chosen) }
|
||||
function restore(snap) { chosen.clear(); snap.forEach((v, k) => chosen.set(k, v)) }
|
||||
|
||||
function _resolve(reqName, constraint, trail) {
|
||||
const existing = chosen.get(reqName)
|
||||
if (existing !== undefined) {
|
||||
const v = providedVersionOf(existing, reqName)
|
||||
return satisfies(v, constraint)
|
||||
? { ok: true }
|
||||
: { ok: false, reason: `${reqName} pinned to ${v}, but ${trail.join(" -> ")} requires ${constraint}` }
|
||||
}
|
||||
|
||||
const providers = findProviders(idx, reqName)
|
||||
if (providers.length === 0) {
|
||||
return { ok: false, reason: `no package provides "${reqName}" (required by ${trail.join(" -> ") || "<root>"})` }
|
||||
}
|
||||
// Satisfaction checks the virtual version the candidate exposes
|
||||
// for `reqName` (HopperProvides), not necessarily the package's
|
||||
// own HopperPackageVersion.
|
||||
const matching = sortCandidates(providers.filter(c => satisfies(providedVersionOf(c, reqName), constraint)))
|
||||
if (matching.length === 0) {
|
||||
const versions = providers.map(p => `${providedVersionOf(p, reqName)}[${p.source}]`).join(", ")
|
||||
return { ok: false, reason: `no version of "${reqName}" satisfies ${constraint} (available: ${versions})` }
|
||||
}
|
||||
|
||||
let lastReason = null
|
||||
for (let i = 0; i < matching.length; i++) {
|
||||
const cand = matching[i]
|
||||
const snap = snapshot()
|
||||
chosen.set(cand.name, cand)
|
||||
|
||||
let allOk = true
|
||||
const subTrail = trail.concat([`${cand.name}@${cand.version}`])
|
||||
for (let j = 0; j < cand.requires.length; j++) {
|
||||
const req = cand.requires[j]
|
||||
const r = _resolve(req.name, req.constraint, subTrail)
|
||||
if (!r.ok) {
|
||||
allOk = false
|
||||
lastReason = r.reason
|
||||
break
|
||||
}
|
||||
}
|
||||
if (allOk) return { ok: true }
|
||||
restore(snap)
|
||||
}
|
||||
|
||||
return { ok: false, reason: lastReason || `no working candidate for "${reqName}"` }
|
||||
}
|
||||
|
||||
requirements.forEach(req => {
|
||||
const r = _resolve(req.name, req.constraint, [])
|
||||
if (!r.ok) issues.push(r.reason)
|
||||
})
|
||||
|
||||
return { chosen, issues }
|
||||
}
|
||||
|
||||
// Compare resolved assignment against currently-installed state.
|
||||
function classifyPlan(idx, chosen) {
|
||||
const installedByName = new Map()
|
||||
listInstalledManifests().forEach(m => installedByName.set(m.HopperPackageName, m))
|
||||
|
||||
const actions = []
|
||||
chosen.forEach((cand, name) => {
|
||||
const inst = installedByName.get(name)
|
||||
if (cand.source === "installed") {
|
||||
actions.push({ action: "keep", name, version: cand.version })
|
||||
}
|
||||
else if (inst === undefined) {
|
||||
actions.push({ action: "install", name, version: cand.version })
|
||||
}
|
||||
else {
|
||||
const cmp = compareVersion(cand.version, inst.HopperPackageVersion)
|
||||
if (cmp > 0) actions.push({ action: "upgrade", name, from: inst.HopperPackageVersion, to: cand.version })
|
||||
else if (cmp < 0) actions.push({ action: "downgrade", name, from: inst.HopperPackageVersion, to: cand.version })
|
||||
else actions.push({ action: "reinstall", name, version: cand.version })
|
||||
}
|
||||
})
|
||||
return actions
|
||||
}
|
||||
|
||||
function printPlan(actions, target) {
|
||||
const changing = actions.filter(a => a.action !== "keep")
|
||||
if (changing.length === 0) {
|
||||
println(`Nothing to do: ${target} is already installed and satisfied.`)
|
||||
return
|
||||
}
|
||||
println("Plan:")
|
||||
changing.forEach(a => {
|
||||
switch (a.action) {
|
||||
case "install": println(` + install ${a.name} ${a.version}`); break
|
||||
case "upgrade": println(` ^ upgrade ${a.name} ${a.from} -> ${a.to}`); break
|
||||
case "downgrade": println(` v downgrade ${a.name} ${a.from} -> ${a.to}`); break
|
||||
case "reinstall": println(` = reinstall ${a.name} ${a.version}`); break
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Remote mirrors
|
||||
// ============================================================
|
||||
//
|
||||
// `mirrors.list` lives next to the installed package manifests.
|
||||
// Each non-empty, non-`#` line is the URL prefix of a Hopper mirror.
|
||||
// The mirror MUST expose `<prefix>mirror_manifest` (key:value pairs
|
||||
// describing the mirror) and `<prefix>filelist` (CSV with rows of
|
||||
// `packagename,version,hoppermanifest-filename`).
|
||||
//
|
||||
// Trailing slash on the prefix is optional and will be added if missing.
|
||||
|
||||
function loadMirrorList() {
|
||||
const f = files.open(MIRROR_LIST_PATH)
|
||||
if (!f.exists || f.isDirectory) return []
|
||||
return f.sread().split("\n")
|
||||
.map(line => line.replace(/\r$/, "").trim())
|
||||
.filter(line => line.length > 0 && line[0] !== "#")
|
||||
.map(line => line.endsWith("/") ? line : (line + "/"))
|
||||
}
|
||||
|
||||
function parseFileList(text) {
|
||||
const out = []
|
||||
text.split("\n").forEach(raw => {
|
||||
const line = raw.replace(/\r$/, "").trim()
|
||||
if (line.length === 0 || line[0] === "#") return
|
||||
const parts = line.split(",")
|
||||
if (parts.length < 3) return
|
||||
out.push({
|
||||
name: parts[0].trim(),
|
||||
version: parts[1].trim(),
|
||||
file: parts[2].trim(),
|
||||
})
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
function fetchManifestsFromMirror(prefix) {
|
||||
const mfText = net.fetchText(prefix + "mirror_manifest")
|
||||
if (mfText === null) {
|
||||
printerrln(` ! could not reach mirror: ${prefix}`)
|
||||
return []
|
||||
}
|
||||
const mirror = parseManifest(mfText)
|
||||
const mirrorName = mirror.HopperMirrorName || prefix
|
||||
|
||||
const flText = net.fetchText(prefix + "filelist")
|
||||
if (flText === null) {
|
||||
printerrln(` ! mirror "${mirrorName}" has no filelist`)
|
||||
return []
|
||||
}
|
||||
|
||||
const out = []
|
||||
parseFileList(flText).forEach(entry => {
|
||||
const manifestText = net.fetchText(prefix + entry.file)
|
||||
if (manifestText === null) {
|
||||
printerrln(` ! mirror "${mirrorName}" missing ${entry.file}`)
|
||||
return
|
||||
}
|
||||
const m = parseManifest(manifestText)
|
||||
m._mirrorName = mirrorName
|
||||
m._mirrorPrefix = prefix
|
||||
m._manifestUrl = prefix + entry.file
|
||||
out.push(m)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Per-invocation memoisation. Search and install both pull the same
|
||||
// data; we only want to hit the network once per `hopper ...` call.
|
||||
let _remoteCache = null
|
||||
|
||||
function fetchRemoteCandidates() {
|
||||
if (_remoteCache !== null) return _remoteCache
|
||||
|
||||
const mirrors = loadMirrorList()
|
||||
if (mirrors.length === 0) {
|
||||
_remoteCache = []
|
||||
return _remoteCache
|
||||
}
|
||||
|
||||
if (!net.isAvailable()) {
|
||||
printerrln("Warning: no HTTP modem attached; remote mirrors will be skipped.")
|
||||
_remoteCache = []
|
||||
return _remoteCache
|
||||
}
|
||||
|
||||
const out = []
|
||||
mirrors.forEach(prefix => {
|
||||
fetchManifestsFromMirror(prefix).forEach(m => out.push(m))
|
||||
})
|
||||
_remoteCache = out
|
||||
return _remoteCache
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Search
|
||||
// ============================================================
|
||||
|
||||
function fieldCandidates(manifest, field) {
|
||||
switch (field) {
|
||||
case "provides": return splitList(manifest.HopperProvides || "")
|
||||
case "requires": return splitList(manifest.HopperRequires || "")
|
||||
case "description": return [manifest.ProperDescription || ""]
|
||||
case "author": return [manifest.ProperAuthor || ""]
|
||||
default: return [manifest.ProperName || "", manifest.HopperPackageName || ""]
|
||||
}
|
||||
}
|
||||
|
||||
function matchesQuery(manifest, field, query) {
|
||||
const q = query.toLowerCase()
|
||||
return fieldCandidates(manifest, field).some(c => c.toLowerCase().indexOf(q) >= 0)
|
||||
}
|
||||
|
||||
function printSearchResult(m, origin) {
|
||||
const name = m.ProperName || m.HopperPackageName || "(unnamed)"
|
||||
const ver = m.HopperPackageVersion || "?"
|
||||
println(` [${origin}] ${name} -- ${m.HopperPackageName} ${ver}`)
|
||||
if (m.ProperDescription) println(` ${m.ProperDescription}`)
|
||||
}
|
||||
|
||||
function cmdSearch(args) {
|
||||
let field = "name"
|
||||
let query = undefined
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i]
|
||||
if (a === "--provides") field = "provides"
|
||||
else if (a === "--requires") field = "requires"
|
||||
else if (a === "--description") field = "description"
|
||||
else if (a === "--author") field = "author"
|
||||
else if (a.startsWith("--")) { printerrln(`Unknown option: ${a}`); return 1 }
|
||||
else query = a
|
||||
}
|
||||
if (query === undefined) {
|
||||
printerrln("Usage: hopper search [--provides|--requires|--description|--author] <query>")
|
||||
return 1
|
||||
}
|
||||
|
||||
println(`Searching installed packages in ${SYSTEM_PACKEAGE_DEF_DIR} ...`)
|
||||
const sysHits = listInstalledManifests().filter(m => matchesQuery(m, field, query))
|
||||
if (sysHits.length === 0) println(" (no matches)")
|
||||
else sysHits.forEach(m => printSearchResult(m, "installed"))
|
||||
|
||||
println("")
|
||||
println("Searching remote mirrors ...")
|
||||
const remote = fetchRemoteCandidates()
|
||||
if (remote.length === 0) {
|
||||
println(" (no mirrors configured or reachable)")
|
||||
}
|
||||
else {
|
||||
const netHits = remote.filter(m => matchesQuery(m, field, query))
|
||||
if (netHits.length === 0) println(" (no matches)")
|
||||
else netHits.forEach(m => printSearchResult(m, m._mirrorName || "remote"))
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Install
|
||||
// ============================================================
|
||||
//
|
||||
// Each upstream manifest declares its payload via `PackageFileList`,
|
||||
// a semicolon-separated list of full URLs. Hopper fetches each URL and
|
||||
// drops the result in /hopper/bin (default) or /hopper/include (.mjs).
|
||||
// The locally-saved manifest gets a `SystemPackagePath` field appended
|
||||
// listing the resulting absolute paths, which is what `cmdRemove` later
|
||||
// walks to clean up.
|
||||
|
||||
function _installOne(action, candidate) {
|
||||
const m = candidate.manifest
|
||||
const files_ = parsePackageFileList(m.PackageFileList)
|
||||
if (files_.length === 0) {
|
||||
printerrln(` ! ${candidate.name}: upstream manifest has no PackageFileList; cannot install`)
|
||||
return false
|
||||
}
|
||||
|
||||
// Fetch first, write second: a single 404 should not leave a
|
||||
// half-installed package behind.
|
||||
const fetched = []
|
||||
for (let i = 0; i < files_.length; i++) {
|
||||
const f = files_[i]
|
||||
println(` fetch ${f.url}`)
|
||||
const body = net.fetchText(f.url)
|
||||
if (body === null || body === undefined) {
|
||||
printerrln(` ! failed to fetch ${f.url}`)
|
||||
return false
|
||||
}
|
||||
fetched.push({ entry: f, body: body })
|
||||
}
|
||||
|
||||
// If we are replacing an existing user-installed copy, remove its
|
||||
// old files first so a renamed payload doesn't leave orphans.
|
||||
if (action !== "install") {
|
||||
const oldManifestPath = `${USER_PACKAGE_DEF_DIR}/${candidate.name}.${MANIFEST_EXT}`
|
||||
const old = readManifestFile(oldManifestPath)
|
||||
if (old !== undefined) {
|
||||
splitList(old.SystemPackagePath || "").forEach(p => {
|
||||
expandSystemPath(p).forEach(abs => {
|
||||
const fd = files.open(abs)
|
||||
if (fd.exists) {
|
||||
try { fd.remove() }
|
||||
catch (e) { printerrln(` ! could not remove old ${abs}: ${e}`) }
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Write payload files.
|
||||
fetched.forEach(item => {
|
||||
const fd = files.open(item.entry.localPath)
|
||||
if (!fd.exists) fd.mkFile()
|
||||
fd.swrite(item.body)
|
||||
println(` write ${item.entry.localPath}`)
|
||||
})
|
||||
|
||||
// Save the manifest with SystemPackagePath appended.
|
||||
const sysPath = fetched.map(item => declarablePath(item.entry.localPath)).join(";")
|
||||
const manifestPath = `${USER_PACKAGE_DEF_DIR}/${candidate.name}.${MANIFEST_EXT}`
|
||||
const mfd = files.open(manifestPath)
|
||||
if (!mfd.exists) mfd.mkFile()
|
||||
mfd.swrite(serializeManifest(m, sysPath))
|
||||
println(` write ${manifestPath}`)
|
||||
return true
|
||||
}
|
||||
|
||||
function cmdInstall(args) {
|
||||
let query = undefined
|
||||
let version = undefined
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "-v") { version = args[i + 1]; i++ }
|
||||
else if (args[i].startsWith("--")) { printerrln(`Unknown option: ${args[i]}`); return 1 }
|
||||
else query = args[i]
|
||||
}
|
||||
if (query === undefined) {
|
||||
printerrln("Usage: hopper install <package> [-v <version>]")
|
||||
return 1
|
||||
}
|
||||
|
||||
const targetConstraint = version || "*"
|
||||
const verSuffix = (targetConstraint !== "*") ? ` (${targetConstraint})` : ""
|
||||
println(`Resolving ${query}${verSuffix} ...`)
|
||||
|
||||
const idx = buildCandidateIndex()
|
||||
|
||||
// Sanity check: target must exist in the index (installed or upstream).
|
||||
if (findProviders(idx, query).length === 0) {
|
||||
printerrln(`Error: package "${query}" not found (not on upstream, not installed).`)
|
||||
return 4
|
||||
}
|
||||
|
||||
// Seed order matters: the target goes FIRST so its (possibly tight)
|
||||
// constraints can drive upgrades of dependencies. The installed-set
|
||||
// requirements follow at "*" so the resolver still has to keep them
|
||||
// alive (preferring installed candidates when their version still fits,
|
||||
// otherwise upgrading or downgrading them).
|
||||
const seed = [{ name: query, constraint: targetConstraint }]
|
||||
listInstalledManifests().forEach(m => {
|
||||
if (m.HopperPackageName === query) return
|
||||
seed.push({ name: m.HopperPackageName, constraint: "*" })
|
||||
})
|
||||
|
||||
const { chosen, issues } = resolveAll(idx, seed)
|
||||
if (issues.length > 0) {
|
||||
printerrln("Resolution failed:")
|
||||
issues.forEach(reason => printerrln(` - ${reason}`))
|
||||
printerrln("")
|
||||
printerrln("No solution found -- not installable.")
|
||||
return 3
|
||||
}
|
||||
|
||||
const plan = classifyPlan(idx, chosen)
|
||||
printPlan(plan, query)
|
||||
|
||||
const changing = plan.filter(a => a.action !== "keep")
|
||||
if (changing.length === 0) return 0
|
||||
|
||||
// Pre-flight: refuse to clobber system packages, and require every
|
||||
// upstream candidate to actually carry a payload list.
|
||||
const blockers = []
|
||||
changing.forEach(a => {
|
||||
const cand = chosen.get(a.name)
|
||||
const inst = findInstalledManifest(a.name)
|
||||
if (inst && inst._origin === "system") {
|
||||
blockers.push(`${a.name}: cannot ${a.action} -- a system package with that name is already installed`)
|
||||
}
|
||||
if (cand && cand.source === "upstream" && !(cand.manifest.PackageFileList && cand.manifest.PackageFileList.length > 0)) {
|
||||
blockers.push(`${a.name}: upstream manifest declares no PackageFileList`)
|
||||
}
|
||||
})
|
||||
if (blockers.length > 0) {
|
||||
printerrln("Cannot proceed:")
|
||||
blockers.forEach(b => printerrln(` - ${b}`))
|
||||
return 5
|
||||
}
|
||||
|
||||
if (!net.isAvailable()) {
|
||||
printerrln("No HTTP modem attached; cannot fetch package files.")
|
||||
return 6
|
||||
}
|
||||
|
||||
println("")
|
||||
if (!confirm("Proceed with installation?", true)) {
|
||||
println("Aborted.")
|
||||
return 0
|
||||
}
|
||||
|
||||
ensureUserDirs()
|
||||
|
||||
let failed = 0
|
||||
for (let i = 0; i < changing.length; i++) {
|
||||
const a = changing[i]
|
||||
const cand = chosen.get(a.name)
|
||||
if (a.action === "install" || a.action === "reinstall") {
|
||||
println(`${a.action} ${a.name} ${a.version}`)
|
||||
} else {
|
||||
println(`${a.action} ${a.name} ${a.from} -> ${a.to}`)
|
||||
}
|
||||
if (!_installOne(a.action, cand)) {
|
||||
failed++
|
||||
printerrln(` ! ${a.name}: aborted`)
|
||||
break
|
||||
}
|
||||
}
|
||||
if (failed > 0) {
|
||||
printerrln(`${failed} package(s) failed to install.`)
|
||||
return 7
|
||||
}
|
||||
|
||||
println("Done.")
|
||||
return 0
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Remove
|
||||
// ============================================================
|
||||
|
||||
// Convert a SystemPackagePath entry (e.g. "/tvdos/bin/taut*") into a
|
||||
// concrete list of files on the A: drive. Supports a simple '*' wildcard
|
||||
// in the filename component.
|
||||
function expandSystemPath(pattern) {
|
||||
const sysDrive = "A:"
|
||||
|
||||
if (pattern.indexOf("*") < 0) {
|
||||
return [`${sysDrive}${pattern}`]
|
||||
}
|
||||
|
||||
const fwd = pattern.lastIndexOf("/")
|
||||
const bck = pattern.lastIndexOf("\\")
|
||||
const lastSep = Math.max(fwd, bck)
|
||||
const dirPart = (lastSep < 0) ? "" : pattern.substring(0, lastSep)
|
||||
const namePart = (lastSep < 0) ? pattern : pattern.substring(lastSep + 1)
|
||||
|
||||
const dir = files.open(`${sysDrive}${dirPart}/`)
|
||||
if (!dir.exists || !dir.isDirectory) return []
|
||||
|
||||
const escaped = namePart.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*")
|
||||
const re = new RegExp(`^${escaped}$`, "i")
|
||||
|
||||
const out = []
|
||||
dir.list().forEach(entry => {
|
||||
if (entry.isDirectory) return
|
||||
if (re.test(entry.name)) out.push(entry.fullPath)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
function cmdRemove(args) {
|
||||
const query = args[0]
|
||||
if (query === undefined) {
|
||||
printerrln("Usage: hopper remove <package>")
|
||||
return 1
|
||||
}
|
||||
|
||||
const m = findInstalledManifest(query)
|
||||
if (m === undefined) {
|
||||
printerrln(`Package not installed: ${query}`)
|
||||
return 2
|
||||
}
|
||||
if (m._origin === "system") {
|
||||
printerrln(`Cannot remove ${query}: it is a system package.`)
|
||||
return 6
|
||||
}
|
||||
|
||||
const name = m.ProperName || m.HopperPackageName || query
|
||||
const ver = m.HopperPackageVersion || "?"
|
||||
println(`Preparing removal of ${name} (${m.HopperPackageName} ${ver}) ...`)
|
||||
|
||||
const paths = splitList(m.SystemPackagePath || "")
|
||||
println("")
|
||||
println("The following files will be deleted:")
|
||||
if (paths.length === 0) {
|
||||
println(" (manifest declares no files)")
|
||||
}
|
||||
paths.forEach(p => {
|
||||
const expanded = expandSystemPath(p)
|
||||
if (expanded.length === 0) {
|
||||
println(` (no match on disk) ${p}`)
|
||||
}
|
||||
else {
|
||||
expanded.forEach(e => println(` ${e}`))
|
||||
}
|
||||
})
|
||||
println(` ${m._manifestPath}`)
|
||||
|
||||
println("")
|
||||
if (!confirm("Proceed with removal?", false)) {
|
||||
println("Aborted.")
|
||||
return 0
|
||||
}
|
||||
|
||||
const removed = deleteInstalledFiles(m)
|
||||
removed.forEach(p => println(` removed ${p}`))
|
||||
if (removed.length === 0) println(" (nothing was removed)")
|
||||
return 0
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Dispatch
|
||||
// ============================================================
|
||||
|
||||
function printUsage() {
|
||||
println("Hopper - Package manager for TVDOS")
|
||||
println("")
|
||||
println("Usage:")
|
||||
println(" hopper {search,se} [--provides|--requires|--description|--author] <query>")
|
||||
println(" hopper {install,in} <package> [-v <version>]")
|
||||
println(" hopper {remove,rm} <package>")
|
||||
}
|
||||
|
||||
const _hopperArgs = (typeof exec_args !== "undefined" && exec_args) ? exec_args.slice(1) : []
|
||||
const _hopperCmd = _hopperArgs[0]
|
||||
const _hopperRest = _hopperArgs.slice(1)
|
||||
|
||||
switch (_hopperCmd) {
|
||||
case "search":
|
||||
case "se":
|
||||
return cmdSearch(_hopperRest)
|
||||
case "install":
|
||||
case "in":
|
||||
return cmdInstall(_hopperRest)
|
||||
case "remove":
|
||||
case "rm":
|
||||
return cmdRemove(_hopperRest)
|
||||
case undefined:
|
||||
printUsage()
|
||||
return 0
|
||||
default:
|
||||
printerrln(`Unknown command: ${_hopperCmd}`)
|
||||
printUsage()
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -15,7 +15,10 @@ Uint16 Encoding
|
||||
10 00 : UTF-8
|
||||
10 01 : UTF-16BE
|
||||
10 02 : UTF-16LE
|
||||
Byte[5] Padding
|
||||
Byte Flags
|
||||
0b 0000 000r
|
||||
r: path is relative
|
||||
Bytes[4] Reserved
|
||||
|
||||
# FileBlocks
|
||||
Uint8 File type (only 1 is used)
|
||||
@@ -28,27 +31,36 @@ instead of compressing individual files)
|
||||
|
||||
function printUsage() {
|
||||
println(`Collects files under a directory into a single archive.
|
||||
Usage: lfs [-c/-x/-t] dest.lfs path\\to\\source
|
||||
Usage: lfs [-c/-x/-t] [-r] dest.lfs path\\to\\source
|
||||
To collect a directory into myarchive.lfs:
|
||||
lfs -c myarchive.lfs path\\to\\directory
|
||||
To collect a directory into myarchive.lfs, using relative path:
|
||||
lfs -c -r myarchive.lfs path\\to\\directory
|
||||
To extract an archive to path\\to\\my\\files:
|
||||
lfs -x myarchive.lfs path\\to\\my\\files
|
||||
To list the collected files:
|
||||
lfs -t myarchive.lfs`)
|
||||
}
|
||||
|
||||
let option = exec_args[1]
|
||||
const lfsPath = exec_args[2]
|
||||
const dirPath = exec_args[3]
|
||||
let option = undefined
|
||||
let useRelative = false
|
||||
const positional = []
|
||||
for (let i = 1; i < exec_args.length; i++) {
|
||||
const a = exec_args[i]
|
||||
if (a === undefined) continue
|
||||
const au = a.toUpperCase()
|
||||
if (au === "-C" || au === "-X" || au === "-T") option = au
|
||||
else if (au === "-R") useRelative = true
|
||||
else positional.push(a)
|
||||
}
|
||||
const lfsPath = positional[0]
|
||||
const dirPath = positional[1]
|
||||
|
||||
|
||||
if (option === undefined || lfsPath === undefined || option.toUpperCase() != "-T" && dirPath === undefined) {
|
||||
if (option === undefined || lfsPath === undefined || (option != "-T" && dirPath === undefined)) {
|
||||
printUsage()
|
||||
return 0
|
||||
}
|
||||
|
||||
option = option.toUpperCase()
|
||||
|
||||
|
||||
function recurseDir(file, action) {
|
||||
if (!file.isDirectory) {
|
||||
@@ -76,13 +88,14 @@ if ("-C" == option) {
|
||||
return 1
|
||||
}
|
||||
|
||||
let out = "TVDOSLFS\x01\x00\x00\x00\x00\x00\x00\x00"
|
||||
const flagsByte = useRelative ? 0x01 : 0x00
|
||||
let out = "TVDOSLFS\x01\x00\x00" + String.fromCharCode(flagsByte) + "\x00\x00\x00\x00"
|
||||
const rootDirPathLen = rootDir.fullPath.length
|
||||
|
||||
recurseDir(rootDir, file=>{
|
||||
let f = files.open(file.fullPath)
|
||||
let flen = f.size
|
||||
let fname = file.fullPath.substring(rootDirPathLen + 1)
|
||||
let fname = useRelative ? file.fullPath.substring(rootDirPathLen + 1) : file.fullPath
|
||||
let plen = fname.length
|
||||
|
||||
out += "\x01" + String.fromCharCode(
|
||||
@@ -116,6 +129,8 @@ else if ("-T" == option || "-X" == option) {
|
||||
return 2
|
||||
}
|
||||
|
||||
const archiveRelative = (bytes.charCodeAt(11) & 0x01) !== 0
|
||||
|
||||
if ("-X" == option && !rootDir.exists) {
|
||||
rootDir.mkDir()
|
||||
}
|
||||
@@ -132,9 +147,12 @@ else if ("-T" == option || "-X" == option) {
|
||||
|
||||
if ("-X" == option) {
|
||||
let filebytes = bytes.substring(curs, curs + filelen)
|
||||
let outfile = files.open(`${rootDir.fullPath}\\${path}`)
|
||||
// Fully qualified paths (e.g. "A:\foo\bar.txt") get their drive prefix
|
||||
// stripped so the archive contents re-root under the destination dir.
|
||||
let subPath = archiveRelative ? path : path.replace(/^[A-Za-z]:[\\\/]?/, "")
|
||||
let outfile = files.open(`${rootDir.fullPath}\\${subPath}`)
|
||||
|
||||
mkDirs(files.open(`${rootDir.driveLetter}:${files.open(`${rootDir.fullPath}\\${path}`).parentPath}`))
|
||||
mkDirs(files.open(`${outfile.driveLetter}:${outfile.parentPath}`))
|
||||
outfile.mkFile()
|
||||
outfile.swrite(filebytes)
|
||||
}
|
||||
|
||||
@@ -1,209 +1,122 @@
|
||||
const SND_BASE_ADDR = audio.getBaseAddr()
|
||||
// playmp2 — MPEG-1/2 Audio Layer II player with the shared playgui visualiser.
|
||||
// Usage: playmp2 <file.mp2> [-i]
|
||||
|
||||
const SND_BASE_ADDR = audio.getBaseAddr()
|
||||
if (!SND_BASE_ADDR) return 10
|
||||
|
||||
const MP2_BITRATES = ["???", 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384]
|
||||
const MP2_BITRATES = ["???", 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384]
|
||||
const MP2_CHANNELMODES = ["Stereo", "Joint", "Dual", "Mono"]
|
||||
|
||||
const pcm = require("pcm")
|
||||
const interactive = exec_args[2] && exec_args[2].toLowerCase() == "-i"
|
||||
const interactive = exec_args[2] && exec_args[2].toLowerCase() === "-i"
|
||||
const gui = interactive ? require("playgui") : null
|
||||
|
||||
function printdbg(s) { if (0) serial.println(s) }
|
||||
|
||||
|
||||
class SequentialFileBuffer {
|
||||
|
||||
constructor(path, offset, length) {
|
||||
if (Array.isArray(path)) throw Error("arg #1 is path(string), not array")
|
||||
|
||||
this.path = path
|
||||
this.file = files.open(path)
|
||||
|
||||
this.offset = offset || 0
|
||||
this.originalOffset = offset
|
||||
this.length = length || this.file.size
|
||||
|
||||
this.seq = require("seqread")
|
||||
this.seq.prepare(path)
|
||||
}
|
||||
|
||||
readBytes(size, ptr) {
|
||||
return this.seq.readBytes(size, ptr)
|
||||
}
|
||||
|
||||
readStr(n) {
|
||||
let ptr = this.seq.readBytes(n)
|
||||
let s = ''
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (i >= this.length) break
|
||||
s += String.fromCharCode(sys.peek(ptr + i))
|
||||
}
|
||||
sys.free(ptr)
|
||||
return s
|
||||
}
|
||||
|
||||
unread(diff) {
|
||||
let newSkipLen = this.seq.getReadCount() - diff
|
||||
this.seq.prepare(this.path)
|
||||
this.seq.skip(newSkipLen)
|
||||
}
|
||||
|
||||
rewind() {
|
||||
this.seq.prepare(this.path)
|
||||
}
|
||||
|
||||
seek(p) {
|
||||
this.seq.prepare(this.path)
|
||||
this.seq.skip(p)
|
||||
}
|
||||
|
||||
get byteLength() {
|
||||
return this.length
|
||||
}
|
||||
|
||||
get fileHeader() {
|
||||
return this.seq.fileHeader
|
||||
}
|
||||
|
||||
/*get remaining() {
|
||||
return this.length - this.getReadCount()
|
||||
}*/
|
||||
readBytes(size, ptr) { return this.seq.readBytes(size, ptr) }
|
||||
get fileHeader() { return this.seq.fileHeader }
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
let filebuf = new SequentialFileBuffer(_G.shell.resolvePathInput(exec_args[1]).full)
|
||||
const FILE_SIZE = filebuf.length// - 100
|
||||
const FRAME_SIZE = audio.mp2GetInitialFrameSize(filebuf.fileHeader)
|
||||
const filebuf = new SequentialFileBuffer(_G.shell.resolvePathInput(exec_args[1]).full)
|
||||
const FILE_SIZE = filebuf.length
|
||||
const FRAME_SIZE = audio.mp2GetInitialFrameSize(filebuf.fileHeader)
|
||||
const MEDIA_BITRATE = MP2_BITRATES[filebuf.fileHeader[2] >>> 4]
|
||||
const MEDIA_CHANNEL_MODE = MP2_CHANNELMODES[filebuf.fileHeader[3] >>> 6]
|
||||
const MEDIA_CHANNEL = MP2_CHANNELMODES[filebuf.fileHeader[3] >>> 6]
|
||||
|
||||
// mediaDecodedBin sits at MMIO offset 64 in the audio peripheral and holds
|
||||
// 2304 bytes (1152 stereo u8 samples per MP2 frame). Peripheral memory grows
|
||||
// toward 0 so the canonical pointer is SND_BASE_ADDR - 64.
|
||||
//
|
||||
// IMPORTANT: single-byte sys.peek on this address hits AudioAdapter.peek()
|
||||
// which maps the lower offsets to sampleBin, not mediaDecodedBin (the
|
||||
// MMIO/Memory-Space split — see CLAUDE.md). To get the decoded PCM into the
|
||||
// visualiser, we sys.memcpy mediaDecodedBin → a RAM scratch buffer; memcpy
|
||||
// uses VM.getDev internally which DOES route the MMIO read correctly.
|
||||
//
|
||||
// VM.getDev's range check on mediaDecodedBin (relPtrInDev) is half-open and
|
||||
// won't let us copy the full 2304 bytes — we copy 2302 (one stereo sample
|
||||
// short of the frame, invisible at visualiser resolution).
|
||||
const MP2_DECODED_ADDR = SND_BASE_ADDR - 64
|
||||
const MP2_VIS_COPY_BYTES = 2302
|
||||
const MP2_VIS_SAMPLE_COUNT = MP2_VIS_COPY_BYTES >> 1 // 1151
|
||||
const mp2VisScratch = interactive ? sys.malloc(MP2_VIS_COPY_BYTES) : 0
|
||||
|
||||
let bytes_left = FILE_SIZE
|
||||
let bytes_left = FILE_SIZE
|
||||
let decodedLength = 0
|
||||
|
||||
|
||||
//serial.println(`Frame size: ${FRAME_SIZE}`)
|
||||
|
||||
|
||||
con.curs_set(0)
|
||||
let [__, CONSOLE_WIDTH] = con.getmaxyx()
|
||||
if (interactive) {
|
||||
let [cy, cx] = con.getyx()
|
||||
// file name
|
||||
con.mvaddch(cy, 1)
|
||||
con.prnch(0xC9);con.prnch(0xCD);con.prnch(0xB5)
|
||||
print(filebuf.file.name)
|
||||
con.prnch(0xC6);con.prnch(0xCD)
|
||||
print("\x84205u".repeat(CONSOLE_WIDTH - 26 - filebuf.file.name.length))
|
||||
con.prnch(0xB5)
|
||||
print("Hold Bksp to Exit")
|
||||
con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBB)
|
||||
|
||||
// L R pillar
|
||||
con.prnch(0xBA)
|
||||
con.mvaddch(cy+1, CONSOLE_WIDTH, 0xBA)
|
||||
|
||||
// media info
|
||||
let mediaInfoStr = `MP2 ${MEDIA_CHANNEL_MODE} ${MEDIA_BITRATE}kbps`
|
||||
con.move(cy+2,1)
|
||||
con.prnch(0xC8)
|
||||
print("\x84205u".repeat(CONSOLE_WIDTH - 5 - mediaInfoStr.length))
|
||||
con.prnch(0xB5)
|
||||
print(mediaInfoStr)
|
||||
con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBC)
|
||||
|
||||
con.move(cy+1, 2)
|
||||
}
|
||||
let [cy, cx] = con.getyx()
|
||||
let paintWidth = CONSOLE_WIDTH - 20
|
||||
function bytesToSec(i) {
|
||||
// using fixed value: FRAME_SIZE(216) bytes for 36 ms on sampling rate 32000 Hz
|
||||
return i / (FRAME_SIZE * 1000 / bufRealTimeLen)
|
||||
}
|
||||
function secToReadable(n) {
|
||||
let mins = ''+((n/60)|0)
|
||||
let secs = ''+(n % 60)
|
||||
return `${mins.padStart(2,'0')}:${secs.padStart(2,'0')}`
|
||||
}
|
||||
function printPlayBar(currently) {
|
||||
if (interactive) {
|
||||
let currently = decodedLength
|
||||
let total = FILE_SIZE
|
||||
|
||||
let currentlySec = Math.round(bytesToSec(currently))
|
||||
let totalSec = Math.round(bytesToSec(total))
|
||||
|
||||
con.move(cy, 3)
|
||||
print(' '.repeat(15))
|
||||
con.move(cy, 3)
|
||||
|
||||
print(`${secToReadable(currentlySec)} / ${secToReadable(totalSec)}`)
|
||||
|
||||
con.move(cy, 17)
|
||||
print(' ')
|
||||
let progressbar = '\x84196u'.repeat(paintWidth + 1)
|
||||
print(progressbar)
|
||||
|
||||
con.mvaddch(cy, 18 + Math.round(paintWidth * (currently / total)), 0xDB)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const bufRealTimeLen = 36 // one MP2 frame at 32 kHz ≈ 36 ms
|
||||
|
||||
audio.resetParams(0)
|
||||
audio.purgeQueue(0)
|
||||
audio.setPcmMode(0)
|
||||
audio.setPcmQueueCapacityIndex(0, 2) // queue size is now 8
|
||||
audio.setPcmQueueCapacityIndex(0, 2)
|
||||
const QUEUE_MAX = audio.getPcmQueueCapacity(0)
|
||||
audio.setMasterVolume(0, 255)
|
||||
audio.play(0)
|
||||
|
||||
|
||||
//let mp2context = audio.mp2Init()
|
||||
audio.mp2Init()
|
||||
|
||||
// decode frame
|
||||
let t1 = sys.nanoTime()
|
||||
let bufRealTimeLen = 36
|
||||
function bytesToSec(i) { return i / (FRAME_SIZE * 1000 / bufRealTimeLen) }
|
||||
|
||||
if (interactive) {
|
||||
const tag = "MP2"
|
||||
const title = `${filebuf.file.name} ${MEDIA_CHANNEL} ${MEDIA_BITRATE}kbps`
|
||||
gui.audioInit({ title, tag })
|
||||
}
|
||||
|
||||
let stopPlay = false
|
||||
let errorlevel = 0
|
||||
try {
|
||||
while (bytes_left > 0 && !stopPlay) {
|
||||
|
||||
if (interactive) {
|
||||
sys.poke(-40, 1)
|
||||
if (sys.peek(-41) == 67) {
|
||||
stopPlay = true
|
||||
}
|
||||
}
|
||||
|
||||
printPlayBar()
|
||||
|
||||
if (interactive && gui.audioIsExitRequested()) { stopPlay = true; break }
|
||||
|
||||
filebuf.readBytes(FRAME_SIZE, SND_BASE_ADDR - 2368)
|
||||
audio.mp2Decode()
|
||||
|
||||
// After decode, 1152 PCMu8 stereo samples sit in mediaDecodedBin
|
||||
// (MMIO). Bounce them through RAM so single-byte peek in the
|
||||
// visualiser pipeline can reach them — see MP2_DECODED_ADDR notes.
|
||||
if (interactive) {
|
||||
sys.memcpy(MP2_DECODED_ADDR, mp2VisScratch, MP2_VIS_COPY_BYTES)
|
||||
gui.audioFeedPcm(mp2VisScratch, MP2_VIS_SAMPLE_COUNT)
|
||||
}
|
||||
|
||||
if (audio.getPosition(0) >= QUEUE_MAX) {
|
||||
while (audio.getPosition(0) >= (QUEUE_MAX >>> 1)) {
|
||||
printdbg(`Queue full, waiting until the queue has some space (${audio.getPosition(0)}/${QUEUE_MAX})`)
|
||||
if (interactive) gui.audioRender()
|
||||
sys.sleep(bufRealTimeLen)
|
||||
}
|
||||
}
|
||||
audio.mp2UploadDecoded(0)
|
||||
|
||||
if (interactive) {
|
||||
gui.audioSetProgress(decodedLength / FILE_SIZE,
|
||||
bytesToSec(decodedLength), bytesToSec(FILE_SIZE))
|
||||
gui.audioRender()
|
||||
}
|
||||
sys.sleep(10)
|
||||
|
||||
|
||||
|
||||
bytes_left -= FRAME_SIZE
|
||||
bytes_left -= FRAME_SIZE
|
||||
decodedLength += FRAME_SIZE
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
} catch (e) {
|
||||
printerrln(e)
|
||||
errorlevel = 1
|
||||
}
|
||||
finally {
|
||||
} finally {
|
||||
if (interactive) {
|
||||
if (mp2VisScratch) sys.free(mp2VisScratch)
|
||||
gui.audioClose()
|
||||
}
|
||||
}
|
||||
|
||||
return errorlevel
|
||||
return errorlevel
|
||||
|
||||
@@ -1,196 +1,81 @@
|
||||
// usage: playpcm audiofile.pcm [/i]
|
||||
let fileeeee = files.open(_G.shell.resolvePathInput(exec_args[1]).full)
|
||||
let filename = fileeeee.fullPath
|
||||
function printdbg(s) { if (0) serial.println(s) }
|
||||
// playpcm — raw PCMu8 stereo player with the shared playgui visualiser.
|
||||
// Usage: playpcm <file.pcm> [-i]
|
||||
|
||||
const interactive = exec_args[2] && exec_args[2].toLowerCase() == "-i"
|
||||
const pcm = require("pcm")
|
||||
const FILE_SIZE = files.open(filename).size
|
||||
|
||||
|
||||
|
||||
function printComments() {
|
||||
for (const [key, value] of Object.entries(comments)) {
|
||||
printdbg(`${key}: ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
function GCD(a, b) {
|
||||
a = Math.abs(a)
|
||||
b = Math.abs(b)
|
||||
if (b > a) {var temp = a; a = b; b = temp}
|
||||
while (true) {
|
||||
if (b == 0) return a
|
||||
a %= b
|
||||
if (a == 0) return b
|
||||
b %= a
|
||||
}
|
||||
}
|
||||
|
||||
function LCM(a, b) {
|
||||
return (!a || !b) ? 0 : Math.abs((a * b) / GCD(a, b))
|
||||
}
|
||||
|
||||
|
||||
|
||||
//println("Reading...")
|
||||
//serial.println("!!! READING")
|
||||
const fileHandle = files.open(_G.shell.resolvePathInput(exec_args[1]).full)
|
||||
const filePath = fileHandle.fullPath
|
||||
|
||||
const interactive = exec_args[2] && exec_args[2].toLowerCase() === "-i"
|
||||
const pcm = require("pcm")
|
||||
const seqread = require("seqread")
|
||||
seqread.prepare(filename)
|
||||
|
||||
|
||||
|
||||
|
||||
const gui = interactive ? require("playgui") : null
|
||||
|
||||
const FILE_SIZE = files.open(filePath).size
|
||||
|
||||
let BLOCK_SIZE = 4096
|
||||
let INFILE_BLOCK_SIZE = BLOCK_SIZE
|
||||
const QUEUE_MAX = 8 // according to the spec
|
||||
const INFILE_BLOCK_SIZE = BLOCK_SIZE
|
||||
const QUEUE_MAX = 8
|
||||
|
||||
let nChannels = 2
|
||||
let samplingRate = pcm.HW_SAMPLING_RATE;
|
||||
let blockSize = 2;
|
||||
let bitsPerSample = 8;
|
||||
let byterate = 2*samplingRate;
|
||||
let comments = {};
|
||||
let readPtr = undefined
|
||||
let decodePtr = undefined
|
||||
const samplingRate = pcm.HW_SAMPLING_RATE
|
||||
const byterate = 2 * samplingRate
|
||||
|
||||
function bytesToSec(i) {
|
||||
return i / byterate
|
||||
}
|
||||
function secToReadable(n) {
|
||||
let mins = ''+((n/60)|0)
|
||||
let secs = ''+(n % 60)
|
||||
return `${mins.padStart(2,'0')}:${secs.padStart(2,'0')}`
|
||||
}
|
||||
|
||||
let stopPlay = false
|
||||
con.curs_set(0)
|
||||
let [__, CONSOLE_WIDTH] = con.getmaxyx()
|
||||
if (interactive) {
|
||||
let [cy, cx] = con.getyx()
|
||||
// file name
|
||||
con.mvaddch(cy, 1)
|
||||
con.prnch(0xC9);con.prnch(0xCD);con.prnch(0xB5)
|
||||
print(fileeeee.name)
|
||||
con.prnch(0xC6);con.prnch(0xCD)
|
||||
print("\x84205u".repeat(CONSOLE_WIDTH - 26 - fileeeee.name.length))
|
||||
con.prnch(0xB5)
|
||||
print("Hold Bksp to Exit")
|
||||
con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBB)
|
||||
|
||||
// L R pillar
|
||||
con.prnch(0xBA)
|
||||
con.mvaddch(cy+1, CONSOLE_WIDTH, 0xBA)
|
||||
|
||||
// media info
|
||||
let mediaInfoStr = `Raw PCM 512kbps`
|
||||
con.move(cy+2,1)
|
||||
con.prnch(0xC8)
|
||||
print("\x84205u".repeat(CONSOLE_WIDTH - 5 - mediaInfoStr.length))
|
||||
con.prnch(0xB5)
|
||||
print(mediaInfoStr)
|
||||
con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBC)
|
||||
|
||||
con.move(cy+1, 2)
|
||||
}
|
||||
let [cy, cx] = con.getyx()
|
||||
let paintWidth = CONSOLE_WIDTH - 20
|
||||
// read chunks loop
|
||||
readPtr = sys.malloc(BLOCK_SIZE * bitsPerSample / 8)
|
||||
decodePtr = sys.malloc(BLOCK_SIZE * pcm.HW_SAMPLING_RATE / samplingRate)
|
||||
function bytesToSec(i) { return i / byterate }
|
||||
|
||||
seqread.prepare(filePath)
|
||||
|
||||
const readPtr = sys.malloc(BLOCK_SIZE)
|
||||
audio.resetParams(0)
|
||||
audio.purgeQueue(0)
|
||||
audio.setPcmMode(0)
|
||||
audio.setMasterVolume(0, 255)
|
||||
|
||||
let readLength = 1
|
||||
|
||||
function printPlayBar() {
|
||||
if (interactive) {
|
||||
let currently = seqread.getReadCount()
|
||||
let total = FILE_SIZE
|
||||
|
||||
let currentlySec = Math.round(bytesToSec(currently))
|
||||
let totalSec = Math.round(bytesToSec(total))
|
||||
|
||||
con.move(cy, 3)
|
||||
print(' '.repeat(15))
|
||||
con.move(cy, 3)
|
||||
|
||||
print(`${secToReadable(currentlySec)} / ${secToReadable(totalSec)}`)
|
||||
|
||||
con.move(cy, 17)
|
||||
print(' ')
|
||||
let progressbar = '\x84196u'.repeat(paintWidth + 1)
|
||||
print(progressbar)
|
||||
|
||||
con.mvaddch(cy, 18 + Math.round(paintWidth * (currently / total)), 0xDB)
|
||||
}
|
||||
if (interactive) {
|
||||
gui.audioInit({
|
||||
title: `${fileHandle.name} Raw PCM 32kHz Stereo`,
|
||||
tag: "PCM"
|
||||
})
|
||||
}
|
||||
|
||||
let stopPlay = false
|
||||
let errorlevel = 0
|
||||
let readLength = 1
|
||||
try {
|
||||
while (!stopPlay && seqread.getReadCount() < FILE_SIZE && readLength > 0) {
|
||||
if (interactive) {
|
||||
sys.poke(-40, 1)
|
||||
if (sys.peek(-41) == 67) {
|
||||
stopPlay = true
|
||||
}
|
||||
}
|
||||
while (!stopPlay && seqread.getReadCount() < FILE_SIZE && readLength > 0) {
|
||||
if (interactive && gui.audioIsExitRequested()) { stopPlay = true; break }
|
||||
|
||||
const queueSize = audio.getPosition(0)
|
||||
if (queueSize <= 1) {
|
||||
for (let repeat = QUEUE_MAX - queueSize; repeat > 0; repeat--) {
|
||||
const remainingBytes = FILE_SIZE - seqread.getReadCount()
|
||||
readLength = (remainingBytes < INFILE_BLOCK_SIZE) ? remainingBytes : INFILE_BLOCK_SIZE
|
||||
if (readLength <= 0) break
|
||||
|
||||
let queueSize = audio.getPosition(0)
|
||||
if (queueSize <= 1) {
|
||||
seqread.readBytes(readLength, readPtr)
|
||||
|
||||
printPlayBar()
|
||||
// Raw PCMu8 stereo — sampleCount = bytes / 2.
|
||||
if (interactive) gui.audioFeedPcm(readPtr, readLength >> 1)
|
||||
|
||||
// upload four samples for lag-safely
|
||||
for (let repeat = QUEUE_MAX - queueSize; repeat > 0; repeat--) {
|
||||
let remainingBytes = FILE_SIZE - seqread.getReadCount()
|
||||
audio.putPcmDataByPtr(0, readPtr, readLength, 0)
|
||||
audio.setSampleUploadLength(0, readLength)
|
||||
audio.startSampleUpload(0)
|
||||
|
||||
readLength = (remainingBytes < INFILE_BLOCK_SIZE) ? remainingBytes : INFILE_BLOCK_SIZE
|
||||
if (readLength <= 0) {
|
||||
printdbg(`readLength = ${readLength}`)
|
||||
break
|
||||
if (repeat > 1) sys.sleep(10)
|
||||
}
|
||||
|
||||
printdbg(`offset: ${seqread.getReadCount()}/${FILE_SIZE}; readLength: ${readLength}`)
|
||||
|
||||
seqread.readBytes(readLength, readPtr)
|
||||
|
||||
audio.putPcmDataByPtr(0, readPtr, readLength, 0)
|
||||
audio.setSampleUploadLength(0, readLength)
|
||||
audio.startSampleUpload(0)
|
||||
|
||||
|
||||
if (repeat > 1) sys.sleep(10)
|
||||
|
||||
printPlayBar()
|
||||
audio.play(0)
|
||||
}
|
||||
|
||||
audio.play(0)
|
||||
if (interactive) {
|
||||
const cur = seqread.getReadCount()
|
||||
gui.audioSetProgress(cur / FILE_SIZE, bytesToSec(cur), bytesToSec(FILE_SIZE))
|
||||
gui.audioRender()
|
||||
}
|
||||
sys.sleep(10)
|
||||
}
|
||||
|
||||
let remainingBytes = FILE_SIZE - seqread.getReadCount()
|
||||
printdbg(`readLength = ${readLength}; remainingBytes2 = ${remainingBytes}; seqread.getReadCount() = ${seqread.getReadCount()};`)
|
||||
|
||||
|
||||
sys.sleep(10)
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
} catch (e) {
|
||||
printerrln(e)
|
||||
errorlevel = 1
|
||||
}
|
||||
finally {
|
||||
//audio.stop(0)
|
||||
} finally {
|
||||
if (readPtr !== undefined) sys.free(readPtr)
|
||||
if (decodePtr !== undefined) sys.free(decodePtr)
|
||||
if (interactive) gui.audioClose()
|
||||
}
|
||||
|
||||
return errorlevel
|
||||
|
||||
|
||||
@@ -1,112 +1,66 @@
|
||||
// playtad — TAD (TSVM Advanced Audio) player with the shared playgui visualiser.
|
||||
// Usage: playtad <file.tad> [-i | -d]
|
||||
// -i Interactive mode (visualiser + progress bar; hold Backspace to exit)
|
||||
// -d Dump mode (print the first three chunks to serial for debugging)
|
||||
|
||||
const SND_BASE_ADDR = audio.getBaseAddr()
|
||||
const SND_MEM_ADDR = audio.getMemAddr()
|
||||
const TAD_INPUT_ADDR = SND_MEM_ADDR - 262144 // TAD input buffer (matches TAV packet 0x24)
|
||||
const TAD_DECODED_ADDR = SND_MEM_ADDR - 262144 + 65536 // TAD decoded buffer
|
||||
const SND_MEM_ADDR = audio.getMemAddr()
|
||||
// tadInputBin at offset 917504, tadDecodedBin at 983040. Both addressed via
|
||||
// negative pointers — peripheral memory grows toward 0.
|
||||
const TAD_INPUT_ADDR = SND_MEM_ADDR - 917504
|
||||
const TAD_DECODED_ADDR = SND_MEM_ADDR - 983040
|
||||
|
||||
if (!SND_BASE_ADDR) return 10
|
||||
|
||||
// Check for help flag or missing arguments
|
||||
if (!exec_args[1] || exec_args[1] == "-h" || exec_args[1] == "--help") {
|
||||
serial.println("Usage: playtad <file.tad> [-i | -d] [quality]")
|
||||
serial.println(" -i Interactive mode (progress bar, press Backspace to exit)")
|
||||
serial.println(" -d Dump mode (show first 3 chunks with payload hex and decoded samples)")
|
||||
serial.println("")
|
||||
serial.println("Examples:")
|
||||
serial.println(" playtad audio.tad -i # Play with progress bar")
|
||||
serial.println(" playtad audio.tad -d # Dump first 3 chunks for debugging")
|
||||
if (!exec_args[1] || exec_args[1] === "-h" || exec_args[1] === "--help") {
|
||||
serial.println("Usage: playtad <file.tad> [-i | -d]")
|
||||
serial.println(" -i Interactive mode (visualiser + progress bar)")
|
||||
serial.println(" -d Dump first three chunks for debugging")
|
||||
return 0
|
||||
}
|
||||
|
||||
const pcm = require("pcm")
|
||||
const interactive = exec_args[2] && exec_args[2].toLowerCase() == "-i"
|
||||
const dumpCoeffs = exec_args[2] && exec_args[2].toLowerCase() == "-d"
|
||||
|
||||
function printdbg(s) { if (0) serial.println(s) }
|
||||
|
||||
const interactive = exec_args[2] && exec_args[2].toLowerCase() === "-i"
|
||||
const dumpCoeffs = exec_args[2] && exec_args[2].toLowerCase() === "-d"
|
||||
const gui = interactive ? require("playgui") : null
|
||||
|
||||
class SequentialFileBuffer {
|
||||
|
||||
constructor(path, offset, length) {
|
||||
constructor(path) {
|
||||
if (Array.isArray(path)) throw Error("arg #1 is path(string), not array")
|
||||
|
||||
this.path = path
|
||||
this.file = files.open(path)
|
||||
|
||||
this.offset = offset || 0
|
||||
this.originalOffset = offset
|
||||
this.length = length || this.file.size
|
||||
|
||||
this.length = this.file.size
|
||||
this.seq = require("seqread")
|
||||
this.seq.prepare(path)
|
||||
}
|
||||
|
||||
readBytes(size, ptr) {
|
||||
return this.seq.readBytes(size, ptr)
|
||||
}
|
||||
|
||||
readBytes(size, ptr) { return this.seq.readBytes(size, ptr) }
|
||||
readByte() {
|
||||
let ptr = this.seq.readBytes(1)
|
||||
let val = sys.peek(ptr)
|
||||
const ptr = this.seq.readBytes(1)
|
||||
const val = sys.peek(ptr)
|
||||
sys.free(ptr)
|
||||
return val
|
||||
}
|
||||
|
||||
readShort() {
|
||||
let ptr = this.seq.readBytes(2)
|
||||
let val = sys.peek(ptr) | (sys.peek(ptr + 1) << 8)
|
||||
const ptr = this.seq.readBytes(2)
|
||||
const val = sys.peek(ptr) | (sys.peek(ptr + 1) << 8)
|
||||
sys.free(ptr)
|
||||
return val
|
||||
}
|
||||
|
||||
readInt() {
|
||||
let ptr = this.seq.readBytes(4)
|
||||
let val = sys.peek(ptr) | (sys.peek(ptr + 1) << 8) | (sys.peek(ptr + 2) << 16) | (sys.peek(ptr + 3) << 24)
|
||||
const ptr = this.seq.readBytes(4)
|
||||
const val = sys.peek(ptr) | (sys.peek(ptr + 1) << 8) | (sys.peek(ptr + 2) << 16) | (sys.peek(ptr + 3) << 24)
|
||||
sys.free(ptr)
|
||||
return val
|
||||
}
|
||||
|
||||
readStr(n) {
|
||||
let ptr = this.seq.readBytes(n)
|
||||
let s = ''
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (i >= this.length) break
|
||||
s += String.fromCharCode(sys.peek(ptr + i))
|
||||
}
|
||||
sys.free(ptr)
|
||||
return s
|
||||
}
|
||||
|
||||
unread(diff) {
|
||||
let newSkipLen = this.seq.getReadCount() - diff
|
||||
const newSkipLen = this.seq.getReadCount() - diff
|
||||
this.seq.prepare(this.path)
|
||||
this.seq.skip(newSkipLen)
|
||||
}
|
||||
|
||||
rewind() {
|
||||
this.seq.prepare(this.path)
|
||||
}
|
||||
|
||||
seek(p) {
|
||||
this.seq.prepare(this.path)
|
||||
this.seq.skip(p)
|
||||
}
|
||||
|
||||
get byteLength() {
|
||||
return this.length
|
||||
}
|
||||
|
||||
get fileHeader() {
|
||||
return this.seq.fileHeader
|
||||
}
|
||||
|
||||
getReadCount() {
|
||||
return this.seq.getReadCount()
|
||||
}
|
||||
rewind() { this.seq.prepare(this.path) }
|
||||
getReadCount() { return this.seq.getReadCount() }
|
||||
}
|
||||
|
||||
|
||||
// Read TAD chunk header to determine format
|
||||
let filebuf = new SequentialFileBuffer(_G.shell.resolvePathInput(exec_args[1]).full)
|
||||
const filebuf = new SequentialFileBuffer(_G.shell.resolvePathInput(exec_args[1]).full)
|
||||
const FILE_SIZE = filebuf.length
|
||||
|
||||
if (FILE_SIZE < 7) {
|
||||
@@ -114,12 +68,12 @@ if (FILE_SIZE < 7) {
|
||||
return 1
|
||||
}
|
||||
|
||||
// Read first chunk header (standalone TAD format: no TAV wrapper)
|
||||
let firstSampleCount = filebuf.readShort()
|
||||
let firstMaxIndex = filebuf.readByte()
|
||||
let firstPayloadSize = filebuf.readInt()
|
||||
// Peek the first chunk header so we know the chunk size for the rough bytes-
|
||||
// to-seconds conversion shown in the progress bar.
|
||||
const firstSampleCount = filebuf.readShort()
|
||||
const firstMaxIndex = filebuf.readByte()
|
||||
const firstPayloadSize = filebuf.readInt()
|
||||
|
||||
// Validate first chunk
|
||||
if (firstSampleCount < 0 || firstSampleCount > 65536) {
|
||||
serial.println(`ERROR: Invalid sample count ${firstSampleCount}. File may be corrupted.`)
|
||||
return 1
|
||||
@@ -133,148 +87,68 @@ if (firstPayloadSize < 1 || firstPayloadSize > 65536) {
|
||||
return 1
|
||||
}
|
||||
|
||||
// Rewind to start
|
||||
filebuf.rewind()
|
||||
|
||||
// Calculate approximate frame info
|
||||
const AVG_CHUNK_SIZE = 7 + firstPayloadSize // TAD header (2+1+4) + payload
|
||||
const SAMPLE_RATE = 32000
|
||||
const bufRealTimeLen = Math.floor((firstSampleCount / SAMPLE_RATE) * 1000) // milliseconds per chunk
|
||||
const AVG_CHUNK_SIZE = 7 + firstPayloadSize
|
||||
const SAMPLE_RATE = 32000
|
||||
const bufRealTimeLen = Math.floor((firstSampleCount / SAMPLE_RATE) * 1000)
|
||||
|
||||
if (dumpCoeffs) {
|
||||
serial.println(`TAD Coefficient Dump Mode`)
|
||||
serial.println(`File: ${filebuf.file.name}`)
|
||||
serial.println(`First chunk header:`)
|
||||
serial.println(` Sample Count: ${firstSampleCount}`)
|
||||
serial.println(` Max Index: ${firstMaxIndex}`)
|
||||
serial.println(` Payload Size: ${firstPayloadSize} bytes`)
|
||||
serial.println(`First chunk: ${firstSampleCount} samples, Q${firstMaxIndex}, ${firstPayloadSize} bytes payload`)
|
||||
serial.println(`Chunk Duration: ${bufRealTimeLen} ms`)
|
||||
serial.println(``)
|
||||
}
|
||||
|
||||
|
||||
let bytes_left = FILE_SIZE
|
||||
let bytes_left = FILE_SIZE
|
||||
let decodedLength = 0
|
||||
let chunkNumber = 0
|
||||
|
||||
|
||||
con.curs_set(0)
|
||||
let [__, CONSOLE_WIDTH] = con.getmaxyx()
|
||||
if (interactive) {
|
||||
let [cy, cx] = con.getyx()
|
||||
// file name
|
||||
con.mvaddch(cy, 1)
|
||||
con.prnch(0xC9);con.prnch(0xCD);con.prnch(0xB5)
|
||||
print(filebuf.file.name)
|
||||
con.prnch(0xC6);con.prnch(0xCD)
|
||||
print("\x84205u".repeat(CONSOLE_WIDTH - 26 - filebuf.file.name.length))
|
||||
con.prnch(0xB5)
|
||||
print("Hold Bksp to Exit")
|
||||
con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBB)
|
||||
|
||||
// L R pillar
|
||||
con.prnch(0xBA)
|
||||
con.mvaddch(cy+1, CONSOLE_WIDTH, 0xBA)
|
||||
|
||||
// media info
|
||||
let mediaInfoStr = `TAD Q${firstMaxIndex} ${SAMPLE_RATE/1000}kHz`
|
||||
con.move(cy+2,1)
|
||||
con.prnch(0xC8)
|
||||
print("\x84205u".repeat(CONSOLE_WIDTH - 5 - mediaInfoStr.length))
|
||||
con.prnch(0xB5)
|
||||
print(mediaInfoStr)
|
||||
con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBC)
|
||||
|
||||
con.move(cy+1, 2)
|
||||
}
|
||||
let [cy, cx] = con.getyx()
|
||||
let paintWidth = CONSOLE_WIDTH - 20
|
||||
let chunkNumber = 0
|
||||
|
||||
function bytesToSec(i) {
|
||||
// Approximate: use first chunk's ratio
|
||||
return Math.round((i / FILE_SIZE) * (FILE_SIZE / AVG_CHUNK_SIZE) * (bufRealTimeLen / 1000))
|
||||
}
|
||||
|
||||
function secToReadable(n) {
|
||||
let mins = ''+((n/60)|0)
|
||||
let secs = ''+(n % 60)
|
||||
return `${mins.padStart(2,'0')}:${secs.padStart(2,'0')}`
|
||||
}
|
||||
|
||||
function printPlayBar() {
|
||||
if (interactive) {
|
||||
let currently = decodedLength
|
||||
let total = FILE_SIZE
|
||||
|
||||
let currentlySec = bytesToSec(currently)
|
||||
let totalSec = bytesToSec(total)
|
||||
|
||||
con.move(cy, 3)
|
||||
print(' '.repeat(15))
|
||||
con.move(cy, 3)
|
||||
|
||||
print(`${secToReadable(currentlySec)} / ${secToReadable(totalSec)}`)
|
||||
|
||||
con.move(cy, 17)
|
||||
print(' ')
|
||||
let progressbar = '\x84196u'.repeat(paintWidth + 1)
|
||||
print(progressbar)
|
||||
|
||||
con.mvaddch(cy, 18 + Math.round(paintWidth * (currently / total)), 0xDB)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
audio.resetParams(0)
|
||||
audio.purgeQueue(0)
|
||||
audio.setPcmMode(0)
|
||||
audio.setPcmQueueCapacityIndex(0, 2) // queue size is now 8
|
||||
audio.setPcmQueueCapacityIndex(0, 2)
|
||||
const QUEUE_MAX = audio.getPcmQueueCapacity(0)
|
||||
audio.setMasterVolume(0, 255)
|
||||
audio.play(0)
|
||||
|
||||
if (interactive) {
|
||||
gui.audioInit({
|
||||
title: `${filebuf.file.name} TAD Q${firstMaxIndex} ${SAMPLE_RATE/1000}kHz`,
|
||||
tag: "TAD"
|
||||
})
|
||||
}
|
||||
|
||||
let stopPlay = false
|
||||
let errorlevel = 0
|
||||
|
||||
try {
|
||||
while (bytes_left > 0 && !stopPlay) {
|
||||
if (interactive && gui.audioIsExitRequested()) { stopPlay = true; break }
|
||||
|
||||
if (interactive) {
|
||||
sys.poke(-40, 1)
|
||||
if (sys.peek(-41) == 67) { // Backspace key
|
||||
stopPlay = true
|
||||
}
|
||||
}
|
||||
const sampleCount = filebuf.readShort()
|
||||
const maxIndex = filebuf.readByte()
|
||||
const payloadSize = filebuf.readInt()
|
||||
|
||||
printPlayBar()
|
||||
|
||||
// Read TAD chunk header (standalone TAD format)
|
||||
// Format: [sample_count][max_index][payload_size][payload]
|
||||
let sampleCount = filebuf.readShort()
|
||||
let maxIndex = filebuf.readByte()
|
||||
let payloadSize = filebuf.readInt()
|
||||
|
||||
// Validate every chunk (not just first one)
|
||||
if (sampleCount < 0 || sampleCount > 65536) {
|
||||
serial.println(`ERROR: Chunk ${chunkNumber}: Invalid sample count ${sampleCount}. File may be corrupted.`)
|
||||
errorlevel = 1
|
||||
break
|
||||
serial.println(`ERROR: Chunk ${chunkNumber}: Invalid sample count ${sampleCount}.`)
|
||||
errorlevel = 1; break
|
||||
}
|
||||
if (maxIndex < 0 || maxIndex > 255) {
|
||||
serial.println(`ERROR: Chunk ${chunkNumber}: Invalid max index ${maxIndex}. File may be corrupted.`)
|
||||
errorlevel = 1
|
||||
break
|
||||
serial.println(`ERROR: Chunk ${chunkNumber}: Invalid max index ${maxIndex}.`)
|
||||
errorlevel = 1; break
|
||||
}
|
||||
if (payloadSize < 1 || payloadSize > 65536) {
|
||||
serial.println(`ERROR: Chunk ${chunkNumber}: Invalid payload size ${payloadSize}. File may be corrupted.`)
|
||||
errorlevel = 1
|
||||
break
|
||||
serial.println(`ERROR: Chunk ${chunkNumber}: Invalid payload size ${payloadSize}.`)
|
||||
errorlevel = 1; break
|
||||
}
|
||||
if (payloadSize + 7 > bytes_left) {
|
||||
serial.println(`ERROR: Chunk ${chunkNumber}: Chunk size ${payloadSize + 7} exceeds remaining file size ${bytes_left}`)
|
||||
errorlevel = 1
|
||||
break
|
||||
serial.println(`ERROR: Chunk ${chunkNumber}: Chunk size exceeds remaining file size.`)
|
||||
errorlevel = 1; break
|
||||
}
|
||||
|
||||
if (dumpCoeffs && chunkNumber < 3) {
|
||||
@@ -282,80 +156,59 @@ try {
|
||||
serial.println(` Sample Count: ${sampleCount}`)
|
||||
serial.println(` Max Index: ${maxIndex}`)
|
||||
serial.println(` Payload Size: ${payloadSize} bytes`)
|
||||
serial.println(` Bytes remaining in file: ${bytes_left}`)
|
||||
}
|
||||
|
||||
// Rewind 7 bytes to re-read the header along with payload
|
||||
// This allows reading the complete chunk (header + payload) in one call
|
||||
// Read entire chunk (header + payload) into TAD input buffer.
|
||||
filebuf.unread(7)
|
||||
filebuf.readBytes(7 + payloadSize, TAD_INPUT_ADDR)
|
||||
|
||||
// Read entire chunk (header + payload) to TAD input buffer
|
||||
// This matches TAV's approach for packet 0x24
|
||||
let totalChunkSize = 7 + payloadSize
|
||||
filebuf.readBytes(totalChunkSize, TAD_INPUT_ADDR)
|
||||
|
||||
if (dumpCoeffs && chunkNumber < 3) {
|
||||
// Dump first 32 bytes of compressed payload (skip 7-byte header)
|
||||
serial.print(` Compressed data (first 32 bytes): `)
|
||||
for (let i = 0; i < Math.min(32, payloadSize); i++) {
|
||||
let b = sys.peek(TAD_INPUT_ADDR + 7 + i)
|
||||
serial.print(`${(b & 0xFF).toString(16).padStart(2, '0')} `)
|
||||
}
|
||||
serial.println('')
|
||||
}
|
||||
|
||||
// Decode TAD chunk
|
||||
audio.tadDecode()
|
||||
|
||||
if (dumpCoeffs && chunkNumber < 3) {
|
||||
// After decoding, the decoded PCMu8 samples are in tadDecodedBin
|
||||
serial.println(` Decoded ${sampleCount} samples`)
|
||||
|
||||
// Dump first 16 decoded samples (PCMu8 stereo interleaved)
|
||||
serial.print(` Decoded (first 16 L samples): `)
|
||||
for (let i = 0; i < 16; i++) {
|
||||
serial.print(`${sys.peek(TAD_DECODED_ADDR + i * 2) & 0xFF} `)
|
||||
}
|
||||
serial.println('')
|
||||
serial.print(` Decoded (first 16 R samples): `)
|
||||
for (let i = 0; i < 16; i++) {
|
||||
serial.print(`${sys.peek(TAD_DECODED_ADDR + i * 2 + 1) & 0xFF} `)
|
||||
}
|
||||
serial.println('')
|
||||
serial.println('')
|
||||
}
|
||||
|
||||
// Upload decoded audio to queue
|
||||
audio.tadUploadDecoded(0, sampleCount)
|
||||
// After upload tadDecodedBin still holds the chunk until the next
|
||||
// tadDecode call, so it's safe to keep slicing samples out of it
|
||||
// during the playback wait below.
|
||||
|
||||
if (!dumpCoeffs) {
|
||||
// Sleep for the duration of the audio chunk to pace playback
|
||||
// This prevents uploading everything at once
|
||||
sys.sleep(bufRealTimeLen)
|
||||
// TAD chunks are typically 1 s long, so feeding the visualiser
|
||||
// once would freeze it for ~1 s. Walk the chunk in 2048-sample
|
||||
// slices (~64 ms each at 32 kHz) so the wavescope and XY-scope
|
||||
// stay in step with what the audio engine is actually playing.
|
||||
const chunkMs = Math.floor((sampleCount / SAMPLE_RATE) * 1000)
|
||||
const TAD_VIS_SLICE = 2048
|
||||
if (interactive) {
|
||||
gui.audioSetProgress(decodedLength / FILE_SIZE,
|
||||
bytesToSec(decodedLength), bytesToSec(FILE_SIZE))
|
||||
let sliceOff = 0
|
||||
while (sliceOff < sampleCount && !stopPlay) {
|
||||
if (gui.audioIsExitRequested()) { stopPlay = true; break }
|
||||
const sliceN = Math.min(TAD_VIS_SLICE, sampleCount - sliceOff)
|
||||
// tadDecodedBin is negative-addressed: sample i sits at
|
||||
// TAD_DECODED_ADDR - i*2. audioFeedPcm flips the read
|
||||
// direction for negative ptrs internally.
|
||||
gui.audioFeedPcm(TAD_DECODED_ADDR - sliceOff * 2, sliceN)
|
||||
gui.audioRender()
|
||||
sys.sleep(Math.floor((sliceN / SAMPLE_RATE) * 1000))
|
||||
sliceOff += sliceN
|
||||
}
|
||||
} else {
|
||||
sys.sleep(chunkMs)
|
||||
}
|
||||
}
|
||||
|
||||
// Chunk size = header (7 bytes) + payload
|
||||
let chunkSize = 7 + payloadSize
|
||||
bytes_left -= chunkSize
|
||||
const chunkSize = 7 + payloadSize
|
||||
bytes_left -= chunkSize
|
||||
decodedLength += chunkSize
|
||||
chunkNumber++
|
||||
|
||||
// Limit coefficient dump to first 3 chunks
|
||||
if (dumpCoeffs && chunkNumber >= 3) {
|
||||
serial.println(`... (remaining chunks omitted)`)
|
||||
// Keep playing but don't dump more
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
} catch (e) {
|
||||
printerrln(e)
|
||||
errorlevel = 1
|
||||
}
|
||||
finally {
|
||||
if (interactive) {
|
||||
con.move(cy + 3, 1)
|
||||
con.curs_set(1)
|
||||
}
|
||||
} finally {
|
||||
if (interactive) gui.audioClose()
|
||||
}
|
||||
|
||||
return errorlevel
|
||||
|
||||
1054
assets/disk0/tvdos/bin/playtaud.js
Normal file
1054
assets/disk0/tvdos/bin/playtaud.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1746,7 +1746,9 @@ try {
|
||||
tadInitialised = true
|
||||
}
|
||||
|
||||
seqread.readBytes(payloadLen, SND_MEM_ADDR - 262144)
|
||||
// tadInputBin lives at audio-local offset 917504 (post-bef85f6 memory map);
|
||||
// the previous 262144 offset now points into the enlarged sampleBin.
|
||||
seqread.readBytes(payloadLen, SND_MEM_ADDR - 917504)
|
||||
audio.tadDecode()
|
||||
audio.tadUploadDecoded(AUDIO_DEVICE, sampleLen)
|
||||
}
|
||||
|
||||
@@ -307,7 +307,7 @@ for (let i = 0; i < cueElements.length; i++) {
|
||||
// Execute the player with modified environment
|
||||
exec_args[1] = targetPath
|
||||
if (playerFile) {
|
||||
let playerPath = `A:\\tvdos\\bin\\${playerFile}.js`
|
||||
let playerPath = `A:${_TVDOS.variables.DOSDIR}/bin/${playerFile}.js`
|
||||
if (files.open(playerPath).exists) {
|
||||
eval(files.readText(playerPath))
|
||||
} else {
|
||||
@@ -334,7 +334,7 @@ for (let i = 0; i < cueElements.length; i++) {
|
||||
}
|
||||
|
||||
// Execute the appropriate player
|
||||
let playerPath = `A:\\tvdos\\bin\\${playerFile}.js`
|
||||
let playerPath = `A:${_TVDOS.variables.DOSDIR}/bin/${playerFile}.js`
|
||||
if (!files.open(playerPath).exists) {
|
||||
serial.println(`Warning: Player script not found: ${playerPath}`)
|
||||
continue
|
||||
|
||||
@@ -1,329 +1,189 @@
|
||||
// usage: playwav audiofile.wav [/i]
|
||||
let fileeeee = files.open(_G.shell.resolvePathInput(exec_args[1]).full)
|
||||
let filename = fileeeee.fullPath
|
||||
// playwav — WAV (LPCM/ADPCM) player with the shared playgui visualiser.
|
||||
// Usage: playwav <file.wav> [-i]
|
||||
|
||||
const fileHandle = files.open(_G.shell.resolvePathInput(exec_args[1]).full)
|
||||
const filePath = fileHandle.fullPath
|
||||
|
||||
const WAV_FORMATS = ["LPCM", "ADPCM"]
|
||||
const WAV_CHANNELS = ["Mono", "Stereo", "3ch", "Quad", "4.1", "5.1", "6.1", "7.1"]
|
||||
const interactive = exec_args[2] && exec_args[2].toLowerCase() === "-i"
|
||||
|
||||
const seqread = require("seqread")
|
||||
const pcm = require("pcm")
|
||||
const gui = interactive ? require("playgui") : null
|
||||
|
||||
function printdbg(s) { if (0) serial.println(s) }
|
||||
|
||||
const WAV_FORMATS = ["LPCM", "ADPCM"]
|
||||
const WAV_CHANNELS = ["Mono", "Stereo", "3ch", "Quad", "4.1", "5.1", "6.1", "7.1"]
|
||||
const interactive = exec_args[2] && exec_args[2].toLowerCase() == "-i"
|
||||
const seqread = require("seqread")
|
||||
const pcm = require("pcm")
|
||||
|
||||
|
||||
|
||||
function printComments() {
|
||||
for (const [key, value] of Object.entries(comments)) {
|
||||
printdbg(`Wave Comment ${key}: ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
function GCD(a, b) {
|
||||
a = Math.abs(a)
|
||||
b = Math.abs(b)
|
||||
if (b > a) {var temp = a; a = b; b = temp}
|
||||
a = Math.abs(a); b = Math.abs(b)
|
||||
if (b > a) { const t = a; a = b; b = t }
|
||||
while (true) {
|
||||
if (b == 0) return a
|
||||
if (b === 0) return a
|
||||
a %= b
|
||||
if (a == 0) return b
|
||||
if (a === 0) return b
|
||||
b %= a
|
||||
}
|
||||
}
|
||||
function LCM(a, b) { return (!a || !b) ? 0 : Math.abs((a * b) / GCD(a, b)) }
|
||||
|
||||
function LCM(a, b) {
|
||||
return (!a || !b) ? 0 : Math.abs((a * b) / GCD(a, b))
|
||||
}
|
||||
|
||||
|
||||
|
||||
//println("Reading...")
|
||||
//serial.println("!!! READING")
|
||||
|
||||
seqread.prepare(filename)
|
||||
|
||||
|
||||
|
||||
|
||||
// decode header
|
||||
if (seqread.readFourCC() != "RIFF") {
|
||||
throw Error("File not RIFF")
|
||||
}
|
||||
|
||||
const FILE_SIZE = seqread.readInt() // size from "WAVEfmt"
|
||||
|
||||
if (seqread.readFourCC() != "WAVE") {
|
||||
throw Error("File is RIFF but not WAVE")
|
||||
}
|
||||
seqread.prepare(filePath)
|
||||
if (seqread.readFourCC() !== "RIFF") throw Error("File not RIFF")
|
||||
const FILE_SIZE = seqread.readInt()
|
||||
if (seqread.readFourCC() !== "WAVE") throw Error("File is RIFF but not WAVE")
|
||||
|
||||
let BLOCK_SIZE = 0
|
||||
let INFILE_BLOCK_SIZE = 0
|
||||
const QUEUE_MAX = 8 // according to the spec
|
||||
const QUEUE_MAX = 8
|
||||
|
||||
let pcmType;
|
||||
let nChannels;
|
||||
let samplingRate;
|
||||
let blockSize;
|
||||
let bitsPerSample;
|
||||
let byterate;
|
||||
let comments = {};
|
||||
let adpcmSamplesPerBlock;
|
||||
let readPtr = undefined
|
||||
let decodePtr = undefined
|
||||
let pcmType, nChannels, samplingRate, blockSize, bitsPerSample, byterate
|
||||
let adpcmSamplesPerBlock
|
||||
let readPtr, decodePtr
|
||||
const comments = {}
|
||||
|
||||
function bytesToSec(i) {
|
||||
if (adpcmSamplesPerBlock) {
|
||||
let newByteRate = samplingRate
|
||||
let generatedSamples = i / blockSize * adpcmSamplesPerBlock
|
||||
return generatedSamples / newByteRate
|
||||
}
|
||||
else {
|
||||
return i / byterate
|
||||
const generatedSamples = i / blockSize * adpcmSamplesPerBlock
|
||||
return generatedSamples / samplingRate
|
||||
}
|
||||
return i / byterate
|
||||
}
|
||||
function secToReadable(n) {
|
||||
let mins = ''+((n/60)|0)
|
||||
let secs = ''+(n % 60)
|
||||
return `${mins.padStart(2,'0')}:${secs.padStart(2,'0')}`
|
||||
}
|
||||
|
||||
function checkIfPlayable() {
|
||||
if (pcmType != 1 && pcmType != 2) return `PCM Type not LPCM/ADPCM (${pcmType})`
|
||||
if (pcmType !== 1 && pcmType !== 2) return `PCM Type not LPCM/ADPCM (${pcmType})`
|
||||
if (nChannels < 1 || nChannels > 2) return `Audio not mono/stereo but instead has ${nChannels} channels`
|
||||
if (pcmType != 1 && samplingRate != pcm.HW_SAMPLING_RATE) return `Format is ADPCM but sampling rate is not ${pcm.HW_SAMPLING_RATE}: ${samplingRate}`
|
||||
if (pcmType !== 1 && samplingRate !== pcm.HW_SAMPLING_RATE)
|
||||
return `Format is ADPCM but sampling rate is not ${pcm.HW_SAMPLING_RATE}: ${samplingRate}`
|
||||
return "playable!"
|
||||
}
|
||||
// @return decoded sample length (not count!)
|
||||
|
||||
function decodeInfilePcm(inPtr, outPtr, inputLen) {
|
||||
// LPCM
|
||||
if (1 == pcmType)
|
||||
if (pcmType === 1)
|
||||
return pcm.decodeLPCM(inPtr, outPtr, inputLen, { nChannels, bitsPerSample, samplingRate, blockSize })
|
||||
else if (2 == pcmType)
|
||||
if (pcmType === 2)
|
||||
return pcm.decodeMS_ADPCM(inPtr, outPtr, inputLen, { nChannels })
|
||||
else
|
||||
throw Error(`PCM Type not LPCM or ADPCM (${pcmType})`)
|
||||
throw Error(`PCM Type not LPCM or ADPCM (${pcmType})`)
|
||||
}
|
||||
|
||||
let stopPlay = false
|
||||
|
||||
|
||||
con.curs_set(0)
|
||||
let [__, CONSOLE_WIDTH] = con.getmaxyx()
|
||||
function printPlayerShell() {
|
||||
if (interactive) {
|
||||
let [cy, cx] = con.getyx()
|
||||
// file name
|
||||
con.mvaddch(cy, 1)
|
||||
con.prnch(0xC9);con.prnch(0xCD);con.prnch(0xB5)
|
||||
print(fileeeee.name)
|
||||
con.prnch(0xC6);con.prnch(0xCD)
|
||||
print("\x84205u".repeat(CONSOLE_WIDTH - 26 - fileeeee.name.length))
|
||||
con.prnch(0xB5)
|
||||
print("Hold Bksp to Exit")
|
||||
con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBB)
|
||||
|
||||
// L R pillar
|
||||
con.prnch(0xBA)
|
||||
con.mvaddch(cy+1, CONSOLE_WIDTH, 0xBA)
|
||||
|
||||
// media info
|
||||
let mediaInfoStr = `WAV ${WAV_FORMATS[pcmType-1]} ${WAV_CHANNELS[nChannels-1]} ${byterate*0.008*(pcmType == 2 ? 2 : 1)}kbps`
|
||||
con.move(cy+2,1)
|
||||
con.prnch(0xC8)
|
||||
print("\x84205u".repeat(CONSOLE_WIDTH - 5 - mediaInfoStr.length))
|
||||
con.prnch(0xB5)
|
||||
print(mediaInfoStr)
|
||||
con.prnch(0xC6);con.prnch(0xCD);con.prnch(0xBC)
|
||||
|
||||
con.move(cy+1, 2)
|
||||
}
|
||||
}
|
||||
let [cy, cx] = con.getyx(); cy++
|
||||
let paintWidth = CONSOLE_WIDTH - 20
|
||||
function printPlayBar(startOffset) {
|
||||
if (interactive) {
|
||||
let currently = seqread.getReadCount() - startOffset
|
||||
let total = FILE_SIZE - startOffset - 8
|
||||
|
||||
let currentlySec = Math.round(bytesToSec(currently))
|
||||
let totalSec = Math.round(bytesToSec(total))
|
||||
|
||||
con.move(cy, 3)
|
||||
print(' '.repeat(15))
|
||||
con.move(cy, 3)
|
||||
|
||||
print(`${secToReadable(currentlySec)} / ${secToReadable(totalSec)}`)
|
||||
|
||||
con.move(cy, 17)
|
||||
print(' ')
|
||||
let progressbar = '\x84196u'.repeat(paintWidth + 1)
|
||||
print(progressbar)
|
||||
|
||||
con.mvaddch(cy, 18 + Math.round(paintWidth * (currently / total)), 0xDB)
|
||||
}
|
||||
}
|
||||
let errorlevel = 0
|
||||
// read chunks loop
|
||||
try {
|
||||
while (!stopPlay && seqread.getReadCount() < FILE_SIZE - 8) {
|
||||
let chunkName = seqread.readFourCC()
|
||||
let chunkSize = seqread.readInt()
|
||||
printdbg(`Reading '${chunkName}' at ${seqread.getReadCount() - 8}`)
|
||||
|
||||
// here be lotsa if-else
|
||||
if ("fmt " == chunkName) {
|
||||
pcmType = seqread.readShort()
|
||||
nChannels = seqread.readShort()
|
||||
samplingRate = seqread.readInt()
|
||||
byterate = seqread.readInt()
|
||||
blockSize = seqread.readShort()
|
||||
bitsPerSample = seqread.readShort()
|
||||
if (pcmType != 2) {
|
||||
seqread.skip(chunkSize - 16)
|
||||
try {
|
||||
while (!stopPlay && seqread.getReadCount() < FILE_SIZE - 8) {
|
||||
const chunkName = seqread.readFourCC()
|
||||
const chunkSize = seqread.readInt()
|
||||
printdbg(`Reading '${chunkName}' at ${seqread.getReadCount() - 8}`)
|
||||
|
||||
if (chunkName === "fmt ") {
|
||||
pcmType = seqread.readShort()
|
||||
nChannels = seqread.readShort()
|
||||
samplingRate = seqread.readInt()
|
||||
byterate = seqread.readInt()
|
||||
blockSize = seqread.readShort()
|
||||
bitsPerSample = seqread.readShort()
|
||||
if (pcmType !== 2) {
|
||||
seqread.skip(chunkSize - 16)
|
||||
} else {
|
||||
seqread.skip(2)
|
||||
adpcmSamplesPerBlock = seqread.readShort()
|
||||
seqread.skip(chunkSize - (16 + 4))
|
||||
}
|
||||
|
||||
if (pcmType === 1) {
|
||||
const incr = LCM(blockSize, samplingRate / GCD(samplingRate, pcm.HW_SAMPLING_RATE))
|
||||
while (BLOCK_SIZE < 4096) BLOCK_SIZE += incr
|
||||
INFILE_BLOCK_SIZE = BLOCK_SIZE * bitsPerSample / 8
|
||||
} else if (pcmType === 2) {
|
||||
BLOCK_SIZE = blockSize
|
||||
INFILE_BLOCK_SIZE = BLOCK_SIZE
|
||||
}
|
||||
|
||||
if (interactive) {
|
||||
const tag = "WAV"
|
||||
const title = fileHandle.name +
|
||||
` ${WAV_FORMATS[pcmType-1]} ${WAV_CHANNELS[nChannels-1]} ${byterate*0.008*(pcmType === 2 ? 2 : 1)}kbps`
|
||||
gui.audioInit({ title, tag })
|
||||
}
|
||||
}
|
||||
else if (chunkName === "LIST") {
|
||||
const startOffset = seqread.getReadCount()
|
||||
const subChunkName = seqread.readFourCC()
|
||||
while (seqread.getReadCount() < startOffset + chunkSize) {
|
||||
if (subChunkName === "INFO") {
|
||||
let key = seqread.readFourCC()
|
||||
let valueLen = seqread.readInt()
|
||||
while (key.charCodeAt(0) === 0) {
|
||||
const kbytes = [key.charCodeAt(1), key.charCodeAt(2), key.charCodeAt(3), valueLen & 255]
|
||||
const klen = [(valueLen >>> 8) & 255, (valueLen >>> 16) & 255, (valueLen >>> 24) & 255, seqread.readOneByte()]
|
||||
key = String.fromCharCode.apply(null, kbytes)
|
||||
valueLen = klen[0] | (klen[1] << 8) | (klen[2] << 16) | (klen[3] << 24)
|
||||
}
|
||||
comments[key] = seqread.readString(valueLen)
|
||||
} else {
|
||||
seqread.skip(startOffset + chunkSize - seqread.getReadCount())
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (chunkName === "data") {
|
||||
const startOffset = seqread.getReadCount()
|
||||
const reason = checkIfPlayable()
|
||||
if (reason !== "playable!") throw Error("WAVE not playable: " + reason)
|
||||
|
||||
readPtr = sys.malloc(pcmType === 2 ? BLOCK_SIZE : BLOCK_SIZE * bitsPerSample / 8)
|
||||
decodePtr = sys.malloc(BLOCK_SIZE * pcm.HW_SAMPLING_RATE / samplingRate)
|
||||
|
||||
audio.resetParams(0)
|
||||
audio.purgeQueue(0)
|
||||
audio.setPcmMode(0)
|
||||
audio.setMasterVolume(0, 255)
|
||||
|
||||
let readLength = 1
|
||||
while (!stopPlay && seqread.getReadCount() < startOffset + chunkSize && readLength > 0) {
|
||||
if (interactive && gui.audioIsExitRequested()) { stopPlay = true; break }
|
||||
|
||||
if (audio.getPosition(0) <= 1) {
|
||||
for (let repeat = 0; repeat < QUEUE_MAX; repeat++) {
|
||||
const remainingBytes = FILE_SIZE - 8 - seqread.getReadCount()
|
||||
readLength = (remainingBytes < INFILE_BLOCK_SIZE) ? remainingBytes : INFILE_BLOCK_SIZE
|
||||
if (readLength <= 0) break
|
||||
|
||||
seqread.readBytes(readLength, readPtr)
|
||||
const decodedSampleLength = decodeInfilePcm(readPtr, decodePtr, readLength)
|
||||
|
||||
// Hand the decoded PCMu8 stereo block to the visualiser
|
||||
// before queueing — the buffer is reused next iteration.
|
||||
if (interactive) gui.audioFeedPcm(decodePtr, decodedSampleLength >> 1)
|
||||
|
||||
audio.putPcmDataByPtr(0, decodePtr, decodedSampleLength, 0)
|
||||
audio.setSampleUploadLength(0, decodedSampleLength)
|
||||
audio.startSampleUpload(0)
|
||||
|
||||
sys.spin()
|
||||
}
|
||||
audio.play(0)
|
||||
}
|
||||
|
||||
if (interactive) {
|
||||
const cur = seqread.getReadCount() - startOffset
|
||||
const tot = FILE_SIZE - startOffset - 8
|
||||
gui.audioSetProgress(cur / tot, bytesToSec(cur), bytesToSec(tot))
|
||||
gui.audioRender()
|
||||
}
|
||||
sys.sleep(10)
|
||||
}
|
||||
}
|
||||
else {
|
||||
seqread.skip(2)
|
||||
adpcmSamplesPerBlock = seqread.readShort()
|
||||
seqread.skip(chunkSize - (16 + 4))
|
||||
seqread.skip(chunkSize)
|
||||
}
|
||||
|
||||
// define BLOCK_SIZE as integer multiple of blockSize, for LPCM
|
||||
// ADPCM will be decoded per-block basis
|
||||
if (1 == pcmType) {
|
||||
// get GCD of given values; this wll make resampling headache-free
|
||||
let blockSizeIncrement = LCM(blockSize, samplingRate / GCD(samplingRate, pcm.HW_SAMPLING_RATE))
|
||||
|
||||
while (BLOCK_SIZE < 4096) {
|
||||
BLOCK_SIZE += blockSizeIncrement // for rate 44100, BLOCK_SIZE will be 4116
|
||||
}
|
||||
INFILE_BLOCK_SIZE = BLOCK_SIZE * bitsPerSample / 8 // for rate 44100, INFILE_BLOCK_SIZE will be 8232
|
||||
}
|
||||
else if (2 == pcmType) {
|
||||
BLOCK_SIZE = blockSize
|
||||
INFILE_BLOCK_SIZE = BLOCK_SIZE
|
||||
}
|
||||
|
||||
printdbg(`Format: ${pcmType}, Channels: ${nChannels}, Rate: ${samplingRate}, BitDepth: ${bitsPerSample}`)
|
||||
printdbg(`BLOCK_SIZE=${BLOCK_SIZE}, INFILE_BLOCK_SIZE=${INFILE_BLOCK_SIZE}`)
|
||||
printPlayerShell()
|
||||
sys.spin()
|
||||
}
|
||||
else if ("LIST" == chunkName) {
|
||||
let startOffset = seqread.getReadCount()
|
||||
let subChunkName = seqread.readFourCC()
|
||||
while (seqread.getReadCount() < startOffset + chunkSize) {
|
||||
if ("INFO" == subChunkName) {
|
||||
let key = seqread.readFourCC()
|
||||
let valueLen = seqread.readInt()
|
||||
|
||||
// f-you WAVE encoders with nonstandard behaviours
|
||||
// related: https://stackoverflow.com/questions/49537639/riff-icmt-tag-size-doesnt-seem-to-match-data
|
||||
while (0 == key.charCodeAt(0)) {
|
||||
printdbg(`Previous key had more zero bytes padded than its marked length, skipping one byte...`)
|
||||
|
||||
let kbytes = [key.charCodeAt(1), key.charCodeAt(2), key.charCodeAt(3), valueLen & 255]
|
||||
let klen = [(valueLen >>> 8) & 255, (valueLen >>> 16) & 255, (valueLen >>> 24) & 255, seqread.readOneByte()]
|
||||
|
||||
key = String.fromCharCode.apply(null, kbytes)
|
||||
valueLen = klen[0] | (klen[1] << 8) | (klen[2] << 16) | (klen[3] << 24)
|
||||
}
|
||||
|
||||
printdbg(`Reading LIST INFO ${key}[${[0,1,2,3].map((i)=>"0x"+key.charCodeAt(i).toString(16).padStart(2,'0'))}] (${valueLen} bytes): `)
|
||||
|
||||
|
||||
let value = seqread.readString(valueLen)
|
||||
printdbg(" |"+value)
|
||||
comments[key] = value
|
||||
}
|
||||
else {
|
||||
printdbg(`LIST skip subchunk ${subChunkName} (${startOffset + chunkSize - seqread.getReadCount()} bytes)`)
|
||||
seqread.skip(startOffset + chunkSize - seqread.getReadCount())
|
||||
}
|
||||
}
|
||||
printComments()
|
||||
}
|
||||
else if ("data" == chunkName) {
|
||||
let startOffset = seqread.getReadCount()
|
||||
|
||||
printdbg(`WAVE size: ${chunkSize}, startOffset=${startOffset}`)
|
||||
// check if the format is actually playable
|
||||
let unplayableReason = checkIfPlayable()
|
||||
if (unplayableReason != "playable!") throw Error("WAVE not playable: "+unplayableReason)
|
||||
|
||||
if (pcmType == 2)
|
||||
readPtr = sys.malloc(BLOCK_SIZE)
|
||||
else
|
||||
readPtr = sys.malloc(BLOCK_SIZE * bitsPerSample / 8)
|
||||
|
||||
decodePtr = sys.malloc(BLOCK_SIZE * pcm.HW_SAMPLING_RATE / samplingRate)
|
||||
|
||||
audio.resetParams(0)
|
||||
audio.purgeQueue(0)
|
||||
audio.setPcmMode(0)
|
||||
audio.setMasterVolume(0, 255)
|
||||
|
||||
let readLength = 1
|
||||
while (!stopPlay && seqread.getReadCount() < startOffset + chunkSize && readLength > 0) {
|
||||
if (interactive) {
|
||||
sys.poke(-40, 1)
|
||||
if (sys.peek(-41) == 67) {
|
||||
stopPlay = true
|
||||
}
|
||||
}
|
||||
|
||||
printPlayBar(startOffset)
|
||||
|
||||
let queueSize = audio.getPosition(0)
|
||||
if (queueSize <= 1) {
|
||||
|
||||
|
||||
// upload four samples for lag-safely
|
||||
for (let repeat = 0; repeat < QUEUE_MAX; repeat++) {
|
||||
let remainingBytes = FILE_SIZE - 8 - seqread.getReadCount()
|
||||
|
||||
readLength = (remainingBytes < INFILE_BLOCK_SIZE) ? remainingBytes : INFILE_BLOCK_SIZE
|
||||
if (readLength <= 0) {
|
||||
printdbg(`readLength = ${readLength}`)
|
||||
break
|
||||
}
|
||||
|
||||
printdbg(`offset: ${seqread.getReadCount()}/${FILE_SIZE + 8}; readLength: ${readLength}`)
|
||||
|
||||
seqread.readBytes(readLength, readPtr)
|
||||
|
||||
let decodedSampleLength = decodeInfilePcm(readPtr, decodePtr, readLength)
|
||||
printdbg(` decodedSampleLength: ${decodedSampleLength}`)
|
||||
|
||||
audio.putPcmDataByPtr(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) {
|
||||
} catch (e) {
|
||||
printerrln(e)
|
||||
errorlevel = 1
|
||||
}
|
||||
finally {
|
||||
//audio.stop(0)
|
||||
if (readPtr !== undefined) sys.free(readPtr)
|
||||
} finally {
|
||||
if (readPtr !== undefined) sys.free(readPtr)
|
||||
if (decodePtr !== undefined) sys.free(decodePtr)
|
||||
if (interactive) gui.audioClose()
|
||||
}
|
||||
|
||||
return errorlevel
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@
|
||||
* Rows 1-3 are owned by the parent; this program draws rows 4+.
|
||||
*
|
||||
* exec_args[1] = path to .taud file
|
||||
* Sets _G.taut_nextPanel before returning to request a panel switch.
|
||||
* Sets _G.TAUT.UI.NEXTPANEL before returning to request a panel switch.
|
||||
*
|
||||
* Created by minjaesong on 2026-04-27
|
||||
*/
|
||||
@@ -65,7 +65,7 @@ while (!done) {
|
||||
if (!keyJustHit) return
|
||||
|
||||
if (keysym === '<TAB>') {
|
||||
_G.taut_nextPanel = (MY_PANEL + (shiftDown ? -1 : 1) + PANEL_COUNT) % PANEL_COUNT
|
||||
_G.TAUT.UI.NEXTPANEL = (MY_PANEL + (shiftDown ? -1 : 1) + PANEL_COUNT) % PANEL_COUNT
|
||||
done = true
|
||||
return
|
||||
}
|
||||
|
||||
171
assets/disk0/tvdos/bin/taut_helpmsg.js
Normal file
171
assets/disk0/tvdos/bin/taut_helpmsg.js
Normal file
@@ -0,0 +1,171 @@
|
||||
if (!_G.TAUT) _G.TAUT = {};
|
||||
let help = {}
|
||||
|
||||
let ts = require("typesetter")
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
/*
|
||||
Tags:
|
||||
<b> - print the text in emphasis colour (colVoiceHdr aka 230)
|
||||
<c> - centre the line. If the line spans multiple lines, centre each line
|
||||
<r> - align right
|
||||
<l> - align left
|
||||
<o> - create virtual typesetting box. Left anchor: where the text cursor is. Right anchor: end of the line
|
||||
µtone; - replace with the brand string (<col 211>Micro</col><col 239>tone</col>)
|
||||
|
||||
&bul; - replace with bullet (\u00F9)
|
||||
&ddot; - replace with double-dot (\u008419u)
|
||||
&mdot; - replace with BIGDOT (\u00FA)
|
||||
&updn; - up-down arrow (\u008418u)
|
||||
&udlr; - four direction arrow (\u008428u\u008429u)
|
||||
|
||||
&keyoffsym; - pattern view key-off symbol (\u00A0\u00B1\u00B1\u00A1)
|
||||
¬ecutsym; - pattern view note-cut symbol (\u00A4\u00A4\u00A4\u00A4)
|
||||
|
||||
&demisharp;
|
||||
♯
|
||||
&sesquisharp;
|
||||
&doublesharp;
|
||||
&triplesharp;
|
||||
&quadsharp;
|
||||
&demiflat;
|
||||
♭
|
||||
&sesquiflat;
|
||||
&doubleflat;
|
||||
&tripleflat;
|
||||
&quadflat;
|
||||
&accuptick;
|
||||
&accdntick;
|
||||
&accupup;
|
||||
&accdndn;
|
||||
|
||||
- nonbreakable space (only meaningful for typesetters)
|
||||
­ - soft hyphen (only meaningful for typesetters)
|
||||
|
||||
default alignment: fully justified
|
||||
*/
|
||||
|
||||
let helpNotation = `<c>CONTROL NOTATION</c>
|
||||
<c>\u00B7${'\u00B8'.repeat(16)}\u00B9</c>
|
||||
µtone; <O>shortcuts differentiate normal and shifted shortcuts.</O>
|
||||
&bul;<b>a</b>&ddot;<b>z</b> : <O>alphabet without shift-in</O>
|
||||
&bul;<b>A</b>&ddot;<b>Z</b> : <O>alphabet with shift-in</O>
|
||||
&bul;<b>^q</b> : <O>hit 'q' with control key</O>
|
||||
&bul;<b>^Q</b> : <O>hit 'q' with control and shift key</O>
|
||||
`
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
let helpJam = `<c>NOTE JAMMING</c>
|
||||
<c>\u00B7${'\u00B8'.repeat(12)}\u00B9</c>
|
||||
Push keys to play or insert notes.
|
||||
w e t y u
|
||||
a s d f g h j k
|
||||
`
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
let helpCommon = `<c>COMMON CONTROLS</c>
|
||||
<c>\u00B7${'\u00B8'.repeat(15)}\u00B9</c>
|
||||
&bul;<b>!</b> : <O>show this help message</O>
|
||||
&bul;<b>Y</b> : <O>plays the entire song from the current cue</O>
|
||||
&bul;<b>U</b> : <O>plays the current cue then stop</O>
|
||||
&bul;<b>I</b> : <O>plays the current row</O>
|
||||
&bul;<b>O</b> : <O>stops the playback</O>
|
||||
&bul;<b>tab</b> : <O>switchs forward a tab</O>
|
||||
&bul;<b>TAB</b> : <O>switchs backward a tab</O>
|
||||
&bul;<b>q</b> : <O>closes µtone;</O>
|
||||
`
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
let helpTimeline = `<c>TIMELINE VIEW</c>
|
||||
<c>\u00B7${'\u00B8'.repeat(13)}\u00B9</c>
|
||||
Timeline has two distinct modes: view and edit mode. Two modes are toggled using the space bar.
|
||||
|
||||
<b> VIEW MODE</b>
|
||||
<b>\u00B7${'\u00B8'.repeat(9)}\u00B9</b>
|
||||
&bul;Note jamming : <O>plays the note</O>
|
||||
&bul;<b>&udlr;</b> : <O>moves the viewing cursor by voices and rows</O>
|
||||
&bul;<b>pg&updn;</b> : <O>goes to previous/next cue</O>
|
||||
&bul;<b>W</b>&mdot;<b>E</b>&mdot;<b>R</b> : <O>toggles timeline view mode. W-most detailed, R-most abridged</O>
|
||||
&bul;<b>n</b> : <O>toggles soloing of the selected voice</O>
|
||||
&bul;<b>m</b> : <O>toggles muting of the selected voice</O>
|
||||
&bul;<b>[</b>&mdot;<b>]</b> : <O>changes tick rate of playhead</O>
|
||||
|
||||
<b> EDIT MODE</b>
|
||||
<b>\u00B7${'\u00B8'.repeat(9)}\u00B9</b>
|
||||
&bul;Note jamming : <O>(note column) inserts the note</O>
|
||||
&bul;<b>{</b>&mdot;<b>}</b> : <O>(note column) lowers/raises a note by one octave (or period)</O>
|
||||
&bul;<b>[</b>&mdot;<b>]</b> : <O>(note column) lowers/raises a note by one unit</O>
|
||||
&bul;<b>z</b> : <O>(note column) inserts a key-off &keyoffsym;</O>
|
||||
&bul;<b>x</b> : <O>(note column) inserts a note-cut ¬ecutsym;</O>
|
||||
&bul;<b>.</b> : <O>clears fields</O>
|
||||
&bul;<b>bksp</b> : <O>deletes one character on the selected column</O>
|
||||
&bul;<b>0</b>&ddot;<b>9</b> <b>a</b>&ddot;<b>f</b> : <O>inserts a (hexa)decimal number</O>
|
||||
&bul;<b>0</b>&ddot;<b>9</b> <b>a</b>&ddot;<b>z</b> : <O>(fx column) inserts an effect</O>
|
||||
&bul;<b>^</b>&mdot;<b>v</b> : <O>(volume column) slide up/down</O>
|
||||
&bul;<b><</b>&mdot;<b>></b>: <O>(panning column) slide left/right</O>
|
||||
&bul;<b>-</b>&mdot;<b>=</b> : <O>(vol/pan col) fine slide down/up</O>
|
||||
&bul;<b>&udlr;</b> : <O>moves the viewing cursor by columns and rows</O>
|
||||
&bul;<b>pg&updn;</b> : <O>goes to previous/next cue</O>
|
||||
|
||||
<b> ACCIDENTALS</b>
|
||||
<b>\u00B7${'\u00B8'.repeat(11)}\u00B9</b>
|
||||
&demisharp; ♯ &doublesharp; &triplesharp; &quadsharp; &demiflat; ♭ &doubleflat; &tripleflat; &accuptick; &accupup; &accdntick; &accdndn;
|
||||
<b>C c cx x xx B b bb bbb ^ ^^ v vv</b>
|
||||
|
||||
<b> GLOBAL EDIT</b>
|
||||
<b>\u00B7${'\u00B8'.repeat(11)}\u00B9</b>
|
||||
&bul;<b>Q</b> : <O>retunes current song into different tuning and strategy. In general, nearest-note works best for macrotonals, nearest-harmonic and nearest-delta works best for highly microtonals (31+); 17- and 19-TET takes nearest-harmonic pretty well, while 22-TET seem to only benefit from the nearest-note</O>
|
||||
`
|
||||
|
||||
let helpProjectFlags = `<c>MIXER FLAGS</c>
|
||||
<c>\u00B7${'\u00B8'.repeat(11)}\u00B9</c>
|
||||
Mixer flags define how should the mixer behave.
|
||||
|
||||
<b> TONE MODE</b>
|
||||
<b>\u00B7${'\u00B8'.repeat(9)}\u00B9</b>
|
||||
&bul;Linear pitch : <O>pitch shift effects operate on linear pitch scale. The default and recommended setting for a new project</O>
|
||||
&bul;Amiga pitch : <O>pitch shift effects operate on Amiga period scale. Backwards compatible setting for MOD/S3M/XM/IT formats</O>
|
||||
&bul;Linear freq : <O>pitch shift effects operate on linear frequency scale. Backwards compatible setting for MONOTONE format</O>
|
||||
|
||||
<b> INTERPOLATION</b>
|
||||
<b>\u00B7${'\u00B8'.repeat(13)}\u00B9</b>
|
||||
&bul;Default : <O>three-tap fast sinc interpolation. The default and recommended setting for a new project</O>
|
||||
&bul;None : <O>zeroth-order hold</O>
|
||||
&bul;A500 : <O>emulates what Paula chip of Amiga 500 does. <b>S 0x00</b> effects only work with this and Amiga 1200 mode</O>
|
||||
&bul;A1200 : <O>emulates what Paula chip of Amiga 1200 does</O>
|
||||
&bul;SNES : <O>four-tap gaussian interpolation used by SNES</O>
|
||||
&bul;DPCM : <O>simulates Differential Pulse Code Modulation used by NES</O>
|
||||
`
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// assemble help text pieces to complete help message
|
||||
|
||||
const HRULE = '\u00B4\u00B5'.repeat((_G.TAUT.HELPMSG_WIDTH) >>> 1) + '\n'
|
||||
|
||||
// taut.js's popup uses (HELP_COL_TEXT on background) as the default colour pair.
|
||||
// The shared typesetter module owns the palette and the markup expander.
|
||||
function typeset(text) {
|
||||
return ts.typeset(text, _G.TAUT.HELPMSG_WIDTH)
|
||||
}
|
||||
|
||||
let helpMessages = [ // index: taut.js PANEL_NAMES
|
||||
/* Timeline */[helpJam, helpTimeline, helpCommon, helpNotation].join(HRULE),
|
||||
/* Cues */[helpCommon, helpNotation].join(HRULE), // placeholder
|
||||
/* Patterns */[helpCommon, helpNotation].join(HRULE), // placeholder
|
||||
/* Samples */[helpCommon, helpNotation].join(HRULE), // placeholder
|
||||
/* Instruments */[helpCommon, helpNotation].join(HRULE), // placeholder
|
||||
/* Project */[helpProjectFlags, helpCommon, helpNotation].join(HRULE), // placeholder
|
||||
/* File */[helpCommon, helpNotation].join(HRULE), // placeholder
|
||||
]
|
||||
|
||||
help.MSG_BY_TABS = helpMessages.map(it => typeset(it))
|
||||
help.typeset = typeset
|
||||
help.COL_TEXT = ts.COL_TEXT
|
||||
help.COL_EMPH = ts.COL_EMPH
|
||||
|
||||
if (!_G.TAUT.HELPMSG) _G.TAUT.HELPMSG=help;
|
||||
@@ -4,7 +4,7 @@
|
||||
* Rows 1-3 are owned by the parent; this program draws rows 4+.
|
||||
*
|
||||
* exec_args[1] = path to .taud file
|
||||
* Sets _G.taut_nextPanel before returning to request a panel switch.
|
||||
* Sets _G.TAUT.UI.NEXTPANEL before returning to request a panel switch.
|
||||
*
|
||||
* Created by minjaesong on 2026-04-27
|
||||
*/
|
||||
@@ -65,7 +65,7 @@ while (!done) {
|
||||
if (!keyJustHit) return
|
||||
|
||||
if (keysym === '<TAB>') {
|
||||
_G.taut_nextPanel = (MY_PANEL + (shiftDown ? -1 : 1) + PANEL_COUNT) % PANEL_COUNT
|
||||
_G.TAUT.UI.NEXTPANEL = (MY_PANEL + (shiftDown ? -1 : 1) + PANEL_COUNT) % PANEL_COUNT
|
||||
done = true
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
/**
|
||||
* TAUT Sample Editor
|
||||
* Sub-program launched by taut.js when the Samples tab is active.
|
||||
* Rows 1-3 are owned by the parent; this program draws rows 4+.
|
||||
* TAUT Sample Editor (stub)
|
||||
* Sub-program launched from taut.js's Samples viewer. Rows 1-3 are owned by
|
||||
* the parent; this program draws rows 4+.
|
||||
*
|
||||
* exec_args[1] = path to .taud file
|
||||
* Sets _G.taut_nextPanel before returning to request a panel switch.
|
||||
* exec_args:
|
||||
* [1] = path to .taud file
|
||||
* [2] = parent panel index (where to return)
|
||||
* [3] = sample index to preload (-1 if none)
|
||||
*
|
||||
* Sets _G.TAUT.UI.NEXTPANEL on return to request a panel switch back.
|
||||
*
|
||||
* Created by minjaesong on 2026-04-27
|
||||
* Stub editing UI added on 2026-05-26
|
||||
*/
|
||||
|
||||
const win = require("wintex")
|
||||
|
||||
const PANEL_COUNT = 7
|
||||
const MY_PANEL = 3 // VIEW_SAMPLES
|
||||
const PARENT_PANEL = (exec_args[2] !== undefined) ? (exec_args[2] | 0) : 3 // VIEW_SAMPLES
|
||||
const SAMPLE_IDX = (exec_args[3] !== undefined) ? (exec_args[3] | 0) : -1
|
||||
|
||||
const [SCRH, SCRW] = con.getmaxyx()
|
||||
const PANEL_Y = 4
|
||||
@@ -21,38 +26,122 @@ const PANEL_H = SCRH - PANEL_Y
|
||||
const colStatus = 253
|
||||
const colContent = 240
|
||||
const colHdr = 230
|
||||
const colEmph = 211
|
||||
const colDim = 246
|
||||
const colBack = 255
|
||||
const colSel = 41
|
||||
|
||||
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++) {
|
||||
con.move(y, 1)
|
||||
con.color_pair(colContent, 255)
|
||||
con.color_pair(colContent, colBack)
|
||||
print(' '.repeat(SCRW))
|
||||
}
|
||||
// Title
|
||||
con.move(PANEL_Y + 1, 3)
|
||||
con.color_pair(colHdr, 255)
|
||||
print('[ Sample Editor ]')
|
||||
con.move(PANEL_Y + 3, 3)
|
||||
con.color_pair(colStatus, 255)
|
||||
print('placeholder — not yet implemented')
|
||||
con.color_pair(colHdr, colBack); print('[ Sample Editor ] ')
|
||||
con.color_pair(colEmph, colBack); print('Sample ')
|
||||
con.color_pair(colStatus, colBack)
|
||||
if (SAMPLE_IDX >= 0) print('#' + (SAMPLE_IDX + 1).toString(16).toUpperCase().padStart(2, '0'))
|
||||
else print('(none)')
|
||||
|
||||
con.move(PANEL_Y + 2, 3)
|
||||
con.color_pair(colDim, colBack)
|
||||
print('stub editor — actions below are placeholders only.')
|
||||
}
|
||||
|
||||
function drawToolList() {
|
||||
const x = 5
|
||||
const y0 = PANEL_Y + 4
|
||||
con.move(y0, x)
|
||||
con.color_pair(colHdr, colBack); print('Editing actions')
|
||||
con.move(y0 + 1, x)
|
||||
con.color_pair(colDim, colBack); print('-'.repeat(16))
|
||||
|
||||
for (let i = 0; i < TOOLS.length; i++) {
|
||||
const y = y0 + 3 + i
|
||||
const t = TOOLS[i]
|
||||
const sel = (i === toolCursor)
|
||||
const back = sel ? colSel : colBack
|
||||
con.move(y, x)
|
||||
con.color_pair(colHdr, back); print(' ' + t.key + ' ')
|
||||
con.color_pair(colStatus, back); print(' ')
|
||||
con.color_pair(sel ? colEmph : colStatus, back)
|
||||
const w = SCRW - x - 6
|
||||
const lbl = t.label.length > w ? t.label.substring(0, w) : t.label.padEnd(w)
|
||||
print(lbl)
|
||||
}
|
||||
|
||||
// Drawing-area placeholder on the right
|
||||
const dx = 38
|
||||
const dy0 = PANEL_Y + 4
|
||||
const dw = SCRW - dx - 2
|
||||
const dh = SCRH - dy0 - 2
|
||||
con.move(dy0, dx)
|
||||
con.color_pair(colHdr, colBack); print('Waveform editor')
|
||||
con.move(dy0 + 1, dx)
|
||||
con.color_pair(colDim, colBack); print('-'.repeat(16))
|
||||
|
||||
// Empty drawing rectangle made of dots
|
||||
for (let r = 0; r < dh; r++) {
|
||||
con.move(dy0 + 3 + r, dx)
|
||||
con.color_pair(colDim, colBack)
|
||||
if (r === (dh >>> 1)) print('-'.repeat(dw)) // zero line
|
||||
else print(' '.repeat(dw))
|
||||
}
|
||||
con.move(dy0 + 3 + (dh >>> 1) + 1, dx)
|
||||
con.color_pair(colDim, colBack)
|
||||
print('(drawing surface — not yet implemented)')
|
||||
}
|
||||
|
||||
function drawHints() {
|
||||
con.move(SCRH, 1)
|
||||
con.color_pair(colStatus, 255)
|
||||
con.color_pair(colStatus, colBack)
|
||||
print(' '.repeat(SCRW - 1))
|
||||
con.move(SCRH, 1)
|
||||
con.color_pair(colHdr, 255); print('Tab ')
|
||||
con.color_pair(colStatus, 255); print('Panel')
|
||||
con.color_pair(colHdr, colBack); print('28u29u ')
|
||||
con.color_pair(colStatus, colBack); print('Tool ')
|
||||
con.color_pair(colHdr, colBack); print('Enter ')
|
||||
con.color_pair(colStatus, colBack); print('Apply ')
|
||||
con.color_pair(colHdr, colBack); print('Esc/Tab ')
|
||||
con.color_pair(colStatus, colBack); print('Back to viewer')
|
||||
}
|
||||
|
||||
function flashAction(idx) {
|
||||
const t = TOOLS[idx]
|
||||
if (!t) return
|
||||
con.move(SCRH - 2, 5)
|
||||
con.color_pair(colEmph, colBack)
|
||||
print(('Action: ' + t.label + ' (stub, no-op)').padEnd(SCRW - 8))
|
||||
}
|
||||
|
||||
function sampleEditInput(wo, event) {
|
||||
// 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()
|
||||
drawHints()
|
||||
|
||||
let done = false
|
||||
while (!done) {
|
||||
@@ -60,17 +149,32 @@ while (!done) {
|
||||
if (event[0] !== 'key_down') return
|
||||
const keysym = event[1]
|
||||
const keyJustHit = (1 == event[2])
|
||||
const shiftDown = (event.includes(59) || event.includes(60))
|
||||
|
||||
if (!keyJustHit) return
|
||||
|
||||
if (keysym === '<TAB>') {
|
||||
_G.taut_nextPanel = (MY_PANEL + (shiftDown ? -1 : 1) + PANEL_COUNT) % PANEL_COUNT
|
||||
if (keysym === '<ESCAPE>' || keysym === '<TAB>') {
|
||||
_G.TAUT.UI.NEXTPANEL = PARENT_PANEL
|
||||
done = true
|
||||
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.
Binary file not shown.
|
Before Width: | Height: | Size: 385 B After Width: | Height: | Size: 490 B |
Binary file not shown.
File diff suppressed because it is too large
Load Diff
11
assets/disk0/tvdos/hopper/getopt.hop.per
Normal file
11
assets/disk0/tvdos/hopper/getopt.hop.per
Normal file
@@ -0,0 +1,11 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:getopt
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:getopt;
|
||||
HopperRequires:
|
||||
ProperName:getopt.js
|
||||
ProperAuthor:David Pacheco
|
||||
ProperDescription:node.js implementation of POSIX getopt() (and then some)
|
||||
Licence:MIT
|
||||
SystemPackagePath:/tvdos/include/getopt.mjs
|
||||
12
assets/disk0/tvdos/hopper/libfs.hop.per
Normal file
12
assets/disk0/tvdos/hopper/libfs.hop.per
Normal file
@@ -0,0 +1,12 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:libfs
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:libfs;
|
||||
HopperRequires:tvdos 1.*;
|
||||
ProperName:LibFS
|
||||
ProperAuthor:CuriousTorvald
|
||||
ProperDescription:NodeJS-compatible Filesystem module for TVDOS
|
||||
Licence:MIT
|
||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||
SystemPackagePath:/tvdos/include/fs.mjs
|
||||
12
assets/disk0/tvdos/hopper/libgl.hop.per
Normal file
12
assets/disk0/tvdos/hopper/libgl.hop.per
Normal file
@@ -0,0 +1,12 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:libgl
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:libgl;
|
||||
HopperRequires:
|
||||
ProperName:LibGL
|
||||
ProperAuthor:CuriousTorvald
|
||||
ProperDescription:TVDOS Graphics Library
|
||||
Licence:MIT
|
||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||
SystemPackagePath:/tvdos/include/gl.mjs
|
||||
12
assets/disk0/tvdos/hopper/libpcm.hop.per
Normal file
12
assets/disk0/tvdos/hopper/libpcm.hop.per
Normal file
@@ -0,0 +1,12 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:libpcm
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:libpcm;
|
||||
HopperRequires:
|
||||
ProperName:LibPCM
|
||||
ProperAuthor:CuriousTorvald
|
||||
ProperDescription:PCM decoder for TSVM
|
||||
Licence:MIT
|
||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||
SystemPackagePath:/tvdos/include/pcm.mjs
|
||||
12
assets/disk0/tvdos/hopper/libpsg.hop.per
Normal file
12
assets/disk0/tvdos/hopper/libpsg.hop.per
Normal file
@@ -0,0 +1,12 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:libpsg
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:libpsg;
|
||||
HopperRequires:
|
||||
ProperName:LibPSG
|
||||
ProperAuthor:CuriousTorvald
|
||||
ProperDescription:Programmable sound generator library for TSVM
|
||||
Licence:MIT
|
||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||
SystemPackagePath:/tvdos/include/psg.mjs
|
||||
12
assets/disk0/tvdos/hopper/libseqread.hop.per
Normal file
12
assets/disk0/tvdos/hopper/libseqread.hop.per
Normal file
@@ -0,0 +1,12 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:libseqread
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:libseqread;
|
||||
HopperRequires:tvdos 1.*;
|
||||
ProperName:LibSeqread
|
||||
ProperAuthor:CuriousTorvald
|
||||
ProperDescription:Sequentially read files from disk drive
|
||||
Licence:MIT
|
||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||
SystemPackagePath:/tvdos/include/seqread.mjs;/tvdos/include/seqreadtape.mjs
|
||||
12
assets/disk0/tvdos/hopper/libtaud.hop.per
Normal file
12
assets/disk0/tvdos/hopper/libtaud.hop.per
Normal file
@@ -0,0 +1,12 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:libtaud
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:libtaud;
|
||||
HopperRequires:tvdos 1.*;
|
||||
ProperName:LibTaud
|
||||
ProperAuthor:CuriousTorvald
|
||||
ProperDescription:Helper functions for interaction between Taud format and TSVM Tracker
|
||||
Licence:MIT
|
||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||
SystemPackagePath:/tvdos/include/taud.mjs
|
||||
12
assets/disk0/tvdos/hopper/libterranbasic.hop.per
Normal file
12
assets/disk0/tvdos/hopper/libterranbasic.hop.per
Normal file
@@ -0,0 +1,12 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:libterranbasic
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:libterranbasic;
|
||||
HopperRequires:
|
||||
ProperName:LibTerranBasic
|
||||
ProperAuthor:CuriousTorvald
|
||||
ProperDescription:Terran BASIC runtime helper for compiled programs
|
||||
Licence:MIT
|
||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||
SystemPackagePath:/tvdos/include/tbas.mjs
|
||||
12
assets/disk0/tvdos/hopper/microtone.hop.per
Normal file
12
assets/disk0/tvdos/hopper/microtone.hop.per
Normal file
@@ -0,0 +1,12 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:microtone
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:microtone;
|
||||
HopperRequires:tvdos 1.*;wintex 1.*;libtaud 1.*;libgl 1.*
|
||||
ProperName:Microtone
|
||||
ProperAuthor:CuriousTorvald
|
||||
ProperDescription:Microtonal tracker for TSVM
|
||||
Licence:MIT
|
||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||
SystemPackagePath:/tvdos/bin/microtone.alias;/tvdos/bin/taut*
|
||||
17
assets/disk0/tvdos/hopper/mirrors.list
Normal file
17
assets/disk0/tvdos/hopper/mirrors.list
Normal file
@@ -0,0 +1,17 @@
|
||||
# Hopper Mirror List
|
||||
#
|
||||
# One mirror per non-empty, non-comment line.
|
||||
# Each entry is the remote URL prefix from which Hopper can fetch
|
||||
# <prefix>mirror_manifest
|
||||
# <prefix>filelist
|
||||
# <prefix><package>.hop.per (one per row of filelist)
|
||||
#
|
||||
# `mirror_manifest` declares HopperMirrorName, HopperMirrorMaintainer
|
||||
# and HopperMirrorRemotePrefix; `filelist` is CSV of
|
||||
# packagename,version,hoppermanifest-filename
|
||||
#
|
||||
# Lines starting with `#` and empty lines are ignored.
|
||||
# A trailing slash on the prefix is optional; Hopper will add one
|
||||
# if missing.
|
||||
|
||||
https://raw.githubusercontent.com/curioustorvald/hopper-mirror/refs/heads/master/
|
||||
12
assets/disk0/tvdos/hopper/textedit.hop.per
Normal file
12
assets/disk0/tvdos/hopper/textedit.hop.per
Normal file
@@ -0,0 +1,12 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:textedit
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:edit;
|
||||
HopperRequires:tvdos 1.*
|
||||
ProperName:edit.js
|
||||
ProperAuthor:CuriousTorvald
|
||||
ProperDescription:TVDOS default text editor
|
||||
Licence:MIT
|
||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||
SystemPackagePath:/tvdos/bin/edit.js
|
||||
12
assets/disk0/tvdos/hopper/tvdos.hop.per
Normal file
12
assets/disk0/tvdos/hopper/tvdos.hop.per
Normal file
@@ -0,0 +1,12 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:tvdos
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:tvdos;
|
||||
HopperRequires:
|
||||
ProperName:TVDOS
|
||||
ProperAuthor:CuriousTorvald
|
||||
ProperDescription:TSVM Disk Operating System
|
||||
Licence:MIT
|
||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||
SystemPackagePath:/tvdos/TVDOS.SYS;/tvdos/hyve.SYS;/tvdos/HSDPADRV.SYS;/tvdos/bin/command.js;/tvdos/sbin/sysctl.js;/tvdos/include/font.mjs;/tvdos/include/keysym.mjs;/tvdos/include/mload.mjs;/tvdos/include/playgui.mjs;/tvdos/include/typesetter.mjs
|
||||
12
assets/disk0/tvdos/hopper/wintex.hop.per
Normal file
12
assets/disk0/tvdos/hopper/wintex.hop.per
Normal file
@@ -0,0 +1,12 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:wintex
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:wintex;
|
||||
HopperRequires:
|
||||
ProperName:WinTex
|
||||
ProperAuthor:CuriousTorvald
|
||||
ProperDescription:TUI window management and renderer
|
||||
Licence:MIT
|
||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||
SystemPackagePath:/tvdos/include/wintex.mjs
|
||||
12
assets/disk0/tvdos/hopper/zfm.hop.per
Normal file
12
assets/disk0/tvdos/hopper/zfm.hop.per
Normal file
@@ -0,0 +1,12 @@
|
||||
HopperManifestVersion:1
|
||||
HopperPackageName:zfm
|
||||
HopperPackageVersion:1.0.0
|
||||
HopperPackageMaintainer:CuriousTorvald
|
||||
HopperProvides:zfm;
|
||||
HopperRequires:tvdos 1.*;wintex 1.*
|
||||
ProperName:ZFM
|
||||
ProperAuthor:CuriousTorvald
|
||||
ProperDescription:Z File Manager - Dual-panel file manager for TVDOS
|
||||
Licence:MIT
|
||||
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||
SystemPackagePath:/tvdos/bin/zfm*
|
||||
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 }
|
||||
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)
|
||||
}
|
||||
|
||||
// ── 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 = {
|
||||
clearSubtitleArea,
|
||||
displaySubtitle,
|
||||
printTopBar,
|
||||
printBottomBar
|
||||
printBottomBar,
|
||||
audioInit,
|
||||
audioFeedPcm,
|
||||
audioSetProgress,
|
||||
audioRender,
|
||||
audioClose,
|
||||
audioIsExitRequested
|
||||
}
|
||||
@@ -10,7 +10,15 @@ const TAUD_MAGIC = [0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64] // \x1F TSV
|
||||
const TAUD_VERSION = 1
|
||||
const TAUD_HEADER_SIZE = 32 // magic(8) + version(1) + numSongs(1) + compSize(4) + projOff(4) + sig(14)
|
||||
const TAUD_SONG_ENTRY = 32 // see encodeSongEntry / decodeSongEntry below
|
||||
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 NUM_PATTERNS_MAX = 256
|
||||
const NUM_CUES = 1024
|
||||
@@ -75,11 +83,13 @@ function uploadTaudFile(inFile, songIndex, playhead) {
|
||||
pos = 8
|
||||
|
||||
// -- 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 numSongs = sys.peek(filePtr + pos) & 0xFF; pos++
|
||||
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
|
||||
|
||||
if (songIndex < 0 || songIndex >= numSongs) {
|
||||
@@ -88,18 +98,14 @@ function uploadTaudFile(inFile, songIndex, playhead) {
|
||||
}
|
||||
|
||||
// -- 4. Decompress and upload sample+instrument bin -----------------------
|
||||
let decompPtr = sys.malloc(SAMPLEINST_SIZE)
|
||||
gzip.decompFromTo(filePtr + pos, compressedSize, decompPtr)
|
||||
// The decompressed image is 8256 kB (8 MB samples bank-major + 64 K instruments)
|
||||
// which exceeds the 8 MB user-space cap, so we route through a hardware helper
|
||||
// that decompresses straight into the adapter's native sample/instrument
|
||||
// storage instead of staging a buffer in user memory.
|
||||
audio.uploadSampleInstBlob(filePtr + pos, compressedSize)
|
||||
audio.setSampleBank(0)
|
||||
pos += compressedSize
|
||||
|
||||
// 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 --------------------
|
||||
let entryOff = pos + songIndex * TAUD_SONG_ENTRY
|
||||
let songOffset = _peekU32LE(filePtr, entryOff)
|
||||
@@ -114,7 +120,7 @@ function uploadTaudFile(inFile, songIndex, playhead) {
|
||||
let patBinCompSize = _peekU32LE(filePtr, entryOff + 18)
|
||||
let cueSheetCompSize = _peekU32LE(filePtr, entryOff + 22)
|
||||
|
||||
let bpm = bpmStored + 24
|
||||
let bpm = bpmStored + 25
|
||||
let patsToLoad = numPatsLo | (numPatsHi << 8)
|
||||
|
||||
// -- 6. Decompress + upload patterns --------------------------------------
|
||||
@@ -151,6 +157,50 @@ function uploadTaudFile(inFile, songIndex, playhead) {
|
||||
audio.setSongGlobalVolume(playhead, songGlobalVolume)
|
||||
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()
|
||||
sys.free(filePtr)
|
||||
@@ -173,14 +223,19 @@ function captureTrackerDataToFile(outFile) {
|
||||
const baseAddr = audio.getBaseAddr()
|
||||
|
||||
// -- 1. Compress sample+instrument bin ------------------------------------
|
||||
// sys.memcpy(negative_src, positive_dst, len) copies peripheral byte k from
|
||||
// (memBase - k) into (sampleInstBuf + k).
|
||||
let sampleInstBuf = sys.malloc(SAMPLEINST_SIZE)
|
||||
sys.memcpy(memBase, sampleInstBuf, SAMPLEINST_SIZE)
|
||||
|
||||
let compBuf = sys.malloc(SAMPLEINST_SIZE + 4096) // headroom for incompressible data
|
||||
let compressedSize = gzip.compFromTo(sampleInstBuf, SAMPLEINST_SIZE, compBuf)
|
||||
sys.free(sampleInstBuf)
|
||||
// The 8256 kB raw image (8 MB samples + 64 K instruments) cannot fit in the
|
||||
// 8 MB user space, so we hand the entire compress step to a hardware helper
|
||||
// that reads directly out of the adapter's native sample/instrument storage.
|
||||
// Realistic sample data compresses well under both gzip and zstd; we cap the
|
||||
// destination at "uncompressed size + 8 K" headroom which suffices for any
|
||||
// sane musical content.
|
||||
const COMP_BUF_CAP = 1024 * 1024 * 4 // 4 MiB cap for compressed sample+inst blob
|
||||
let compBuf = sys.malloc(COMP_BUF_CAP)
|
||||
let compressedSize = audio.captureSampleInstBlob(compBuf, COMP_BUF_CAP)
|
||||
if (compressedSize > COMP_BUF_CAP) {
|
||||
sys.free(compBuf)
|
||||
throw Error("taud: compressed sample+inst blob exceeded " + COMP_BUF_CAP + " bytes (got " + compressedSize + ")")
|
||||
}
|
||||
|
||||
// -- 2. Find last non-empty pattern in bank 0 (all-zero = uninitialized) --
|
||||
let numPatsActual = 0
|
||||
@@ -201,7 +256,7 @@ function captureTrackerDataToFile(outFile) {
|
||||
// -- 3. BPM / tick-rate / volumes from playhead 0 -------------------------
|
||||
let bpm = audio.getBPM(0) || 125
|
||||
let tickRate = audio.getTickRate(0) || 6
|
||||
let bpmStored = (bpm - 24) & 0xFF
|
||||
let bpmStored = (bpm - 25) & 0xFF
|
||||
let songGlobalVolume = audio.getSongGlobalVolume(0)
|
||||
let songMixingVolume = audio.getSongMixingVolume(0)
|
||||
if (songGlobalVolume === undefined || songGlobalVolume === null) songGlobalVolume = 0x80
|
||||
@@ -263,7 +318,7 @@ function captureTrackerDataToFile(outFile) {
|
||||
(songOffset >>> 24) & 0xFF,
|
||||
20, // numVoices
|
||||
numPats & 0xFF, (numPats >>> 8) & 0xFF, // numPatterns Uint16 LE
|
||||
bpmStored, // BPM with −24 bias
|
||||
bpmStored, // BPM with −25 bias
|
||||
tickRate, // initial tick-rate
|
||||
0x00,0xA0, // basenote (0xA000 -- C9)
|
||||
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)) },
|
||||
}
|
||||
331
assets/disk0/tvdos/include/typesetter.mjs
Normal file
331
assets/disk0/tvdos/include/typesetter.mjs
Normal file
@@ -0,0 +1,331 @@
|
||||
/*
|
||||
* typesetter.mjs - Rich-text typesetter for TVDOS console output.
|
||||
*
|
||||
* Wraps and aligns text using a tiny markup language. Originally lifted
|
||||
* out of taut_helpmsg.js so other tools (motd, help popups, ...) can
|
||||
* share the same formatter.
|
||||
*
|
||||
* Markup
|
||||
* ------
|
||||
* <b>...</b> emphasised foreground colour
|
||||
* <c>...</c> centre-align this source line
|
||||
* <r>...</r> right-align this source line
|
||||
* <l>...</l> left-align this source line
|
||||
* <o>...</o> virtual typesetting box. Left anchor is the cursor
|
||||
* column at the open tag, right anchor is the wrap edge.
|
||||
* default alignment is fully justified (override per-call via opts).
|
||||
*
|
||||
* Entities
|
||||
* --------
|
||||
* µtone; "Microtone" wordmark
|
||||
* &bul; &ddot; &mdot; bullet glyphs
|
||||
* &updn; &udlr; arrow glyphs
|
||||
* &keyoffsym; ¬ecutsym;
|
||||
* &demisharp; ♯ &sesquisharp; &doublesharp; &triplesharp; &quadsharp;
|
||||
* &demiflat; ♭ &sesquiflat; &doubleflat; &tripleflat; &quadflat;
|
||||
* &accuptick; &accdntick; &accupup; &accdndn;
|
||||
* non-breaking space
|
||||
* ­ soft hyphen (currently dropped)
|
||||
* < > literal angle brackets
|
||||
*
|
||||
* Usage
|
||||
* -----
|
||||
* let ts = require("typesetter")
|
||||
* let lines = ts.typeset(text, width) // array of width-wide strings
|
||||
* let lines = ts.typeset(text) // width = rest of current row
|
||||
* let lines = ts.typeset(text, width, { defaultAlign: 'l' })
|
||||
*/
|
||||
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Palette / ANSI helpers
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
const COL_TEXT = 239 // popup body default (== colWHITE)
|
||||
const COL_EMPH = 230 // <b>...</b> highlight (== colVoiceHdr)
|
||||
const COL_BRAND = 211 // first half of "Microtone"
|
||||
const COL_BRAND_DIM = 239 // second half of "Microtone"
|
||||
|
||||
const fgEsc = (n) => `\x1B[38;5;${n}m`
|
||||
const ESC_DEFAULT = fgEsc(COL_TEXT)
|
||||
const ESC_EMPH = fgEsc(COL_EMPH)
|
||||
const MICROTONE = `${fgEsc(COL_BRAND)}Micro${fgEsc(COL_BRAND_DIM)}tone${ESC_DEFAULT}`
|
||||
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Entity expansion
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Replace &xxx; entities with their final printable representations.
|
||||
function expandEntities(s) {
|
||||
return s
|
||||
.replaceAll('µtone;', MICROTONE)
|
||||
.replaceAll('&bul;', '\u00F9')
|
||||
.replaceAll('&ddot;', '\u008419u')
|
||||
.replaceAll('&mdot;', '\u00FA')
|
||||
.replaceAll('&updn;', '\u008418u')
|
||||
.replaceAll('&udlr;', '\u008428u\u008429u')
|
||||
.replaceAll('&keyoffsym;', '\u00A0\u00B1\u00B1\u00A1')
|
||||
.replaceAll('¬ecutsym;', '\u00A4\u00A4\u00A4\u00A4')
|
||||
.replaceAll(' ', '\u007F')
|
||||
.replaceAll('­', '')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('&demisharp;', '\u0080\u0081')
|
||||
.replaceAll('♯', '\u0082\u0083')
|
||||
.replaceAll('&sesquisharp;', '\u0084132u\u0085')
|
||||
.replaceAll('&doublesharp;', '\u0086\u0087')
|
||||
.replaceAll('&triplesharp;', '\u0088\u0089')
|
||||
.replaceAll('&quadsharp;', '\u008A\u008B')
|
||||
.replaceAll('&demiflat;', '\u008C\u008D')
|
||||
.replaceAll('♭', '\u008E\u008F')
|
||||
.replaceAll('&sesquiflat;', '\u0090\u0091')
|
||||
.replaceAll('&doubleflat;', '\u0092\u0093')
|
||||
.replaceAll('&tripleflat;', '\u0094\u0095')
|
||||
.replaceAll('&quadflat;', '\u0096\u0097')
|
||||
.replaceAll('&accuptick;', '\u009A')
|
||||
.replaceAll('&accdntick;', '\u009B')
|
||||
.replaceAll('&accupup;', '\u009C')
|
||||
.replaceAll('&accdndn;', '\u009D')
|
||||
}
|
||||
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Tokeniser
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Tokenise a (post-entity-expansion) line. Returns an array of:
|
||||
// {type:'word', text:String, w:int} - non-breakable run of visible chars (may carry ANSI escapes)
|
||||
// {type:'sp'} - a single soft space (eligible for break/expansion)
|
||||
// {type:'anchor', open:Boolean} - <o>/</o> markers (zero width)
|
||||
//
|
||||
// Width accounting:
|
||||
// - ANSI escapes (`\x1B[...m`) : 0 visible chars
|
||||
// - TSVM unicode escapes (`\u0084..u`) : 1 visible char
|
||||
// - non-breaking space (\u007F) : 1 visible char (consumed as part of a word)
|
||||
// - soft hyphen (\u00AD) : dropped (not implemented as a break point)
|
||||
// - everything else : 1 visible char
|
||||
function tokenise(line) {
|
||||
const tokens = []
|
||||
let buf = ''
|
||||
let bufW = 0
|
||||
let i = 0
|
||||
|
||||
const flushWord = () => {
|
||||
if (buf.length > 0) {
|
||||
tokens.push({type: 'word', text: buf, w: bufW})
|
||||
buf = ''
|
||||
bufW = 0
|
||||
}
|
||||
}
|
||||
|
||||
while (i < line.length) {
|
||||
// inline tags (case-sensitive for <b>, case-insensitive for <o>)
|
||||
if (line.slice(i, i + 3) === '<b>') { buf += ESC_EMPH; i += 3; continue }
|
||||
if (line.slice(i, i + 4) === '</b>') { buf += ESC_DEFAULT; i += 4; continue }
|
||||
const head3 = line.slice(i, i + 3).toLowerCase()
|
||||
const head4 = line.slice(i, i + 4).toLowerCase()
|
||||
if (head3 === '<o>') { flushWord(); tokens.push({type: 'anchor', open: true}); i += 3; continue }
|
||||
if (head4 === '</o>') { flushWord(); tokens.push({type: 'anchor', open: false}); i += 4; continue }
|
||||
|
||||
const c = line[i]
|
||||
const cc = line.charCodeAt(i)
|
||||
|
||||
if (cc === 0x1B) {
|
||||
// pre-existing ANSI escape - copy verbatim, zero visible width
|
||||
const m = line.indexOf('m', i)
|
||||
const end = (m < 0) ? line.length : m + 1
|
||||
buf += line.slice(i, end)
|
||||
i = end
|
||||
}
|
||||
else if (cc === 0x84) {
|
||||
// TSVM \u0084<digits>u escape - copy verbatim, one visible char
|
||||
const u = line.indexOf('u', i)
|
||||
const end = (u < 0) ? line.length : u + 1
|
||||
buf += line.slice(i, end)
|
||||
bufW += 1
|
||||
i = end
|
||||
}
|
||||
else if (c === ' ') {
|
||||
flushWord()
|
||||
tokens.push({type: 'sp'})
|
||||
i += 1
|
||||
}
|
||||
else if (cc === 0x00AD) {
|
||||
// soft hyphen: drop (no break-point handling for now)
|
||||
i += 1
|
||||
}
|
||||
else {
|
||||
buf += c
|
||||
bufW += 1
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
flushWord()
|
||||
return tokens
|
||||
}
|
||||
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Line builder
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Build wrapped lines from a token stream then format each one according to alignment.
|
||||
// Returns an array of strings, each exactly `width` visible chars wide (padded with
|
||||
// trailing spaces) so the caller can blit them without further math.
|
||||
function wrapAndAlign(tokens, width, alignment) {
|
||||
const lines = [] // each: {tokens, indent, contentW}
|
||||
let curTokens = []
|
||||
let curW = 0
|
||||
let curIndent = 0
|
||||
let nextIndent = 0 // indent the *next* flushed line should use
|
||||
|
||||
const flushLine = () => {
|
||||
// strip trailing soft spaces
|
||||
while (curTokens.length > 0 && curTokens[curTokens.length - 1].type === 'sp') {
|
||||
curTokens.pop()
|
||||
curW -= 1
|
||||
}
|
||||
lines.push({tokens: curTokens, indent: curIndent, contentW: curW})
|
||||
curTokens = []
|
||||
curW = 0
|
||||
curIndent = nextIndent
|
||||
}
|
||||
|
||||
for (const tok of tokens) {
|
||||
if (tok.type === 'anchor') {
|
||||
// anchor opens at the current visible column (accounting for indent)
|
||||
if (tok.open) nextIndent = curIndent + curW
|
||||
else nextIndent = 0
|
||||
continue
|
||||
}
|
||||
|
||||
if (tok.type === 'sp') {
|
||||
// ignore leading soft spaces on a fresh line
|
||||
if (curW === 0) continue
|
||||
// hard wrap if the line is already at the right edge
|
||||
if (curIndent + curW + 1 > width) { flushLine(); continue }
|
||||
curTokens.push(tok)
|
||||
curW += 1
|
||||
continue
|
||||
}
|
||||
|
||||
// word
|
||||
const tw = tok.w
|
||||
if (curIndent + curW + tw > width) {
|
||||
flushLine()
|
||||
// word too wide for the wrapped line: emit it on its own row (possibly clipped by terminal)
|
||||
if (curIndent + tw > width) {
|
||||
curTokens.push(tok)
|
||||
curW += tw
|
||||
flushLine()
|
||||
continue
|
||||
}
|
||||
}
|
||||
curTokens.push(tok)
|
||||
curW += tw
|
||||
}
|
||||
|
||||
if (curTokens.length > 0 || lines.length === 0) flushLine()
|
||||
|
||||
return lines.map((line, i) => formatLine(line, width, alignment, i === lines.length - 1))
|
||||
}
|
||||
|
||||
function formatLine(line, totalWidth, alignment, isLast) {
|
||||
if (line.tokens.length === 0) return ' '.repeat(totalWidth)
|
||||
|
||||
const indent = ' '.repeat(line.indent)
|
||||
const remaining = totalWidth - line.indent - line.contentW
|
||||
const pad = (n) => (n > 0) ? ' '.repeat(n) : ''
|
||||
const flatText = () => line.tokens.map(t => (t.type === 'sp') ? ' ' : t.text).join('')
|
||||
|
||||
if (alignment === 'c') {
|
||||
const left = remaining >> 1
|
||||
return indent + pad(left) + flatText() + pad(remaining - left)
|
||||
}
|
||||
if (alignment === 'r') return indent + pad(remaining) + flatText()
|
||||
if (alignment === 'l') return indent + flatText() + pad(remaining)
|
||||
|
||||
// justified: only expand spaces when there's slack and we're not on the
|
||||
// last (or single) wrapped line
|
||||
if (isLast || remaining <= 0) return indent + flatText() + pad(remaining)
|
||||
|
||||
const spaceCount = line.tokens.reduce((n, t) => n + (t.type === 'sp' ? 1 : 0), 0)
|
||||
if (spaceCount === 0) return indent + flatText() + pad(remaining)
|
||||
|
||||
const baseExtra = (remaining / spaceCount) | 0
|
||||
let leftover = remaining - baseExtra * spaceCount
|
||||
|
||||
let out = indent
|
||||
for (const tok of line.tokens) {
|
||||
if (tok.type === 'sp') {
|
||||
const extra = baseExtra + (leftover > 0 ? 1 : 0)
|
||||
if (leftover > 0) leftover -= 1
|
||||
out += ' '.repeat(1 + extra)
|
||||
} else {
|
||||
out += tok.text
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Process a single source line: peel a leading <c>/<r>/<l> alignment tag (if present),
|
||||
// strip its matching close tag, then tokenise + wrap.
|
||||
function typesetSourceLine(line, width, defaultAlign) {
|
||||
if (line.length === 0) return [' '.repeat(width)]
|
||||
|
||||
let alignment = defaultAlign || 'j' // justified default
|
||||
const startMatch = line.match(/^<([crl])>/i)
|
||||
if (startMatch) {
|
||||
alignment = startMatch[1].toLowerCase()
|
||||
line = line.slice(startMatch[0].length)
|
||||
const closeRe = new RegExp(`</${alignment}>$`, 'i')
|
||||
line = line.replace(closeRe, '')
|
||||
}
|
||||
|
||||
const tokens = tokenise(line)
|
||||
return wrapAndAlign(tokens, width, alignment)
|
||||
}
|
||||
|
||||
function typesetText(text, width, defaultAlign) {
|
||||
text = expandEntities(text)
|
||||
const out = []
|
||||
for (const srcLine of text.split('\n')) {
|
||||
for (const outLine of typesetSourceLine(srcLine, width, defaultAlign)) out.push(outLine)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Convenience entry: `typeset(text)` defaults the wrap width to "rest of current row".
|
||||
// `opts` may be `{ defaultAlign: 'l' | 'c' | 'r' | 'j' }`.
|
||||
function typeset(text, customWidth, opts) {
|
||||
let typesetWidth = customWidth
|
||||
if (typesetWidth === undefined) {
|
||||
const SCRW = con.getmaxyx()[1]
|
||||
const currentPosX = con.getyx()[1] // 1-indexed
|
||||
typesetWidth = SCRW - currentPosX + 1
|
||||
}
|
||||
let defaultAlign = (opts && opts.defaultAlign) || 'j'
|
||||
return typesetText(text, typesetWidth, defaultAlign)
|
||||
}
|
||||
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Module exports
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
exports = {
|
||||
typeset,
|
||||
typesetText,
|
||||
typesetSourceLine,
|
||||
tokenise,
|
||||
expandEntities,
|
||||
fgEsc,
|
||||
COL_TEXT,
|
||||
COL_EMPH,
|
||||
COL_BRAND,
|
||||
COL_BRAND_DIM,
|
||||
ESC_DEFAULT,
|
||||
ESC_EMPH,
|
||||
MICROTONE,
|
||||
}
|
||||
@@ -65,12 +65,12 @@ class WindowObject {
|
||||
}
|
||||
if (this.titleRight !== undefined) {
|
||||
let tt = ''+this.titleRight
|
||||
con.move(this.y, this.x + this.width - tt.length - 2)
|
||||
con.move(this.y + this.height - 1, this.x + this.width - tt.length - 2)
|
||||
print(`\x84${charset[4]}u`)
|
||||
if (this.titleBackRight !== undefined) print(`\x1B[48;5;${this.titleBackRight}m`)
|
||||
print(`\x1B[38;5;${colourText}m${tt}`)
|
||||
if (this.titleBackRight !== undefined) print(`\x1B[48;5;${oldBack}m`)
|
||||
print(`\x1B[38;5;${colour}m\x84${charset[1]}u`)
|
||||
print(`\x1B[38;5;${colour}m\x84${charset[3]}u`)
|
||||
}
|
||||
|
||||
|
||||
@@ -180,4 +180,769 @@ function scrollHorz(dx, stringSize, stringViewSize, currentCursorPos, currentScr
|
||||
return [currentCursorPos, currentScrollPos]
|
||||
}
|
||||
|
||||
exports = { WindowObject, scrollVert, scrollHorz }
|
||||
// ---------------------------------------------------------------------------
|
||||
// Modal dialog with optional body text, input fields, a scrollable selection
|
||||
// list, and OK/Cancel-style buttons. Layout from top to bottom:
|
||||
// title bar, message, fields, list, buttons.
|
||||
//
|
||||
// opts = {
|
||||
// title: string,
|
||||
// message: string | string[]?, -- optional body text drawn above fields/list
|
||||
// drawFrame: function(wo)?, -- override for the window-frame painter;
|
||||
// same contract as WindowObject's
|
||||
// `drawFrame` slot. Useful when the caller
|
||||
// wants its own border / title styling.
|
||||
//
|
||||
// fields: [{label, initial?, width, maxLength?}, ...] -- omit / [] for no input
|
||||
// field. Label does NOT get auto-colon.
|
||||
// `maxLength` caps insertable chars
|
||||
// (default: width * 4).
|
||||
//
|
||||
// list: { -- optional vertical selection list
|
||||
// items: [{label, ...}, ...], -- arbitrary user objects; only `label`
|
||||
// is read by the default renderer.
|
||||
// height: number, -- visible row count.
|
||||
// width: number?, -- inner width override (default: popup w-4).
|
||||
// cursor: number?, -- initial cursor row (default: first selectable).
|
||||
// selectable: function(item, i)->bool?, -- default: every item selectable. Non-
|
||||
// selectable rows are skipped by arrow keys.
|
||||
// When NO row is selectable, arrow / PgUp
|
||||
// / PgDn scroll the view instead.
|
||||
// renderItem: function(ctx)?, -- per-row painter; ctx exposes
|
||||
// { y, x, w, item, idx, isCursor, focused,
|
||||
// listBg, selBg, fg, hlFg, dimFg }.
|
||||
// Default prints `item.label`.
|
||||
// onActivate: function(item, i, key)?, -- fired on Enter ('\n') / Space (' ')
|
||||
// / left-click ('click'); return an
|
||||
// action string to close the dialog,
|
||||
// or null to stay open.
|
||||
// showScrollbar: bool?, -- default: auto (true when overflowing).
|
||||
// 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 }
|
||||
|
||||
561
assets/disk0/tvdos/sbin/vtmgr.js
Normal file
561
assets/disk0/tvdos/sbin/vtmgr.js
Normal file
@@ -0,0 +1,561 @@
|
||||
// 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
|
||||
|
||||
// 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
|
||||
1236
it2taud.py
1236
it2taud.py
File diff suppressed because it is too large
Load Diff
412
mod2taud.py
412
mod2taud.py
@@ -7,9 +7,9 @@ Usage:
|
||||
Limits:
|
||||
- Up to 20 MOD channels (excess disabled; hard error if pattern count
|
||||
× channel count > 4095).
|
||||
- Sample bin is 737280 bytes; if all samples together exceed this, every
|
||||
sample is globally resampled down (with c2spd adjusted) so pitch is
|
||||
preserved.
|
||||
- Sample bin is 8 MB (8388608 bytes); if all samples together exceed
|
||||
this, every sample is globally resampled down (with c2spd adjusted)
|
||||
so pitch is preserved.
|
||||
|
||||
Effect support:
|
||||
Full PT effect dispatch per TAUD_NOTE_EFFECTS.md "ProTracker to Taud
|
||||
@@ -24,7 +24,7 @@ Effect support:
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import copy
|
||||
import math
|
||||
import struct
|
||||
import sys
|
||||
@@ -39,8 +39,9 @@ from taud_common import (
|
||||
TOP_J, TOP_K, TOP_L, TOP_O, TOP_Q, TOP_R, TOP_S, TOP_T, TOP_U, TOP_V, TOP_Y,
|
||||
SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE,
|
||||
J_SEMI_TABLE,
|
||||
d_arg_to_col, resample_linear, encode_cue, deduplicate_patterns,
|
||||
encode_song_entry,
|
||||
d_arg_to_col, resample_linear, rescale_offset_effects, encode_cue, deduplicate_patterns,
|
||||
encode_song_entry, compress_blob,
|
||||
build_project_data, detect_subsongs,
|
||||
)
|
||||
|
||||
|
||||
@@ -59,6 +60,9 @@ PT_MEM_TOP = frozenset({0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0xA})
|
||||
# E sub-effects with memory (key is sub-nibble of the E command):
|
||||
PT_MEM_E_SUB = frozenset({0x1, 0x2, 0xA, 0xB})
|
||||
|
||||
GLOBAL_FLAGS_AMIGA_FREQ = 0b01
|
||||
GLOBAL_FLAGS_A500_INTP = 0b1000
|
||||
|
||||
|
||||
# ── Taud constants (mod-specific) ────────────────────────────────────────────
|
||||
|
||||
@@ -179,6 +183,26 @@ def parse_mod(data: bytes):
|
||||
inst = (b0 & 0xF0) | ((b2 >> 4) & 0x0F)
|
||||
effect = b2 & 0x0F
|
||||
arg = b3
|
||||
# MT-style PT-strict cell rewrites (LoaderMOD.cpp:354-365):
|
||||
# PT does not recall arg for portamento up/down (1xx, 2xx) or
|
||||
# volume slide (Axx); the literal arg is read every tick. The
|
||||
# vol-slide nibbles in 5xx/6xx likewise take literal args, with
|
||||
# the recalled state living in the porta/vibrato side. So a
|
||||
# zero-arg cell decays to a no-slide variant: 1/2/A drop to
|
||||
# no-op, 5 collapses to bare tone-porta (3), 6 to bare vibrato
|
||||
# (4). Without this, resolve_pt_recalls would back-fill these
|
||||
# zero args from the cohort memory and produce a continuous
|
||||
# slide where PT plays a single-row swell (canonical bug:
|
||||
# GSLINGER ord 0x03 ch1 — `5 01` on r30/r38 with `5 00` on the
|
||||
# rest, was fading 24→0 in 5 rows instead of stair-stepping
|
||||
# 24→14 across 16 rows).
|
||||
if arg == 0:
|
||||
if effect in (0x1, 0x2, 0xA):
|
||||
effect = 0x0
|
||||
elif effect == 0x5:
|
||||
effect = 0x3
|
||||
elif effect == 0x6:
|
||||
effect = 0x4
|
||||
cell = grid[ch][r]
|
||||
cell.period = period
|
||||
cell.inst = inst
|
||||
@@ -226,7 +250,7 @@ def period_to_taud_note(period: int) -> int:
|
||||
if period <= 0:
|
||||
return NOTE_NOP
|
||||
val = round(TAUD_C4 + 4096.0 * math.log2(PT_REFERENCE_PERIOD / period))
|
||||
return max(1, min(0xFFFD, val))
|
||||
return max(0x20, min(0xFFFF, val))
|
||||
|
||||
|
||||
# ── PT effect → Taud effect ──────────────────────────────────────────────────
|
||||
@@ -266,12 +290,17 @@ def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple:
|
||||
return (TOP_H, ((hi * 0x11) << 8) | (lo * 0x11), None, None)
|
||||
|
||||
if cmd == 0x5:
|
||||
# Tone porta + vol slide → Taud L (engine splits internally).
|
||||
return (TOP_G, 0x0000, d_arg_to_col(arg), None)
|
||||
# Tone porta + vol slide → Taud L verbatim. PT's 500 recall is already
|
||||
# collapsed by resolve_pt_recalls; if the source had no prior 5xy the
|
||||
# resolved arg is 0, which Taud's L $0000 then recalls from L's own
|
||||
# private memory. Emitting a real L (rather than the previous
|
||||
# G+vol-col split) preserves the slide on rows that also carry a
|
||||
# vol-column SET (e.g., a Cxx fold) — see TAUD_NOTE_EFFECTS.md §L.
|
||||
return (TOP_L, (arg & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == 0x6:
|
||||
# Vibrato + vol slide → Taud K.
|
||||
return (TOP_H, 0x0000, d_arg_to_col(arg), None)
|
||||
# Vibrato + vol slide → Taud K verbatim (same rationale as 0x5).
|
||||
return (TOP_K, (arg & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == 0x7:
|
||||
hi = (arg >> 4) & 0xF
|
||||
@@ -287,7 +316,17 @@ def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple:
|
||||
return (TOP_O, (arg & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == 0xA:
|
||||
return (TOP_NONE, 0, d_arg_to_col(arg), None)
|
||||
# Route Axy via Taud's effect-column D so it can coexist with a Cxx
|
||||
# SET on the same row. (Vol-column slide selectors share the cell with
|
||||
# the SET selector — when both Cxx and Axy land on a trigger row the
|
||||
# vol-col slot can only encode one, and the slide gets dropped, losing
|
||||
# 5 ticks of slide per row.) Resolution-time A00 is already collapsed
|
||||
# to a concrete arg in resolve_pt_recalls; a remaining 0 means truly
|
||||
# no-op (memory was empty), so emit nothing rather than D 00 (which
|
||||
# would recall TSVM's D memory).
|
||||
if arg == 0:
|
||||
return (TOP_NONE, 0, None, None)
|
||||
return (TOP_D, (arg & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == 0xB:
|
||||
return (TOP_B, arg & 0xFF, None, None)
|
||||
@@ -353,7 +392,7 @@ def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0) -> tuple:
|
||||
if arg == 0:
|
||||
return (TOP_NONE, 0, None, None)
|
||||
return (TOP_A, (arg & 0xFF) << 8, None, None)
|
||||
return (TOP_T, ((arg - 0x18) & 0xFF) << 8, None, None)
|
||||
return (TOP_T, ((arg - 0x19) & 0xFF) << 8, None, None)
|
||||
|
||||
return (TOP_NONE, 0, None, None)
|
||||
|
||||
@@ -490,7 +529,8 @@ def build_sample_inst_bin(samples: list) -> tuple:
|
||||
s.loop_end = min(s.loop_end, n)
|
||||
pos += n
|
||||
|
||||
# New 192-byte instrument layout (terranmon.txt:1997-2070).
|
||||
# New 256-byte instrument layout (terranmon.txt:2001+).
|
||||
INST_STRIDE = 256
|
||||
inst_bin = bytearray(INSTBIN_SIZE)
|
||||
for i, s in enumerate(samples):
|
||||
taud_idx = i + 1 # 1-based instrument number
|
||||
@@ -506,12 +546,17 @@ def build_sample_inst_bin(samples: list) -> tuple:
|
||||
le = min(s.loop_end, 65535)
|
||||
loop_mode = 1 if (s.flags & 1) else 0
|
||||
flags_byte = loop_mode & 0x3
|
||||
# Envelope first point is full-scale; per-sample level is carried by
|
||||
# IGV (byte 171) so the envelope must contribute a unit multiplier.
|
||||
# Envelope first point is full-scale; per-trigger initial level is
|
||||
# carried by Default Note Volume (byte 196) so the envelope must
|
||||
# contribute a unit multiplier.
|
||||
env_vol = 63
|
||||
vol_env_flags = 0x0020 # use-envelope bit
|
||||
# MOD has no envelopes; vol LOOP word b=1 just so the engine evaluates
|
||||
# the unit envelope, plus P=1 (envelope present) for consistency with
|
||||
# the new gate spec (terranmon.txt byte 16/18/20 bit 5). Pan/PF stay
|
||||
# fully zero — the engine sees P=0 there and skips them.
|
||||
vol_env_loop = 0x2020 # P (bit 13) | b (bit 5)
|
||||
|
||||
base = taud_idx * 192
|
||||
base = taud_idx * INST_STRIDE
|
||||
struct.pack_into('<I', inst_bin, base + 0, ptr)
|
||||
struct.pack_into('<H', inst_bin, base + 4, s_len)
|
||||
struct.pack_into('<H', inst_bin, base + 6, c2spd)
|
||||
@@ -519,24 +564,27 @@ def build_sample_inst_bin(samples: list) -> tuple:
|
||||
struct.pack_into('<H', inst_bin, base + 10, ls)
|
||||
struct.pack_into('<H', inst_bin, base + 12, le)
|
||||
inst_bin[base + 14] = flags_byte
|
||||
struct.pack_into('<H', inst_bin, base + 15, vol_env_flags)
|
||||
# LOOP words at 15/17/19; SUSTAIN words at 189/191/193 (left zero).
|
||||
struct.pack_into('<H', inst_bin, base + 15, vol_env_loop)
|
||||
struct.pack_into('<H', inst_bin, base + 17, 0)
|
||||
struct.pack_into('<H', inst_bin, base + 19, 0)
|
||||
inst_bin[base + 21] = env_vol
|
||||
inst_bin[base + 22] = 0
|
||||
# Instrument Global Volume carries the MOD sample's default volume (0..64 → 0..255).
|
||||
# The pattern builder no longer emits SEL_SET=Sv on note triggers; the engine
|
||||
# multiplies by IGV instead, so the per-instrument level lives here.
|
||||
inst_bin[base + 171] = min(0xFF, round(min(s.volume, 64) * 255 / 64))
|
||||
# MOD has no continuous instrumentwise volume scaler — its `s.volume`
|
||||
# (0..64) is purely the per-trigger initial value. Byte 171 (IGV)
|
||||
# stays at full and byte 196 (DNV) carries the per-instrument default.
|
||||
# Pre-2026-05-09 layout folded s.volume into IGV — see terranmon §2350.
|
||||
inst_bin[base + 171] = 0xFF # IGV: continuous unity
|
||||
inst_bin[base + 177] = 0x80 # default pan = centre (unused; pan env "p" flag not set)
|
||||
inst_bin[base + 182] = 0xFF # filter cutoff = off
|
||||
inst_bin[base + 183] = 0xFF # filter resonance = off
|
||||
inst_bin[base + 186] = 1 # NNA: note cut
|
||||
inst_bin[base + 196] = min(0xFF, round(min(s.volume, 64) * 255 / 64)) # DNV
|
||||
|
||||
vprint(f" instrument[{taud_idx}] '{s.name}' ptr={ptr} c2spd={s.c2spd} "
|
||||
f"vol={s.volume} loop=({ls},{le},{'on' if loop_mode else 'off'})")
|
||||
|
||||
return bytes(sample_bin) + bytes(inst_bin), offsets
|
||||
return bytes(sample_bin) + bytes(inst_bin), offsets, ratio
|
||||
|
||||
|
||||
# ── Pattern build ────────────────────────────────────────────────────────────
|
||||
@@ -544,7 +592,7 @@ def build_sample_inst_bin(samples: list) -> tuple:
|
||||
# PT hard-pans channels in LRRL order: 0=L 1=R 2=R 3=L (and tile for >4).
|
||||
def _default_channel_pan(ch_idx: int) -> int:
|
||||
side = (ch_idx % 4)
|
||||
return 16 if side in (0, 3) else 47
|
||||
return 8 if side in (0, 3) else 55
|
||||
|
||||
|
||||
def build_pattern(grid: list, ch_idx: int, default_pan: int,
|
||||
@@ -552,9 +600,9 @@ def build_pattern(grid: list, ch_idx: int, default_pan: int,
|
||||
"""Build a 512-byte Taud pattern for one MOD channel.
|
||||
|
||||
Volume column: explicit Cxx → SEL_SET; effect-folded vol slide → vol_override;
|
||||
otherwise SEL_FINE/0 (no-op). Per-instrument default volume lives in IGV
|
||||
(byte 171) and is applied by the engine on every fresh trigger — the
|
||||
converter no longer has to emit SEL_SET=Sv to scale notes.
|
||||
otherwise SEL_FINE/0 (no-op). Per-instrument default volume lives in DNV
|
||||
(byte 196) and is consulted by the engine when the trigger row has no V
|
||||
column — the converter doesn't need to emit SEL_SET=Sv on plain triggers.
|
||||
"""
|
||||
out = bytearray(PATTERN_BYTES)
|
||||
rows = grid[ch_idx] if ch_idx < len(grid) else [ModRow()] * MOD_PATTERN_ROWS
|
||||
@@ -655,106 +703,133 @@ def find_initial_bpm_speed(patterns: list, order_list: list) -> tuple:
|
||||
return speed, tempo
|
||||
|
||||
|
||||
def assemble_taud(mod: dict) -> bytes:
|
||||
samples = mod['samples']
|
||||
patterns = mod['patterns']
|
||||
def _per_pattern_bxx_mod(patterns: list, n_channels: int):
|
||||
"""Return callable(pat_idx) → (set_of_bxx_target_orders, kills_fallthrough)
|
||||
for `detect_subsongs`. MOD patterns are 64 rows × n_channels; Bxx is
|
||||
raw effect digit 0xB.
|
||||
"""
|
||||
def fn(pat_idx: int):
|
||||
if pat_idx < 0 or pat_idx >= len(patterns):
|
||||
return set(), False
|
||||
grid = patterns[pat_idx]
|
||||
targets = set()
|
||||
last_row_has_b = False
|
||||
for ch in range(min(n_channels, len(grid))):
|
||||
ch_rows = grid[ch]
|
||||
for r in range(min(PATTERN_ROWS, len(ch_rows))):
|
||||
cell = ch_rows[r]
|
||||
if cell.effect == 0xB:
|
||||
targets.add(cell.effect_arg & 0xFF)
|
||||
if r == PATTERN_ROWS - 1:
|
||||
last_row_has_b = True
|
||||
return targets, last_row_has_b
|
||||
return fn
|
||||
|
||||
|
||||
def _build_song_payload_mod(mod: dict, patterns_template: list,
|
||||
positions: list, sample_ratio: dict,
|
||||
inst_vols: dict, n_channels: int,
|
||||
*, song_label: str = 'song') -> tuple:
|
||||
"""Build pattern bin + cue sheet + song-entry kwargs for one MOD subsong.
|
||||
|
||||
`patterns_template` is deep-copied so per-song stateful transforms
|
||||
(recall resolution, late-note-delay relocation, Bxx remap) don't leak
|
||||
into the next subsong.
|
||||
"""
|
||||
patterns = copy.deepcopy(patterns_template)
|
||||
order_list = mod['order_list']
|
||||
n_channels = mod['n_channels']
|
||||
virtual_orders = [order_list[pos] for pos in positions]
|
||||
|
||||
vprint(f" [{song_label}] resolving PT per-effect recalls…")
|
||||
resolve_pt_recalls(patterns, virtual_orders, n_channels)
|
||||
|
||||
init_speed, _ = find_initial_bpm_speed(patterns, virtual_orders)
|
||||
relocate_late_note_delays(patterns, virtual_orders, n_channels, init_speed)
|
||||
|
||||
speed, tempo = find_initial_bpm_speed(patterns, virtual_orders)
|
||||
tempo = max(25, min(280, tempo))
|
||||
bpm_stored = (tempo - 25) & 0xFF
|
||||
vprint(f" [{song_label}] initial speed={speed}, tempo(BPM)={tempo}")
|
||||
|
||||
n_patterns = mod['n_patterns']
|
||||
|
||||
if n_channels > NUM_VOICES:
|
||||
vprint(f" warning: MOD has {n_channels} channels; truncating to {NUM_VOICES}")
|
||||
n_channels = NUM_VOICES
|
||||
# Cue list and pos→cue mapping, skipping orders that aren't valid pattern refs.
|
||||
cue_list = []
|
||||
pos_to_cue = {}
|
||||
for pos in positions:
|
||||
order = order_list[pos]
|
||||
if order >= n_patterns:
|
||||
continue
|
||||
pos_to_cue[pos] = len(cue_list)
|
||||
cue_list.append(order)
|
||||
|
||||
if n_patterns * n_channels > NUM_PATTERNS_MAX:
|
||||
sys.exit(
|
||||
f"error: {n_patterns} MOD patterns × {n_channels} channels = "
|
||||
f"{n_patterns*n_channels} > {NUM_PATTERNS_MAX} Taud pattern limit.\n"
|
||||
f" Reduce the MOD to ≤ {NUM_PATTERNS_MAX // max(n_channels,1)} patterns."
|
||||
)
|
||||
# Densely renumber the patterns this song uses.
|
||||
used_ordered = []
|
||||
seen = set()
|
||||
for src_pat in cue_list:
|
||||
if src_pat not in seen:
|
||||
used_ordered.append(src_pat)
|
||||
seen.add(src_pat)
|
||||
pat_idx_remap = {src: i for i, src in enumerate(used_ordered)}
|
||||
P_used = len(used_ordered)
|
||||
|
||||
vprint(f" channels: {n_channels}, mod patterns: {n_patterns}, "
|
||||
f"taud patterns: {n_patterns * n_channels}")
|
||||
if P_used * n_channels > NUM_PATTERNS_MAX:
|
||||
sys.exit(f"error: [{song_label}] {P_used} patterns × {n_channels} channels = "
|
||||
f"{P_used*n_channels} > {NUM_PATTERNS_MAX} Taud pattern limit.")
|
||||
|
||||
# Fold Cxx into row.vol_set so the volume column carries explicit set-volume.
|
||||
# This is done in-place before recall resolution so Cxx with arg 0 still
|
||||
# resolves to vol 0 (silence) rather than recalling another effect's memory.
|
||||
for grid in patterns:
|
||||
# Bxx remap on the patterns this song actually emits.
|
||||
crossings = 0
|
||||
for src_pat in used_ordered:
|
||||
if src_pat >= len(patterns): continue
|
||||
grid = patterns[src_pat]
|
||||
for ch in range(min(n_channels, len(grid))):
|
||||
for row in grid[ch]:
|
||||
if row.effect == 0xC:
|
||||
row.vol_set = min(row.effect_arg, 0x3F)
|
||||
row.effect = 0
|
||||
row.effect_arg = 0
|
||||
if row.effect == 0xB:
|
||||
if row.effect_arg in pos_to_cue:
|
||||
row.effect_arg = pos_to_cue[row.effect_arg] & 0xFF
|
||||
else:
|
||||
crossings += 1
|
||||
row.effect_arg = 0
|
||||
if crossings:
|
||||
vprint(f" warning: [{song_label}]: {crossings} Bxx target(s) cross "
|
||||
f"subsong boundary; clamped to cue 0")
|
||||
|
||||
vprint(" resolving PT per-effect recalls…")
|
||||
resolve_pt_recalls(patterns, order_list, n_channels)
|
||||
|
||||
init_speed, _ = find_initial_bpm_speed(patterns, order_list)
|
||||
relocate_late_note_delays(patterns, order_list, n_channels, init_speed)
|
||||
|
||||
vprint(" building sample/instrument bin…")
|
||||
sampleinst_raw, _offsets = build_sample_inst_bin(samples)
|
||||
assert len(sampleinst_raw) == SAMPLEINST_SIZE
|
||||
|
||||
compressed = gzip.compress(sampleinst_raw, compresslevel=9, mtime=0)
|
||||
comp_size = len(compressed)
|
||||
vprint(f" sample+inst bin: {SAMPLEINST_SIZE} → {comp_size} bytes (gzip)")
|
||||
|
||||
speed, tempo = find_initial_bpm_speed(patterns, order_list)
|
||||
tempo = max(24, min(280, tempo))
|
||||
bpm_stored = (tempo - 24) & 0xFF
|
||||
vprint(f" initial speed={speed}, tempo(BPM)={tempo}")
|
||||
|
||||
song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY
|
||||
|
||||
sig = (SIGNATURE + b' ' * 14)[:14]
|
||||
header = (
|
||||
TAUD_MAGIC +
|
||||
bytes([TAUD_VERSION, 1]) +
|
||||
struct.pack('<I', comp_size) +
|
||||
b'\x00\x00\x00\x00' +
|
||||
sig
|
||||
)
|
||||
assert len(header) == TAUD_HEADER_SIZE
|
||||
|
||||
vprint(" building pattern bin…")
|
||||
inst_vols = {
|
||||
i + 1: min(s.volume, 0x3F)
|
||||
for i, s in enumerate(samples)
|
||||
if s.sample_data
|
||||
}
|
||||
pat_bin = bytearray()
|
||||
for pi in range(n_patterns):
|
||||
grid = patterns[pi]
|
||||
for src_pat in used_ordered:
|
||||
grid = patterns[src_pat]
|
||||
for ch in range(n_channels):
|
||||
default_pan = _default_channel_pan(ch)
|
||||
pat_bin += build_pattern(grid, ch, default_pan, inst_vols)
|
||||
assert len(pat_bin) == n_patterns * n_channels * PATTERN_BYTES
|
||||
pat_bin = rescale_offset_effects(bytes(pat_bin), sample_ratio)
|
||||
|
||||
vprint(" deduplicating patterns…")
|
||||
orig_count = n_patterns * n_channels
|
||||
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(bytes(pat_bin), orig_count)
|
||||
vprint(f" patterns: {orig_count} → {num_taud_pats} unique "
|
||||
orig_count = P_used * n_channels
|
||||
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(pat_bin, orig_count)
|
||||
vprint(f" [{song_label}] patterns: {orig_count} → {num_taud_pats} unique "
|
||||
f"({orig_count - num_taud_pats} deduplicated)")
|
||||
|
||||
vprint(" building cue sheet…")
|
||||
cue_sheet = build_cue_sheet(order_list, n_patterns, n_channels, pat_remap)
|
||||
assert len(cue_sheet) == NUM_CUES * CUE_SIZE
|
||||
sheet = bytearray(NUM_CUES * CUE_SIZE)
|
||||
for c in range(NUM_CUES):
|
||||
sheet[c*CUE_SIZE:c*CUE_SIZE+CUE_SIZE] = encode_cue([], 0)
|
||||
|
||||
pat_comp = gzip.compress(bytes(pat_bin), compresslevel=9, mtime=0)
|
||||
cue_comp = gzip.compress(bytes(cue_sheet), compresslevel=9, mtime=0)
|
||||
vprint(f" pattern bin: {len(pat_bin)} → {len(pat_comp)} bytes (gzip)")
|
||||
vprint(f" cue sheet: {len(cue_sheet)} → {len(cue_comp)} bytes (gzip)")
|
||||
last_active = -1
|
||||
for cue_idx, src_pat in enumerate(cue_list):
|
||||
if cue_idx >= NUM_CUES: break
|
||||
new_pat_idx = pat_idx_remap[src_pat]
|
||||
orig_pats = [new_pat_idx * n_channels + v for v in range(n_channels)]
|
||||
sheet[cue_idx*CUE_SIZE:(cue_idx+1)*CUE_SIZE] = encode_cue(
|
||||
[pat_remap[p] for p in orig_pats], 0)
|
||||
last_active = cue_idx
|
||||
|
||||
# ProTracker is Amiga-period-based by definition, so we set the f bit so
|
||||
# the engine applies coarse pitch slides in period space (recovers PT's
|
||||
# characteristic non-linear pitch character).
|
||||
# bit 2 (m) set: FT2 fadeout-zero policy — PT has no fadeout, so the stored
|
||||
# zero on every instrument means "cut on key-off" (unified with S3M imports).
|
||||
flags_byte = 0x02 | 0x04
|
||||
song_table = encode_song_entry(
|
||||
song_offset=song_offset,
|
||||
if last_active >= 0:
|
||||
sheet[last_active * CUE_SIZE + 30] = 0x01
|
||||
else:
|
||||
sheet[30] = 0x01
|
||||
|
||||
pat_comp = compress_blob(bytes(pat_bin), f"[{song_label}] pattern bin")
|
||||
cue_comp = compress_blob(bytes(sheet), f"[{song_label}] cue sheet")
|
||||
|
||||
flags_byte = GLOBAL_FLAGS_AMIGA_FREQ | GLOBAL_FLAGS_A500_INTP
|
||||
entry_kwargs = dict(
|
||||
num_voices=n_channels,
|
||||
num_patterns=num_taud_pats,
|
||||
bpm_stored=bpm_stored,
|
||||
@@ -767,9 +842,117 @@ def assemble_taud(mod: dict) -> bytes:
|
||||
global_vol=0xFF,
|
||||
mixing_vol=180,
|
||||
)
|
||||
assert len(song_table) == TAUD_SONG_ENTRY
|
||||
return pat_comp, cue_comp, entry_kwargs
|
||||
|
||||
return header + compressed + song_table + pat_comp + cue_comp
|
||||
|
||||
def assemble_taud(mod: dict, with_project_data: bool = True) -> bytes:
|
||||
samples = mod['samples']
|
||||
patterns = mod['patterns']
|
||||
order_list = mod['order_list']
|
||||
n_channels = mod['n_channels']
|
||||
n_patterns = mod['n_patterns']
|
||||
|
||||
if n_channels > NUM_VOICES:
|
||||
vprint(f" warning: MOD has {n_channels} channels; truncating to {NUM_VOICES}")
|
||||
n_channels = NUM_VOICES
|
||||
vprint(f" channels: {n_channels}, mod patterns: {n_patterns}")
|
||||
|
||||
# Fold Cxx into row.vol_set so the volume column carries explicit set-volume.
|
||||
# This is non-stateful (doesn't depend on order list) so it runs once on the
|
||||
# shared template; per-song deepcopies inherit the folded form.
|
||||
for grid in patterns:
|
||||
for ch in range(min(n_channels, len(grid))):
|
||||
for row in grid[ch]:
|
||||
if row.effect == 0xC:
|
||||
row.vol_set = min(row.effect_arg, 0x3F)
|
||||
row.effect = 0
|
||||
row.effect_arg = 0
|
||||
|
||||
vprint(" building sample/instrument bin…")
|
||||
sampleinst_raw, _offsets, sample_ratio = build_sample_inst_bin(samples)
|
||||
assert len(sampleinst_raw) == SAMPLEINST_SIZE
|
||||
compressed = compress_blob(sampleinst_raw, "sample+inst bin")
|
||||
comp_size = len(compressed)
|
||||
|
||||
inst_vols = {
|
||||
i + 1: min(s.volume, 0x3F)
|
||||
for i, s in enumerate(samples)
|
||||
if s.sample_data
|
||||
}
|
||||
|
||||
# ── Detect subsongs ──────────────────────────────────────────────────────
|
||||
# MOD shares IT/S3M's 0xFF-end / 0xFE-skip convention; orders ≥ n_patterns
|
||||
# are also unplayable and treated as skips by the player (build_cue_sheet).
|
||||
skip_set = set([0xFE]) | set(range(n_patterns, 256))
|
||||
subsongs = detect_subsongs(order_list,
|
||||
_per_pattern_bxx_mod(patterns, n_channels),
|
||||
terminators=(0xFF,),
|
||||
skip_marker=skip_set)
|
||||
if not subsongs:
|
||||
vprint(" warning: no traversable orders in source; emitting empty song")
|
||||
subsongs = [{'entry': 0, 'positions': []}]
|
||||
n_songs = len(subsongs)
|
||||
if n_songs == 1:
|
||||
vprint(f" detected 1 song ({len(subsongs[0]['positions'])} orders)")
|
||||
else:
|
||||
vprint(f" detected {n_songs} subsongs:")
|
||||
for i, ss in enumerate(subsongs):
|
||||
vprint(f" song {i}: entry@{ss['entry']}, {len(ss['positions'])} orders")
|
||||
|
||||
# ── Build per-song payloads ──────────────────────────────────────────────
|
||||
song_payloads = []
|
||||
for i, ss in enumerate(subsongs):
|
||||
label = f"song {i}" if n_songs > 1 else "song"
|
||||
song_payloads.append(_build_song_payload_mod(
|
||||
mod, patterns, ss['positions'], sample_ratio, inst_vols,
|
||||
n_channels, song_label=label))
|
||||
|
||||
# ── Layout offsets and song table ────────────────────────────────────────
|
||||
song_table_off = TAUD_HEADER_SIZE + comp_size
|
||||
first_song_off = song_table_off + TAUD_SONG_ENTRY * n_songs
|
||||
|
||||
song_table = bytearray()
|
||||
cur_off = first_song_off
|
||||
for pat_comp, cue_comp, entry_kwargs in song_payloads:
|
||||
entry = encode_song_entry(song_offset=cur_off, **entry_kwargs)
|
||||
assert len(entry) == TAUD_SONG_ENTRY
|
||||
song_table += entry
|
||||
cur_off += len(pat_comp) + len(cue_comp)
|
||||
|
||||
# Project Data (optional). MOD samples *are* its instruments — the names
|
||||
# populate both INam and SNam (1-based; slot 0 empty).
|
||||
proj_data = b''
|
||||
proj_off = 0
|
||||
if with_project_data:
|
||||
names = [''] + [s.name for s in samples[:255]]
|
||||
proj_data = build_project_data(
|
||||
project_name=mod['title'],
|
||||
instrument_names=names,
|
||||
sample_names=names,
|
||||
)
|
||||
if proj_data:
|
||||
proj_off = cur_off
|
||||
vprint(f" project data: {len(proj_data)} bytes @ offset {proj_off}")
|
||||
|
||||
sig = (SIGNATURE + b' ' * 14)[:14]
|
||||
header = (
|
||||
TAUD_MAGIC +
|
||||
bytes([TAUD_VERSION, n_songs & 0xFF]) +
|
||||
struct.pack('<I', comp_size) +
|
||||
struct.pack('<I', proj_off) +
|
||||
sig
|
||||
)
|
||||
assert len(header) == TAUD_HEADER_SIZE
|
||||
|
||||
out = bytearray()
|
||||
out += header
|
||||
out += compressed
|
||||
out += song_table
|
||||
for pat_comp, cue_comp, _ in song_payloads:
|
||||
out += pat_comp
|
||||
out += cue_comp
|
||||
out += proj_data
|
||||
return bytes(out)
|
||||
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||
@@ -781,6 +964,9 @@ def main():
|
||||
ap.add_argument('output', help='Output .taud file')
|
||||
ap.add_argument('-v', '--verbose', action='store_true',
|
||||
help='Print conversion details to stderr')
|
||||
ap.add_argument('--no-project-data', action='store_true',
|
||||
help='Omit the optional Project Data section '
|
||||
'(song / instrument / sample names)')
|
||||
args = ap.parse_args()
|
||||
|
||||
set_verbose(args.verbose)
|
||||
@@ -795,7 +981,7 @@ def main():
|
||||
vprint(f" orders={len(mod['order_list'])}, patterns={mod['n_patterns']}, "
|
||||
f"samples={sum(1 for s in mod['samples'] if s.sample_data)}")
|
||||
|
||||
taud = assemble_taud(mod)
|
||||
taud = assemble_taud(mod, with_project_data=not args.no_project_data)
|
||||
|
||||
with open(args.output, 'wb') as f:
|
||||
f.write(taud)
|
||||
|
||||
558
mon2taud.py
Normal file
558
mon2taud.py
Normal file
@@ -0,0 +1,558 @@
|
||||
#!/usr/bin/env python3
|
||||
"""mon2taud.py — Convert Monotone (.MON) tracker modules to TSVM Taud (.taud)
|
||||
|
||||
Usage:
|
||||
python3 mon2taud.py input.MON output.taud [-v]
|
||||
|
||||
Monotone is Calvin "Trixter" French's tracker for the PC speaker / Tandy /
|
||||
TI-99 SN76489. It has no user-defined instruments (the only instrument is
|
||||
the beeper), 1..12 voices, 64 rows per pattern, ProTracker-flavoured 2-byte
|
||||
cells and a reduced 8-effect set: 0,1,2,3,4,B,D,F.
|
||||
|
||||
This converter:
|
||||
- synthesises a single 32-byte squarewave instrument (instrument #1)
|
||||
- splits each Monotone pattern (64 × N voices) into N Taud patterns
|
||||
- converts notes (A0=27.5 Hz chromatic) to Taud 4096-TET centred on C4
|
||||
- maps the 8 Monotone effects to their closest Taud equivalents
|
||||
- emits Hz/tick slides (1xx/2xx/3xx) verbatim and turns on Taud's
|
||||
linear-frequency tone mode (Effect 1 ff=2) so the engine interprets
|
||||
E/F/G arguments as Hz at A4=440 Hz reference — no scaling drift
|
||||
|
||||
Limits: numVoices ≤ 20, numPatterns × numVoices ≤ 4095.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import struct
|
||||
import sys
|
||||
|
||||
from taud_common import (
|
||||
set_verbose, vprint,
|
||||
TAUD_MAGIC, TAUD_VERSION, TAUD_HEADER_SIZE, TAUD_SONG_ENTRY,
|
||||
SAMPLEBIN_SIZE, INSTBIN_SIZE, SAMPLEINST_SIZE,
|
||||
PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES,
|
||||
NOTE_NOP, NOTE_KEYOFF, NOTE_CUT, TAUD_C4,
|
||||
TOP_NONE, TOP_A, TOP_B, TOP_C, TOP_E, TOP_F, TOP_G, TOP_H, TOP_J,
|
||||
SEL_SET, SEL_FINE,
|
||||
J_SEMI_TABLE,
|
||||
encode_cue, deduplicate_patterns, encode_song_entry, compress_blob,
|
||||
build_project_data, detect_subsongs,
|
||||
)
|
||||
|
||||
|
||||
# ── Monotone constants ───────────────────────────────────────────────────────
|
||||
|
||||
MON_MAGIC_PREFIX = b'\x08MONOTONE' # only the first 9 bytes are stable
|
||||
MON_HEADER_SIZE = 0x15F # 92 magic + 3 meta + 256 order list
|
||||
MON_PATTERN_ROWS = 64
|
||||
MON_CELL_BYTES = 2
|
||||
|
||||
# Effect-code (3-bit) → ProTracker-style letter, following the format-doc table.
|
||||
MON_EFFECT_LETTERS = ['0', '1', '2', '3', '4', 'B', 'D', 'F']
|
||||
|
||||
# Note value 1 = A0; C4 sits at value 40 (A0 + 39 semitones).
|
||||
MON_NOTE_C4 = 40
|
||||
|
||||
# Global behaviour flags byte (Taud Effect 1 / song-table byte 15):
|
||||
# bits 0-1 (ff): tone mode — 2 = linear-frequency (Hz/tick)
|
||||
# Selecting ff=2 makes the engine interpret 1xx/2xx/3xx slide arguments in
|
||||
# audible Hz at the A4=440 Hz reference, matching Monotone's MT_PLAY.PAS
|
||||
# `Frequency:=Frequency±parm1` arithmetic (see MTSRC/MT_PLAY.PAS:606-630).
|
||||
# Panning law is fixed to the equal-energy — there is no `p` bit any more.
|
||||
GLOBAL_FLAGS_LINEAR_FREQ = 0b10
|
||||
GLOBAL_FLAGS_NO_INTERPOLATION = 0b0100
|
||||
|
||||
|
||||
# ── Taud container ───────────────────────────────────────────────────────────
|
||||
|
||||
SIGNATURE = b"mon2taud/TSVM " # 14 bytes
|
||||
|
||||
|
||||
# ── Monotone parser ──────────────────────────────────────────────────────────
|
||||
|
||||
class MonRow:
|
||||
__slots__ = ('note', 'effect', 'effect_arg')
|
||||
def __init__(self):
|
||||
self.note = 0 # 0 = empty, 0x7F = note off, else 1..126
|
||||
self.effect = 0 # 0..7 (raw 3-bit code)
|
||||
self.effect_arg = 0 # 0..63 (6-bit data)
|
||||
|
||||
|
||||
def parse_mon(data: bytes):
|
||||
if len(data) < MON_HEADER_SIZE:
|
||||
sys.exit(f"error: file too short ({len(data)} bytes); "
|
||||
f"need at least {MON_HEADER_SIZE} for the header")
|
||||
|
||||
if data[:9] != MON_MAGIC_PREFIX:
|
||||
sys.exit(f"error: bad magic; expected '\\x08MONOTONE', got {data[:9]!r}")
|
||||
|
||||
song_len = data[0x5C]
|
||||
num_voices = data[0x5D]
|
||||
if num_voices < 1 or num_voices > 12:
|
||||
sys.exit(f"error: invalid voice count {num_voices} (expected 1..12)")
|
||||
|
||||
order_raw = data[0x5F:0x15F]
|
||||
# Effective order list: take first song_len entries and drop 0xFF skip-slots
|
||||
# (matches mtreader.lua and MT_PLAY.PAS' "ignore 0xFF" semantics).
|
||||
order_list = [b for b in order_raw[:song_len] if b != 0xFF]
|
||||
if not order_list:
|
||||
sys.exit("error: order list is empty after filtering 0xFF skip slots")
|
||||
|
||||
n_patterns = max(order_list) + 1
|
||||
pattern_size = MON_PATTERN_ROWS * num_voices * MON_CELL_BYTES
|
||||
expected = MON_HEADER_SIZE + n_patterns * pattern_size
|
||||
if len(data) < expected:
|
||||
sys.exit(f"error: file truncated; expected {expected} bytes for "
|
||||
f"{n_patterns} patterns × {num_voices} voices, got {len(data)}")
|
||||
|
||||
# patterns[pi][voice][row] -> MonRow
|
||||
patterns = []
|
||||
for pi in range(n_patterns):
|
||||
base = MON_HEADER_SIZE + pi * pattern_size
|
||||
grid = [[MonRow() for _ in range(MON_PATTERN_ROWS)] for _ in range(num_voices)]
|
||||
for r in range(MON_PATTERN_ROWS):
|
||||
row_off = base + r * num_voices * MON_CELL_BYTES
|
||||
for v in range(num_voices):
|
||||
cell_off = row_off + v * MON_CELL_BYTES
|
||||
# Little-endian 16-bit cell.
|
||||
word = data[cell_off] | (data[cell_off + 1] << 8)
|
||||
cell = grid[v][r]
|
||||
cell.note = (word >> 9) & 0x7F
|
||||
cell.effect = (word >> 6) & 0x07
|
||||
cell.effect_arg = word & 0x3F
|
||||
patterns.append(grid)
|
||||
|
||||
return {
|
||||
'song_len': song_len,
|
||||
'num_voices': num_voices,
|
||||
'order_list': order_list,
|
||||
'n_patterns': n_patterns,
|
||||
'patterns': patterns,
|
||||
}
|
||||
|
||||
|
||||
# ── Note conversion (Monotone → Taud 4096-TET) ───────────────────────────────
|
||||
|
||||
def mon_note_to_taud(mon_note: int) -> int:
|
||||
if mon_note == 0:
|
||||
return NOTE_NOP
|
||||
if mon_note == 0x7F:
|
||||
return NOTE_CUT
|
||||
val = TAUD_C4 + round((mon_note - MON_NOTE_C4) * 4096.0 / 12.0)
|
||||
return max(0x20, min(0xFFFF, val))
|
||||
|
||||
|
||||
# ── Effect mapping (Monotone 3-bit code + 6-bit data → Taud) ─────────────────
|
||||
|
||||
def encode_effect(eff_code: int, data: int) -> tuple:
|
||||
"""Return (taud_op, taud_arg16)."""
|
||||
letter = MON_EFFECT_LETTERS[eff_code & 7]
|
||||
|
||||
if letter == '0':
|
||||
if data == 0:
|
||||
return (TOP_NONE, 0)
|
||||
x = (data >> 3) & 0x7
|
||||
y = data & 0x7
|
||||
return (TOP_J, (J_SEMI_TABLE[x] << 8) | J_SEMI_TABLE[y])
|
||||
|
||||
if letter == '1': # slide up Hz/tick → Taud F (Hz/tick under ff=2)
|
||||
return (TOP_F, data & 0xFFFF)
|
||||
|
||||
if letter == '2': # slide down Hz/tick → Taud E (Hz/tick under ff=2)
|
||||
return (TOP_E, data & 0xFFFF)
|
||||
|
||||
if letter == '3': # tone porta Hz/tick → Taud G (Hz/tick under ff=2)
|
||||
return (TOP_G, data & 0xFFFF)
|
||||
|
||||
if letter == '4': # vibrato xy → Taud H
|
||||
x = (data >> 3) & 0x7 # speed (3 bits)
|
||||
y = data & 0x7 # depth (3 bits)
|
||||
# Scale 3-bit nibble (0..7) to 8-bit byte (0..252) via × 0x24 (= 36).
|
||||
return (TOP_H, ((x * 0x24) << 8) | (y * 0x24))
|
||||
|
||||
if letter == 'B': # position jump → Taud B
|
||||
return (TOP_B, data & 0xFF)
|
||||
|
||||
if letter == 'D': # pattern break → Taud C
|
||||
return (TOP_C, data & 0xFF)
|
||||
|
||||
if letter == 'F': # set speed → Taud A
|
||||
if data == 0: # invalid in Monotone
|
||||
return (TOP_NONE, 0)
|
||||
return (TOP_A, (data & 0xFF) << 8)
|
||||
|
||||
return (TOP_NONE, 0)
|
||||
|
||||
|
||||
# ── Squarewave instrument synthesis ──────────────────────────────────────────
|
||||
|
||||
# 32-byte single-cycle 50%-duty square; played at 8372 Hz at C4 → 261.6 Hz tone.
|
||||
SQUARE_SAMPLE = bytes([0xFF] * 16 + [0x00] * 16)
|
||||
SQUARE_C2SPD = 8372
|
||||
|
||||
def build_sample_inst_bin() -> bytes:
|
||||
"""Emit the full 786432-byte sample+instrument bin.
|
||||
|
||||
Instrument 1 carries the synthesised square wave; all other slots stay
|
||||
zero. Sample bin starts with the 32-byte square at offset 0; rest is
|
||||
silence padding.
|
||||
"""
|
||||
sample_bin = bytearray(SAMPLEBIN_SIZE)
|
||||
sample_bin[0:len(SQUARE_SAMPLE)] = SQUARE_SAMPLE
|
||||
|
||||
inst_bin = bytearray(INSTBIN_SIZE)
|
||||
base = 1 * 256 # instrument #1 (slot 0 always blank)
|
||||
struct.pack_into('<I', inst_bin, base + 0, 0) # sample ptr
|
||||
struct.pack_into('<H', inst_bin, base + 4, len(SQUARE_SAMPLE)) # length
|
||||
struct.pack_into('<H', inst_bin, base + 6, SQUARE_C2SPD) # rate at C4
|
||||
struct.pack_into('<H', inst_bin, base + 8, 0) # play start
|
||||
struct.pack_into('<H', inst_bin, base + 10, 0) # loop start
|
||||
struct.pack_into('<H', inst_bin, base + 12, len(SQUARE_SAMPLE)) # loop end
|
||||
inst_bin[base + 14] = 0x01 # forward loop
|
||||
struct.pack_into('<H', inst_bin, base + 15, 0x2020) # vol-env: P (bit 13) | b (bit 5)
|
||||
struct.pack_into('<H', inst_bin, base + 17, 0) # pan-env flags (P=0 → mixer skips)
|
||||
struct.pack_into('<H', inst_bin, base + 19, 0) # pitch-env flags (P=0 → mixer skips)
|
||||
inst_bin[base + 21] = 63 # vol env pt 0 = full
|
||||
inst_bin[base + 22] = 0
|
||||
inst_bin[base + 171] = 0xA0 # IGV (square-wave headroom)
|
||||
inst_bin[base + 177] = 0x80 # default pan = centre
|
||||
inst_bin[base + 182] = 0xFF # filter cutoff off
|
||||
inst_bin[base + 183] = 0xFF # filter resonance off
|
||||
inst_bin[base + 186] = 0x01 # NNA: cut
|
||||
# Monotone has no per-sample default volume concept (only one synth
|
||||
# voice, no V column overrides). Set DNV to full so triggers seed
|
||||
# noteVolume at 0x3F; the IGV above provides the actual attenuation.
|
||||
inst_bin[base + 196] = 0xFF # DNV: full
|
||||
|
||||
return bytes(sample_bin) + bytes(inst_bin)
|
||||
|
||||
|
||||
# ── Pattern build ────────────────────────────────────────────────────────────
|
||||
|
||||
def build_taud_pattern(grid: list, voice: int) -> bytes:
|
||||
"""Build one 512-byte Taud pattern from one Monotone voice's 64 rows."""
|
||||
out = bytearray(PATTERN_BYTES)
|
||||
rows = grid[voice]
|
||||
for r, row in enumerate(rows):
|
||||
note_taud = mon_note_to_taud(row.note)
|
||||
# Trigger instrument #1 only when an actual note (1..0x7E) starts.
|
||||
triggers = (1 <= row.note <= 0x7E)
|
||||
|
||||
op, arg = encode_effect(row.effect, row.effect_arg)
|
||||
|
||||
# Volume column: Monotone has none → permanent no-op (FINE 0).
|
||||
vol_byte = (SEL_FINE << 6) | 0
|
||||
# Pan column: SET centre on row 0, no-op afterwards.
|
||||
if r == 0:
|
||||
pan_byte = (SEL_SET << 6) | 32
|
||||
else:
|
||||
pan_byte = (SEL_FINE << 6) | 0
|
||||
|
||||
base = r * 8
|
||||
struct.pack_into('<H', out, base + 0, note_taud)
|
||||
out[base + 2] = 1 if triggers else 0
|
||||
out[base + 3] = vol_byte
|
||||
out[base + 4] = pan_byte
|
||||
out[base + 5] = op & 0xFF
|
||||
struct.pack_into('<H', out, base + 6, arg & 0xFFFF)
|
||||
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def build_cue_sheet(order_list: list, num_voices: int, pat_remap: dict) -> bytes:
|
||||
"""One cue per order-list entry; last cue carries the halt instruction."""
|
||||
sheet = bytearray(NUM_CUES * CUE_SIZE)
|
||||
for c in range(NUM_CUES):
|
||||
sheet[c*CUE_SIZE : (c+1)*CUE_SIZE] = encode_cue([], 0)
|
||||
|
||||
cue_idx = 0
|
||||
last_active = -1
|
||||
for order in order_list:
|
||||
if cue_idx >= NUM_CUES:
|
||||
break
|
||||
orig_pats = [order * num_voices + v for v in range(num_voices)]
|
||||
mapped = [pat_remap[p] for p in orig_pats]
|
||||
sheet[cue_idx*CUE_SIZE : (cue_idx+1)*CUE_SIZE] = encode_cue(mapped, 0)
|
||||
last_active = cue_idx
|
||||
cue_idx += 1
|
||||
|
||||
if last_active >= 0:
|
||||
sheet[last_active * CUE_SIZE + 30] = 0x01
|
||||
|
||||
return bytes(sheet)
|
||||
|
||||
|
||||
# ── Initial speed scan ───────────────────────────────────────────────────────
|
||||
|
||||
def find_initial_speed(patterns: list, order_list: list, num_voices: int) -> int:
|
||||
"""Pick up an Fxx in the first ordered pattern's row 0 if present.
|
||||
|
||||
Default tempo per MT_PLAY.PAS:238-239 is `max(numTracks, 4)`.
|
||||
"""
|
||||
default_speed = max(num_voices, 4)
|
||||
if not order_list:
|
||||
return default_speed
|
||||
first = order_list[0]
|
||||
if first >= len(patterns):
|
||||
return default_speed
|
||||
grid = patterns[first]
|
||||
for v in range(num_voices):
|
||||
row = grid[v][0]
|
||||
if row.effect == 7 and 0 < row.effect_arg < 0x40: # Fxx (idx 7)
|
||||
return row.effect_arg
|
||||
return default_speed
|
||||
|
||||
|
||||
# ── Top-level assembly ───────────────────────────────────────────────────────
|
||||
|
||||
def _per_pattern_bxx_mon(patterns: list, num_voices: int):
|
||||
"""Return callable(pat_idx) → (set_of_bxx_target_orders, kills_fallthrough)
|
||||
for `detect_subsongs`. Monotone effect index 5 is 'B' (position jump);
|
||||
arg is 6 bits (0..63). Patterns are 64 rows × num_voices. `grid[v][r]`.
|
||||
"""
|
||||
def fn(pat_idx: int):
|
||||
if pat_idx < 0 or pat_idx >= len(patterns):
|
||||
return set(), False
|
||||
grid = patterns[pat_idx]
|
||||
targets = set()
|
||||
last_row_has_b = False
|
||||
for v in range(min(num_voices, len(grid))):
|
||||
v_rows = grid[v]
|
||||
for r in range(min(MON_PATTERN_ROWS, len(v_rows))):
|
||||
cell = v_rows[r]
|
||||
if cell.effect == 5:
|
||||
targets.add(cell.effect_arg & 0x3F)
|
||||
if r == MON_PATTERN_ROWS - 1:
|
||||
last_row_has_b = True
|
||||
return targets, last_row_has_b
|
||||
return fn
|
||||
|
||||
|
||||
def _build_song_payload_mon(mon: dict, patterns_template: list,
|
||||
positions: list, num_voices: int,
|
||||
*, song_label: str = 'song') -> tuple:
|
||||
"""Build pattern bin + cue sheet + song-entry kwargs for one Monotone
|
||||
subsong. Mutates a deepcopy of the patterns to remap Bxx targets to
|
||||
per-song cue indices.
|
||||
"""
|
||||
patterns = copy.deepcopy(patterns_template)
|
||||
order_list = mon['order_list']
|
||||
n_patterns = mon['n_patterns']
|
||||
virtual_orders = [order_list[pos] for pos in positions]
|
||||
|
||||
speed = find_initial_speed(patterns, virtual_orders, num_voices)
|
||||
vprint(f" [{song_label}] initial speed (ticks/row): {speed}")
|
||||
|
||||
cue_list = []
|
||||
pos_to_cue = {}
|
||||
for pos in positions:
|
||||
order = order_list[pos]
|
||||
if order >= n_patterns:
|
||||
continue
|
||||
pos_to_cue[pos] = len(cue_list)
|
||||
cue_list.append(order)
|
||||
|
||||
used_ordered = []
|
||||
seen = set()
|
||||
for src_pat in cue_list:
|
||||
if src_pat not in seen:
|
||||
used_ordered.append(src_pat)
|
||||
seen.add(src_pat)
|
||||
pat_idx_remap = {src: i for i, src in enumerate(used_ordered)}
|
||||
P_used = len(used_ordered)
|
||||
|
||||
if P_used * num_voices > NUM_PATTERNS_MAX:
|
||||
sys.exit(f"error: [{song_label}] {P_used} patterns × {num_voices} voices = "
|
||||
f"{P_used*num_voices} > {NUM_PATTERNS_MAX} Taud pattern limit.")
|
||||
|
||||
# Bxx remap: source position → cue index. Cross-song clamps to cue 0.
|
||||
crossings = 0
|
||||
for src_pat in used_ordered:
|
||||
if src_pat >= len(patterns): continue
|
||||
grid = patterns[src_pat]
|
||||
for v in range(min(num_voices, len(grid))):
|
||||
for row in grid[v]:
|
||||
if row.effect == 5:
|
||||
if row.effect_arg in pos_to_cue:
|
||||
row.effect_arg = pos_to_cue[row.effect_arg] & 0x3F
|
||||
else:
|
||||
crossings += 1
|
||||
row.effect_arg = 0
|
||||
if crossings:
|
||||
vprint(f" warning: [{song_label}]: {crossings} Bxx target(s) cross "
|
||||
f"subsong boundary; clamped to cue 0")
|
||||
|
||||
pat_bin = bytearray()
|
||||
for src_pat in used_ordered:
|
||||
grid = patterns[src_pat]
|
||||
for v in range(num_voices):
|
||||
pat_bin += build_taud_pattern(grid, v)
|
||||
|
||||
orig_count = P_used * num_voices
|
||||
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(bytes(pat_bin), orig_count)
|
||||
vprint(f" [{song_label}] patterns: {orig_count} → {num_taud_pats} unique "
|
||||
f"({orig_count - num_taud_pats} deduplicated)")
|
||||
|
||||
sheet = bytearray(NUM_CUES * CUE_SIZE)
|
||||
for c in range(NUM_CUES):
|
||||
sheet[c*CUE_SIZE:(c+1)*CUE_SIZE] = encode_cue([], 0)
|
||||
|
||||
last_active = -1
|
||||
for cue_idx, src_pat in enumerate(cue_list):
|
||||
if cue_idx >= NUM_CUES: break
|
||||
new_pat_idx = pat_idx_remap[src_pat]
|
||||
orig_pats = [new_pat_idx * num_voices + v for v in range(num_voices)]
|
||||
sheet[cue_idx*CUE_SIZE:(cue_idx+1)*CUE_SIZE] = encode_cue(
|
||||
[pat_remap[p] for p in orig_pats], 0)
|
||||
last_active = cue_idx
|
||||
if last_active >= 0:
|
||||
sheet[last_active * CUE_SIZE + 30] = 0x01
|
||||
|
||||
pat_comp = compress_blob(bytes(pat_bin), f"[{song_label}] pattern bin")
|
||||
cue_comp = compress_blob(bytes(sheet), f"[{song_label}] cue sheet")
|
||||
|
||||
flags_byte = GLOBAL_FLAGS_LINEAR_FREQ | GLOBAL_FLAGS_NO_INTERPOLATION
|
||||
bpm_stored = 150 - 25
|
||||
entry_kwargs = dict(
|
||||
num_voices=num_voices,
|
||||
num_patterns=num_taud_pats,
|
||||
bpm_stored=bpm_stored,
|
||||
tick_rate=speed,
|
||||
base_note=0xA000,
|
||||
base_freq=SQUARE_C2SPD,
|
||||
flags_byte=flags_byte,
|
||||
pat_bin_comp_size=len(pat_comp),
|
||||
cue_sheet_comp_size=len(cue_comp),
|
||||
global_vol=0xFF,
|
||||
mixing_vol=round(180 / num_voices),
|
||||
)
|
||||
return pat_comp, cue_comp, entry_kwargs
|
||||
|
||||
|
||||
def assemble_taud(mon: dict, with_project_data: bool = True) -> bytes:
|
||||
num_voices = mon['num_voices']
|
||||
patterns = mon['patterns']
|
||||
order_list = mon['order_list']
|
||||
n_patterns = mon['n_patterns']
|
||||
|
||||
if num_voices > NUM_VOICES:
|
||||
vprint(f" warning: {num_voices} voices > {NUM_VOICES}; truncating")
|
||||
num_voices = NUM_VOICES
|
||||
vprint(f" voices: {num_voices}, mon patterns: {n_patterns}")
|
||||
|
||||
vprint(" building sample/instrument bin…")
|
||||
sampleinst_raw = build_sample_inst_bin()
|
||||
assert len(sampleinst_raw) == SAMPLEINST_SIZE
|
||||
compressed = compress_blob(sampleinst_raw, "sample+inst bin")
|
||||
comp_size = len(compressed)
|
||||
|
||||
# ── Detect subsongs ──────────────────────────────────────────────────────
|
||||
# Monotone strips 0xFF (skip) markers during parse, so the order list is
|
||||
# already a clean sequence of pattern indices. No terminator/skip values
|
||||
# to feed the detector — subsongs only emerge from the Bxx graph.
|
||||
skip_set = set(range(n_patterns, 256)) # invalid pattern refs → skip
|
||||
subsongs = detect_subsongs(order_list,
|
||||
_per_pattern_bxx_mon(patterns, num_voices),
|
||||
terminators=(),
|
||||
skip_marker=skip_set)
|
||||
if not subsongs:
|
||||
vprint(" warning: no traversable orders in source; emitting empty song")
|
||||
subsongs = [{'entry': 0, 'positions': []}]
|
||||
n_songs = len(subsongs)
|
||||
if n_songs == 1:
|
||||
vprint(f" detected 1 song ({len(subsongs[0]['positions'])} orders)")
|
||||
else:
|
||||
vprint(f" detected {n_songs} subsongs:")
|
||||
for i, ss in enumerate(subsongs):
|
||||
vprint(f" song {i}: entry@{ss['entry']}, {len(ss['positions'])} orders")
|
||||
|
||||
# ── Build per-song payloads ──────────────────────────────────────────────
|
||||
song_payloads = []
|
||||
for i, ss in enumerate(subsongs):
|
||||
label = f"song {i}" if n_songs > 1 else "song"
|
||||
song_payloads.append(_build_song_payload_mon(
|
||||
mon, patterns, ss['positions'], num_voices, song_label=label))
|
||||
|
||||
# ── Layout offsets and song table ────────────────────────────────────────
|
||||
song_table_off = TAUD_HEADER_SIZE + comp_size
|
||||
first_song_off = song_table_off + TAUD_SONG_ENTRY * n_songs
|
||||
|
||||
song_table = bytearray()
|
||||
cur_off = first_song_off
|
||||
for pat_comp, cue_comp, entry_kwargs in song_payloads:
|
||||
entry = encode_song_entry(song_offset=cur_off, **entry_kwargs)
|
||||
assert len(entry) == TAUD_SONG_ENTRY
|
||||
song_table += entry
|
||||
cur_off += len(pat_comp) + len(cue_comp)
|
||||
|
||||
# Project Data (optional). Monotone has no title, no user instruments and
|
||||
# no per-sample names, but we still emit one identifying entry so the
|
||||
# synthesised square slot is documented.
|
||||
proj_data = b''
|
||||
proj_off = 0
|
||||
if with_project_data:
|
||||
proj_data = build_project_data(
|
||||
instrument_names=['', 'PC speaker square'],
|
||||
sample_names=['', 'PC speaker square'],
|
||||
)
|
||||
if proj_data:
|
||||
proj_off = cur_off
|
||||
vprint(f" project data: {len(proj_data)} bytes @ offset {proj_off}")
|
||||
|
||||
sig = (SIGNATURE + b' ' * 14)[:14]
|
||||
header = (
|
||||
TAUD_MAGIC
|
||||
+ bytes([TAUD_VERSION, n_songs & 0xFF])
|
||||
+ struct.pack('<I', comp_size)
|
||||
+ struct.pack('<I', proj_off)
|
||||
+ sig
|
||||
)
|
||||
assert len(header) == TAUD_HEADER_SIZE
|
||||
|
||||
out = bytearray()
|
||||
out += header
|
||||
out += compressed
|
||||
out += song_table
|
||||
for pat_comp, cue_comp, _ in song_payloads:
|
||||
out += pat_comp
|
||||
out += cue_comp
|
||||
out += proj_data
|
||||
return bytes(out)
|
||||
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument('input', help='Input .MON file')
|
||||
ap.add_argument('output', help='Output .taud file')
|
||||
ap.add_argument('-v', '--verbose', action='store_true',
|
||||
help='Print conversion details to stderr')
|
||||
ap.add_argument('--no-project-data', action='store_true',
|
||||
help='Omit the optional Project Data section '
|
||||
'(song / instrument / sample names)')
|
||||
args = ap.parse_args()
|
||||
|
||||
set_verbose(args.verbose)
|
||||
|
||||
with open(args.input, 'rb') as f:
|
||||
data = f.read()
|
||||
|
||||
vprint(f"parsing '{args.input}' ({len(data)} bytes)…")
|
||||
mon = parse_mon(data)
|
||||
vprint(f" songLen={mon['song_len']}, voices={mon['num_voices']}, "
|
||||
f"patterns={mon['n_patterns']}, orders={len(mon['order_list'])}")
|
||||
|
||||
taud = assemble_taud(mon, with_project_data=not args.no_project_data)
|
||||
|
||||
with open(args.output, 'wb') as f:
|
||||
f.write(taud)
|
||||
|
||||
print(f"wrote {len(taud)} bytes to '{args.output}'")
|
||||
if args.verbose:
|
||||
print(f" magic ok: {taud[:8].hex()}", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
421
s3m2taud.py
421
s3m2taud.py
@@ -7,9 +7,9 @@ Usage:
|
||||
Limits:
|
||||
- Up to 20 S3M channels (excess disabled; hard error if pattern count
|
||||
× channel count > 4095).
|
||||
- Sample bin is 737280 bytes; if all samples together exceed this, every
|
||||
sample is globally resampled down (with c2spd adjusted) so pitch is
|
||||
preserved.
|
||||
- Sample bin is 8 MB (8388608 bytes); if all samples together exceed
|
||||
this, every sample is globally resampled down (with c2spd adjusted)
|
||||
so pitch is preserved.
|
||||
- AdLib instruments are skipped.
|
||||
|
||||
Effect support:
|
||||
@@ -25,7 +25,7 @@ Effect support:
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import copy
|
||||
import math
|
||||
import struct
|
||||
import sys
|
||||
@@ -37,14 +37,15 @@ from taud_common import (
|
||||
PATTERN_ROWS, PATTERN_BYTES, NUM_PATTERNS_MAX, NUM_CUES, CUE_SIZE, NUM_VOICES,
|
||||
NOTE_NOP, NOTE_KEYOFF, NOTE_CUT, TAUD_C4,
|
||||
TOP_NONE, TOP_A, TOP_B, TOP_C, TOP_D, TOP_E, TOP_F, TOP_G, TOP_H, TOP_I,
|
||||
TOP_J, TOP_K, TOP_L, TOP_O, TOP_Q, TOP_R, TOP_S, TOP_T, TOP_U, TOP_V, TOP_W, TOP_Y,
|
||||
TOP_J, TOP_K, TOP_L, TOP_M, TOP_N, TOP_O, TOP_P, TOP_Q, TOP_R, TOP_S, TOP_T, TOP_U, TOP_V, TOP_W, TOP_Y,
|
||||
SEL_SET, SEL_UP, SEL_DOWN, SEL_FINE,
|
||||
EFF_A, EFF_B, EFF_C, EFF_D, EFF_E, EFF_F, EFF_G, EFF_H, EFF_I, EFF_J,
|
||||
EFF_K, EFF_L, EFF_M, EFF_N, EFF_O, EFF_P, EFF_Q, EFF_R, EFF_S, EFF_T,
|
||||
EFF_U, EFF_V, EFF_W, EFF_X, EFF_Y, EFF_Z,
|
||||
J_SEMI_TABLE,
|
||||
d_arg_to_col, resample_linear, encode_cue, deduplicate_patterns,
|
||||
normalise_sample, encode_song_entry,
|
||||
d_arg_to_col, resample_linear, rescale_offset_effects, encode_cue, deduplicate_patterns,
|
||||
normalise_sample, encode_song_entry, compress_blob,
|
||||
build_project_data, detect_subsongs,
|
||||
)
|
||||
|
||||
|
||||
@@ -137,7 +138,11 @@ def parse_instruments(data: bytes, h: S3MHeader) -> list:
|
||||
continue
|
||||
inst = S3MInstrument()
|
||||
inst.itype = data[ptr]
|
||||
inst.filename = data[ptr+1:ptr+13].rstrip(b'\x00').decode('latin-1', errors='replace')
|
||||
# 12-byte DOS filename field; null-terminated with possible trailing
|
||||
# garbage after the terminator (ST3 doesn't zero the tail). Truncate at
|
||||
# the first null. This field carries the per-sample short name (e.g.
|
||||
# 'HIT1') as distinct from the 28-byte title at 0x30.
|
||||
inst.filename = data[ptr+1:ptr+13].split(b'\x00', 1)[0].decode('latin-1', errors='replace')
|
||||
# memseg: 3 bytes at offsets 0x0D,0x0E,0x0F — high byte first (quirk)
|
||||
memseg_hi = data[ptr + 0x0D]
|
||||
memseg_lo = struct.unpack_from('<H', data, ptr + 0x0E)[0]
|
||||
@@ -226,14 +231,14 @@ def encode_note(s3m_note: int) -> int:
|
||||
if s3m_note == S3M_NOTE_EMPTY:
|
||||
return NOTE_NOP
|
||||
if s3m_note == S3M_NOTE_OFF:
|
||||
return NOTE_KEYOFF
|
||||
return NOTE_CUT
|
||||
octave = (s3m_note >> 4) & 0xF
|
||||
pitch = s3m_note & 0xF
|
||||
if pitch > 11:
|
||||
return NOTE_NOP
|
||||
semitones = (octave - 4) * 12 + pitch
|
||||
val = round(TAUD_C4 + semitones * 4096 / 12)
|
||||
return max(1, min(0xFFFD, val))
|
||||
return max(0x20, min(0xFFFF, val))
|
||||
|
||||
|
||||
def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0,
|
||||
@@ -305,25 +310,28 @@ def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0,
|
||||
None, None)
|
||||
|
||||
if cmd == EFF_K:
|
||||
# K = vibrato continuation + vol slide; engine treats K as no-op.
|
||||
# Split into: H $0000 (recall vibrato from HU memory) + vol-col slide.
|
||||
return (TOP_H, 0x0000, d_arg_to_col(arg), None)
|
||||
# K = vibrato continuation + vol slide; emitted verbatim. ST3's shared
|
||||
# memory cohort is already resolved upstream by resolve_st3_recalls.
|
||||
return (TOP_K, (arg & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == EFF_L:
|
||||
# L = tone-porta continuation + vol slide; split similarly.
|
||||
return (TOP_G, 0x0000, d_arg_to_col(arg), None)
|
||||
# L = tone-porta continuation + vol slide; emitted verbatim.
|
||||
return (TOP_L, (arg & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == EFF_M:
|
||||
return (TOP_NONE, 0, (SEL_SET, min(arg, 0x3F)), None)
|
||||
# M = set channel volume; literal byte (no recall). Clamp ST3/IT $40 → $3F.
|
||||
return (TOP_M, (min(arg, 0x3F) & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == EFF_N:
|
||||
return (TOP_NONE, 0, d_arg_to_col(arg), None)
|
||||
# N = channel volume slide; D-style encoding.
|
||||
return (TOP_N, (arg & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == EFF_O:
|
||||
return (TOP_O, (arg & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == EFF_P:
|
||||
return (TOP_NONE, 0, None, d_arg_to_col(arg))
|
||||
# P = channel panning slide; D-style encoding (low nib = right, high nib = left).
|
||||
return (TOP_P, (arg & 0xFF) << 8, None, None)
|
||||
|
||||
if cmd == EFF_Q:
|
||||
return (TOP_Q, (arg & 0xFF) << 8, None, None)
|
||||
@@ -349,7 +357,7 @@ def encode_effect(cmd: int, arg: int, ch: int = 0, row: int = 0,
|
||||
|
||||
if cmd == EFF_T:
|
||||
if arg >= 0x20:
|
||||
return (TOP_T, ((arg - 0x18) & 0xFF) << 8, None, None)
|
||||
return (TOP_T, ((arg - 0x19) & 0xFF) << 8, None, None)
|
||||
# OpenMPT slide forms: $0y down per tick, $1y up per tick.
|
||||
return (TOP_T, arg & 0xFF, None, None)
|
||||
|
||||
@@ -476,9 +484,10 @@ def build_sample_inst_bin(instruments: list) -> tuple:
|
||||
inst.loop_end = min(inst.loop_end, n)
|
||||
pos += n
|
||||
|
||||
# Build instrument bin (256 × 192 bytes)
|
||||
# New layout (terranmon.txt:1997-2070): u32 sample ptr, ..., 25-point envelopes,
|
||||
# plus a host of optional fields. S3M doesn't supply most of those — they default to 0.
|
||||
# Build instrument bin (256 × 256 bytes)
|
||||
# New layout (terranmon.txt:2001+): LOOP words at 15/17/19, SUSTAIN words at 189/191/193.
|
||||
# S3M has no envelope sustain or loop, so SUSTAIN words stay zero.
|
||||
INST_STRIDE = 256
|
||||
inst_bin = bytearray(INSTBIN_SIZE)
|
||||
for i, inst in enumerate(instruments):
|
||||
taud_idx = i + 1
|
||||
@@ -495,13 +504,17 @@ def build_sample_inst_bin(instruments: list) -> tuple:
|
||||
loop_mode = 1 if (inst.flags & 1) else 0
|
||||
flags_byte = loop_mode & 0x3 # 0b 0000 00pp
|
||||
|
||||
# Volume envelope first point is full-scale; per-sample level is carried
|
||||
# by IGV (byte 171) so the envelope contributes a unit multiplier.
|
||||
# Volume envelope first point is full-scale; per-trigger initial level
|
||||
# is carried by Default Note Volume (byte 196), so the envelope
|
||||
# contributes a unit multiplier.
|
||||
env_vol = 63
|
||||
# Vol env-flags: enable use-envelope bit (b=1) so engine reads the single point.
|
||||
vol_env_flags = 0x0020 # b=bit 5
|
||||
# Vol LOOP word: P=1 (envelope present) | b=1 (use envelope) — no actual
|
||||
# loop / sustain. P added 2026-05-06 alongside the pan/pf gate spec
|
||||
# change (terranmon.txt byte 16/18/20 bit 5); informational for vol but
|
||||
# set for consistency. Pan/PF stay zero so the engine sees P=0 there.
|
||||
vol_env_loop = 0x2020
|
||||
|
||||
base = taud_idx * 192
|
||||
base = taud_idx * INST_STRIDE
|
||||
struct.pack_into('<I', inst_bin, base + 0, ptr) # u32 sample pointer
|
||||
struct.pack_into('<H', inst_bin, base + 4, s_len)
|
||||
struct.pack_into('<H', inst_bin, base + 6, c2spd) # rate at TAUD_C4
|
||||
@@ -509,26 +522,30 @@ def build_sample_inst_bin(instruments: list) -> tuple:
|
||||
struct.pack_into('<H', inst_bin, base + 10, ls)
|
||||
struct.pack_into('<H', inst_bin, base + 12, le)
|
||||
inst_bin[base + 14] = flags_byte
|
||||
struct.pack_into('<H', inst_bin, base + 15, vol_env_flags)
|
||||
struct.pack_into('<H', inst_bin, base + 17, 0) # pan env-flags
|
||||
struct.pack_into('<H', inst_bin, base + 19, 0) # pitch/filter env-flags
|
||||
# LOOP words at 15/17/19; SUSTAIN words at 189/191/193 (left zero).
|
||||
struct.pack_into('<H', inst_bin, base + 15, vol_env_loop)
|
||||
struct.pack_into('<H', inst_bin, base + 17, 0)
|
||||
struct.pack_into('<H', inst_bin, base + 19, 0)
|
||||
# Volume env point 0: hold at env_vol indefinitely (offset minifloat = 0 → hold).
|
||||
inst_bin[base + 21] = env_vol
|
||||
inst_bin[base + 22] = 0
|
||||
# Instrument Global Volume carries the S3M instrument's default volume (0..64 → 0..255).
|
||||
# The pattern builder no longer emits SEL_SET=Sv on note triggers; the engine
|
||||
# multiplies by IGV instead, so the per-instrument level lives here.
|
||||
inst_bin[base + 171] = min(0xFF, round(min(inst.volume, 64) * 255 / 64))
|
||||
# S3M has no continuous instrumentwise volume scaler — its `inst.volume`
|
||||
# (0..64) is purely the per-trigger initial value, equivalent to IT's
|
||||
# sample.vol. So byte 171 (IGV) stays at full and byte 196 (DNV)
|
||||
# carries the per-instrument default. Pre-2026-05-09 layout folded
|
||||
# inst.volume into IGV — see terranmon §2350.
|
||||
inst_bin[base + 171] = 0xFF # IGV: continuous unity
|
||||
inst_bin[base + 177] = 0x80 # default pan = centre (unused; pan env "p" flag not set)
|
||||
inst_bin[base + 182] = 0xFF # filter cutoff = off
|
||||
inst_bin[base + 183] = 0xFF # filter resonance = off
|
||||
inst_bin[base + 186] = 1 # NNA: note cut
|
||||
inst_bin[base + 196] = min(0xFF, round(min(inst.volume, 64) * 255 / 64)) # DNV
|
||||
|
||||
vprint(f" instrument[{base // 192}] '{inst.name}' ptr: '{ptr}', sampling rate: '{inst.c2spd}'")
|
||||
vprint(f" instrument[{base // INST_STRIDE}] '{inst.name}' ptr: '{ptr}', sampling rate: '{inst.c2spd}'")
|
||||
if inst.c2spd > 65535:
|
||||
vprint(f" warning: sampling rate of '{inst.name}' exceeds 65535 (got '{inst.c2spd}')")
|
||||
|
||||
return bytes(sample_bin) + bytes(inst_bin), offsets
|
||||
return bytes(sample_bin) + bytes(inst_bin), offsets, ratio
|
||||
|
||||
|
||||
def _default_channel_pan(ch_setting: int) -> int:
|
||||
@@ -550,8 +567,9 @@ def build_pattern(s3m_grid: list, ch_idx: int, default_pan: int,
|
||||
|
||||
Volume column: explicit S3M cell vol -> SEL_SET; M/N/K/L vol slides folded
|
||||
by encode_effect -> vol_override; otherwise SEL_FINE/0 (no-op). Per-
|
||||
instrument default volume lives in IGV (byte 171) and is applied by the
|
||||
engine on every fresh trigger, so the converter no longer emits SEL_SET=Sv.
|
||||
instrument default volume lives in DNV (byte 196) and is consulted by
|
||||
the engine when the trigger row has no V column, so the converter
|
||||
doesn't need to emit SEL_SET=Sv on plain trigger rows.
|
||||
Pan column: row 0 emits SEL_SET = default_pan to position the channel;
|
||||
other rows default to SEL_FINE/0 unless an X/P/etc effect overrides.
|
||||
"""
|
||||
@@ -711,106 +729,146 @@ def find_initial_bpm_speed(patterns: list, order_list: list,
|
||||
return speed, tempo
|
||||
|
||||
|
||||
def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes:
|
||||
# Determine active channels (bit7 clear = enabled)
|
||||
active_channels = [i for i, cs in enumerate(h.channel_settings)
|
||||
if i < 32 and not (cs & 0x80)][:NUM_VOICES]
|
||||
C = len(active_channels)
|
||||
P = len(patterns)
|
||||
def _per_pattern_bxx_s3m(patterns: list):
|
||||
"""Return callable(pat_idx) → (set_of_bxx_target_orders, kills_fallthrough)
|
||||
for `detect_subsongs`. `kills_fallthrough` is True iff the pattern carries
|
||||
a Bxx on its absolute last row (the unconditional terminating-jump idiom).
|
||||
S3M patterns are always 64 rows.
|
||||
"""
|
||||
def fn(pat_idx: int):
|
||||
if pat_idx < 0 or pat_idx >= len(patterns):
|
||||
return set(), False
|
||||
grid = patterns[pat_idx]
|
||||
targets = set()
|
||||
last_row_has_b = False
|
||||
for ch in range(min(32, len(grid))):
|
||||
ch_rows = grid[ch]
|
||||
for r in range(min(PATTERN_ROWS, len(ch_rows))):
|
||||
cell = ch_rows[r]
|
||||
if getattr(cell, 'effect', 0) == EFF_B:
|
||||
targets.add(cell.effect_arg)
|
||||
if r == PATTERN_ROWS - 1:
|
||||
last_row_has_b = True
|
||||
return targets, last_row_has_b
|
||||
return fn
|
||||
|
||||
if P * C > NUM_PATTERNS_MAX:
|
||||
|
||||
def _build_song_payload_s3m(h: S3MHeader, patterns_template: list,
|
||||
positions: list, sample_ratio: dict,
|
||||
inst_vols: dict, active_channels: list,
|
||||
*, song_label: str = 'song') -> tuple:
|
||||
"""Build pattern bin + cue sheet + song-entry kwargs for one subsong.
|
||||
|
||||
Returns (pat_comp, cue_comp, entry_kwargs). The caller fills in
|
||||
`song_offset` from the global layout. `patterns_template` is deep-copied
|
||||
so per-song stateful walks (recall resolution, late-note-delay
|
||||
relocation, Bxx remap) don't leak into the next subsong.
|
||||
"""
|
||||
pats = copy.deepcopy(patterns_template)
|
||||
virtual_orders = [h.order_list[pos] for pos in positions]
|
||||
|
||||
vprint(f" [{song_label}] resolving ST3 shared-memory recalls…")
|
||||
resolve_st3_recalls(pats, virtual_orders, 32)
|
||||
warn_st3_quirks(pats, virtual_orders, 32)
|
||||
|
||||
init_speed, _ = find_initial_bpm_speed(pats, virtual_orders,
|
||||
h.initial_speed, h.initial_tempo)
|
||||
relocate_late_note_delays(pats, virtual_orders, 32, init_speed)
|
||||
|
||||
speed, tempo = find_initial_bpm_speed(pats, virtual_orders,
|
||||
h.initial_speed, h.initial_tempo)
|
||||
tempo = max(25, min(280, tempo))
|
||||
bpm_stored = (tempo - 25) & 0xFF
|
||||
vprint(f" [{song_label}] initial speed={speed}, tempo(BPM)={tempo}")
|
||||
|
||||
# Cue list (source pattern indices) and pos→cue mapping. Skip orders that
|
||||
# already terminate (S3M_ORDER_END) or point past the pattern table.
|
||||
cue_list = []
|
||||
pos_to_cue = {}
|
||||
for pos in positions:
|
||||
order = h.order_list[pos]
|
||||
if order >= S3M_ORDER_END or order >= len(pats):
|
||||
continue
|
||||
pos_to_cue[pos] = len(cue_list)
|
||||
cue_list.append(order)
|
||||
|
||||
# Densely renumber the patterns this song actually emits.
|
||||
used_ordered = []
|
||||
seen = set()
|
||||
for src_pat in cue_list:
|
||||
if src_pat not in seen:
|
||||
used_ordered.append(src_pat)
|
||||
seen.add(src_pat)
|
||||
pat_idx_remap = {src: i for i, src in enumerate(used_ordered)}
|
||||
P_used = len(used_ordered)
|
||||
|
||||
C = len(active_channels)
|
||||
if P_used * C > NUM_PATTERNS_MAX:
|
||||
sys.exit(
|
||||
f"error: {P} S3M patterns × {C} channels = {P*C} > {NUM_PATTERNS_MAX} Taud pattern limit.\n"
|
||||
f" Reduce the S3M to ≤ {NUM_PATTERNS_MAX // max(C,1)} patterns, or mute "
|
||||
f"channels to bring active count below {NUM_PATTERNS_MAX // max(P,1) + 1}."
|
||||
f"error: [{song_label}] {P_used} patterns × {C} channels = "
|
||||
f"{P_used*C} > {NUM_PATTERNS_MAX} Taud pattern limit."
|
||||
)
|
||||
|
||||
vprint(f" channels: {C}, s3m patterns: {P}, taud patterns: {P*C}")
|
||||
# Bxx remap: target source-position → cue-index. Cross-subsong jumps
|
||||
# clamp to cue 0 (loop the subsong rather than jump out of bounds). Walk
|
||||
# only the patterns this song actually emits.
|
||||
crossings = 0
|
||||
for src_pat in used_ordered:
|
||||
if src_pat >= len(pats): continue
|
||||
grid = pats[src_pat]
|
||||
for ch in range(min(32, len(grid))):
|
||||
for row in grid[ch]:
|
||||
if row.effect == EFF_B:
|
||||
if row.effect_arg in pos_to_cue:
|
||||
row.effect_arg = pos_to_cue[row.effect_arg] & 0xFF
|
||||
else:
|
||||
crossings += 1
|
||||
row.effect_arg = 0
|
||||
if crossings:
|
||||
vprint(f" warning: [{song_label}]: {crossings} Bxx target(s) cross "
|
||||
f"subsong boundary; clamped to cue 0")
|
||||
|
||||
# Resolve ST3 shared-memory recalls (D/E/F/I/J/K/L/Q/R/S with $00 arg)
|
||||
# before any per-row encoding, so cohort-aware Taud effects see explicit
|
||||
# arguments. Mutates patterns in place.
|
||||
vprint(" resolving ST3 shared-memory recalls…")
|
||||
resolve_st3_recalls(patterns, h.order_list, 32)
|
||||
warn_st3_quirks(patterns, h.order_list, 32)
|
||||
|
||||
init_speed, _ = find_initial_bpm_speed(patterns, h.order_list,
|
||||
h.initial_speed, h.initial_tempo)
|
||||
relocate_late_note_delays(patterns, h.order_list, 32, init_speed)
|
||||
|
||||
# Build sample+instrument bin
|
||||
vprint(" building sample/instrument bin…")
|
||||
sampleinst_raw, _offsets = build_sample_inst_bin(instruments)
|
||||
assert len(sampleinst_raw) == SAMPLEINST_SIZE
|
||||
|
||||
# Compress
|
||||
compressed = gzip.compress(sampleinst_raw, compresslevel=9, mtime=0)
|
||||
comp_size = len(compressed)
|
||||
vprint(f" sample+inst bin: {SAMPLEINST_SIZE} → {comp_size} bytes (gzip)")
|
||||
|
||||
# Initial BPM / speed
|
||||
speed, tempo = find_initial_bpm_speed(patterns, h.order_list,
|
||||
h.initial_speed, h.initial_tempo)
|
||||
tempo = max(24, min(280, tempo))
|
||||
bpm_stored = (tempo - 24) & 0xFF
|
||||
vprint(f" initial speed={speed}, tempo(BPM)={tempo}")
|
||||
|
||||
# Song offset = header(32) + compressed + song_table(8)
|
||||
song_offset = TAUD_HEADER_SIZE + comp_size + TAUD_SONG_ENTRY
|
||||
num_taud_pats = P * C
|
||||
|
||||
# Header (32 bytes): magic(8)+ver(1)+numSongs(1)+compSize(4)+rsvd(4)+sig(14)
|
||||
sig = (SIGNATURE + b' ' * 14)[:14]
|
||||
header = (
|
||||
TAUD_MAGIC +
|
||||
bytes([TAUD_VERSION, 1]) +
|
||||
struct.pack('<I', comp_size) +
|
||||
b'\x00\x00\x00\x00' +
|
||||
sig
|
||||
)
|
||||
assert len(header) == TAUD_HEADER_SIZE
|
||||
|
||||
# Pattern bin: for each s3m pattern, for each active channel, 512 bytes
|
||||
vprint(" building pattern bin…")
|
||||
default_pans = [_default_channel_pan(h.channel_settings[ch]) for ch in active_channels]
|
||||
# 1-based inst index → default volume (0..63) for note-trigger vol injection.
|
||||
inst_vols = {
|
||||
i + 1: min(inst.volume, 0x3F)
|
||||
for i, inst in enumerate(instruments)
|
||||
if inst is not None and inst.itype == S3M_TYPE_PCM
|
||||
}
|
||||
# Pattern bin: emit only patterns this song uses (densely indexed).
|
||||
default_pans = [_default_channel_pan(h.channel_settings[ch])
|
||||
for ch in active_channels]
|
||||
pat_bin = bytearray()
|
||||
for pi in range(P):
|
||||
grid = patterns[pi]
|
||||
for src_pat in used_ordered:
|
||||
grid = pats[src_pat]
|
||||
for vi, ch in enumerate(active_channels):
|
||||
pat_bin += build_pattern(grid, ch, default_pans[vi], h.linear_slides,
|
||||
inst_vols, amiga_mode=not h.linear_slides)
|
||||
assert len(pat_bin) == num_taud_pats * PATTERN_BYTES
|
||||
pat_bin += build_pattern(grid, ch, default_pans[vi],
|
||||
h.linear_slides, inst_vols,
|
||||
amiga_mode=not h.linear_slides)
|
||||
|
||||
# Deduplicate identical patterns
|
||||
vprint(" deduplicating patterns…")
|
||||
orig_count = num_taud_pats
|
||||
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(bytes(pat_bin), orig_count)
|
||||
vprint(f" patterns: {orig_count} → {num_taud_pats} unique ({orig_count - num_taud_pats} deduplicated)")
|
||||
pat_bin = rescale_offset_effects(bytes(pat_bin), sample_ratio)
|
||||
orig_count = P_used * C
|
||||
pat_bin, pat_remap, num_taud_pats = deduplicate_patterns(pat_bin, orig_count)
|
||||
vprint(f" [{song_label}] patterns: {orig_count} → {num_taud_pats} unique "
|
||||
f"({orig_count - num_taud_pats} deduplicated)")
|
||||
|
||||
# Cue sheet (using remapped pattern indices)
|
||||
vprint(" building cue sheet…")
|
||||
cue_sheet = build_cue_sheet(h.order_list, P, C, pat_remap)
|
||||
assert len(cue_sheet) == NUM_CUES * CUE_SIZE
|
||||
# Cue sheet
|
||||
sheet = bytearray(NUM_CUES * CUE_SIZE)
|
||||
for c in range(NUM_CUES):
|
||||
sheet[c*CUE_SIZE:c*CUE_SIZE+CUE_SIZE] = encode_cue([], 0)
|
||||
|
||||
# Compress pattern bin and cue sheet (per Taud spec)
|
||||
pat_comp = gzip.compress(bytes(pat_bin), compresslevel=9, mtime=0)
|
||||
cue_comp = gzip.compress(bytes(cue_sheet), compresslevel=9, mtime=0)
|
||||
vprint(f" pattern bin: {len(pat_bin)} → {len(pat_comp)} bytes (gzip)")
|
||||
vprint(f" cue sheet: {len(cue_sheet)} → {len(cue_comp)} bytes (gzip)")
|
||||
last_active = -1
|
||||
for cue_idx, src_pat in enumerate(cue_list):
|
||||
if cue_idx >= NUM_CUES: break
|
||||
new_pat_idx = pat_idx_remap[src_pat]
|
||||
orig_pats = [new_pat_idx * C + v for v in range(C)]
|
||||
sheet[cue_idx*CUE_SIZE:(cue_idx+1)*CUE_SIZE] = encode_cue(
|
||||
[pat_remap[p] for p in orig_pats], 0)
|
||||
last_active = cue_idx
|
||||
|
||||
# Song table row (32 bytes; see encode_song_entry).
|
||||
# flags byte: bit 1 (f) = Amiga pitch-slide mode (mirrors the S3M linear_slides flag inverted)
|
||||
# bit 2 (m) set: FT2 fadeout-zero policy — S3M has no per-instrument fadeout field, so a
|
||||
# stored zero means "cut on key-off" (matching ST3's lineage from the FT2 family).
|
||||
flags_byte = (0x00 if h.linear_slides else 0x02) | 0x04
|
||||
song_table = encode_song_entry(
|
||||
song_offset=song_offset,
|
||||
if last_active >= 0:
|
||||
sheet[last_active * CUE_SIZE + 30] = 0x01
|
||||
else:
|
||||
sheet[30] = 0x01
|
||||
|
||||
pat_comp = compress_blob(bytes(pat_bin), f"[{song_label}] pattern bin")
|
||||
cue_comp = compress_blob(bytes(sheet), f"[{song_label}] cue sheet")
|
||||
|
||||
flags_byte = (0x00 if h.linear_slides else 0x01)
|
||||
entry_kwargs = dict(
|
||||
num_voices=C,
|
||||
num_patterns=num_taud_pats,
|
||||
bpm_stored=bpm_stored,
|
||||
@@ -823,9 +881,108 @@ def assemble_taud(h: S3MHeader, instruments: list, patterns: list) -> bytes:
|
||||
global_vol=0xFF,
|
||||
mixing_vol=180,
|
||||
)
|
||||
assert len(song_table) == TAUD_SONG_ENTRY
|
||||
return pat_comp, cue_comp, entry_kwargs
|
||||
|
||||
return header + compressed + song_table + pat_comp + cue_comp
|
||||
|
||||
def assemble_taud(h: S3MHeader, instruments: list, patterns: list,
|
||||
with_project_data: bool = True) -> bytes:
|
||||
# Determine active channels (bit7 clear = enabled)
|
||||
active_channels = [i for i, cs in enumerate(h.channel_settings)
|
||||
if i < 32 and not (cs & 0x80)][:NUM_VOICES]
|
||||
C = len(active_channels)
|
||||
P = len(patterns)
|
||||
vprint(f" channels: {C}, s3m patterns: {P}")
|
||||
|
||||
# Build sample+instrument bin (shared across subsongs)
|
||||
vprint(" building sample/instrument bin…")
|
||||
sampleinst_raw, _offsets, sample_ratio = build_sample_inst_bin(instruments)
|
||||
assert len(sampleinst_raw) == SAMPLEINST_SIZE
|
||||
compressed = compress_blob(sampleinst_raw, "sample+inst bin")
|
||||
comp_size = len(compressed)
|
||||
|
||||
# 1-based inst index → default volume (0..63) for note-trigger vol injection.
|
||||
inst_vols = {
|
||||
i + 1: min(inst.volume, 0x3F)
|
||||
for i, inst in enumerate(instruments)
|
||||
if inst is not None and inst.itype == S3M_TYPE_PCM
|
||||
}
|
||||
|
||||
# ── Detect subsongs ──────────────────────────────────────────────────────
|
||||
subsongs = detect_subsongs(h.order_list, _per_pattern_bxx_s3m(patterns),
|
||||
terminators=(S3M_ORDER_END,),
|
||||
skip_marker=S3M_ORDER_SKIP)
|
||||
if not subsongs:
|
||||
vprint(" warning: no traversable orders in source; emitting empty song")
|
||||
subsongs = [{'entry': 0, 'positions': []}]
|
||||
n_songs = len(subsongs)
|
||||
if n_songs == 1:
|
||||
vprint(f" detected 1 song ({len(subsongs[0]['positions'])} orders)")
|
||||
else:
|
||||
vprint(f" detected {n_songs} subsongs:")
|
||||
for i, ss in enumerate(subsongs):
|
||||
vprint(f" song {i}: entry@{ss['entry']}, {len(ss['positions'])} orders")
|
||||
|
||||
# ── Build per-song payloads ──────────────────────────────────────────────
|
||||
song_payloads = []
|
||||
for i, ss in enumerate(subsongs):
|
||||
label = f"song {i}" if n_songs > 1 else "song"
|
||||
song_payloads.append(_build_song_payload_s3m(
|
||||
h, patterns, ss['positions'], sample_ratio, inst_vols,
|
||||
active_channels, song_label=label))
|
||||
|
||||
# ── Layout offsets and song table ────────────────────────────────────────
|
||||
song_table_off = TAUD_HEADER_SIZE + comp_size
|
||||
first_song_off = song_table_off + TAUD_SONG_ENTRY * n_songs
|
||||
|
||||
song_table = bytearray()
|
||||
cur_off = first_song_off
|
||||
for pat_comp, cue_comp, entry_kwargs in song_payloads:
|
||||
entry = encode_song_entry(song_offset=cur_off, **entry_kwargs)
|
||||
assert len(entry) == TAUD_SONG_ENTRY
|
||||
song_table += entry
|
||||
cur_off += len(pat_comp) + len(cue_comp)
|
||||
|
||||
# ── Project Data (optional) ──────────────────────────────────────────────
|
||||
# S3M instruments and samples share the same slot space, but carry two
|
||||
# distinct name fields: the 28-byte title (inst.name → INam) and the
|
||||
# 12-byte DOS filename (inst.filename → SNam). e.g. WHEN.s3m instrument #1
|
||||
# is titled "(c) Purple Motion / 1994" with sample name 'HIT1'.
|
||||
proj_data = b''
|
||||
proj_off = 0
|
||||
if with_project_data:
|
||||
inst_names = [''] + [(inst.name if inst is not None else '')
|
||||
for inst in instruments[:255]]
|
||||
sample_names = [''] + [(inst.filename if inst is not None else '')
|
||||
for inst in instruments[:255]]
|
||||
proj_data = build_project_data(
|
||||
project_name=h.title,
|
||||
instrument_names=inst_names,
|
||||
sample_names=sample_names,
|
||||
)
|
||||
if proj_data:
|
||||
proj_off = cur_off
|
||||
vprint(f" project data: {len(proj_data)} bytes @ offset {proj_off}")
|
||||
|
||||
# ── Header ───────────────────────────────────────────────────────────────
|
||||
sig = (SIGNATURE + b' ' * 14)[:14]
|
||||
header = (
|
||||
TAUD_MAGIC +
|
||||
bytes([TAUD_VERSION, n_songs & 0xFF]) +
|
||||
struct.pack('<I', comp_size) +
|
||||
struct.pack('<I', proj_off) +
|
||||
sig
|
||||
)
|
||||
assert len(header) == TAUD_HEADER_SIZE
|
||||
|
||||
out = bytearray()
|
||||
out += header
|
||||
out += compressed
|
||||
out += song_table
|
||||
for pat_comp, cue_comp, _ in song_payloads:
|
||||
out += pat_comp
|
||||
out += cue_comp
|
||||
out += proj_data
|
||||
return bytes(out)
|
||||
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||
@@ -837,6 +994,9 @@ def main():
|
||||
ap.add_argument('output', help='Output .taud file')
|
||||
ap.add_argument('-v', '--verbose', action='store_true',
|
||||
help='Print conversion details to stderr')
|
||||
ap.add_argument('--no-project-data', action='store_true',
|
||||
help='Omit the optional Project Data section '
|
||||
'(song / instrument / sample names)')
|
||||
args = ap.parse_args()
|
||||
|
||||
set_verbose(args.verbose)
|
||||
@@ -852,7 +1012,8 @@ def main():
|
||||
instruments = parse_instruments(data, h)
|
||||
patterns = parse_patterns(data, h)
|
||||
|
||||
taud = assemble_taud(h, instruments, patterns)
|
||||
taud = assemble_taud(h, instruments, patterns,
|
||||
with_project_data=not args.no_project_data)
|
||||
|
||||
with open(args.output, 'wb') as f:
|
||||
f.write(taud)
|
||||
|
||||
555
taud_common.py
555
taud_common.py
@@ -7,9 +7,16 @@ pattern deduper, sample normaliser) that all three converters used to
|
||||
duplicate verbatim.
|
||||
"""
|
||||
|
||||
import gzip as _gzip
|
||||
import struct
|
||||
import sys
|
||||
|
||||
try:
|
||||
import zstandard as _zstd
|
||||
_ZSTD_CCTX = _zstd.ZstdCompressor(level=22)
|
||||
except ImportError:
|
||||
_ZSTD_CCTX = None
|
||||
|
||||
|
||||
# ── Verbose logging (shared across converters via set_verbose) ───────────────
|
||||
|
||||
@@ -24,15 +31,57 @@ def vprint(*a, **kw) -> None:
|
||||
print(*a, **kw, file=sys.stderr)
|
||||
|
||||
|
||||
# ── Compression (gzip vs zstd; whichever is smaller) ─────────────────────────
|
||||
#
|
||||
# The Taud loader sniffs the 4-byte magic of every compressed slot and routes
|
||||
# to GZIPInputStream or ZstdInputStream accordingly (CompressorDelegate.kt:148-149),
|
||||
# so each blob can independently pick whichever codec compresses it smaller.
|
||||
|
||||
def best_compress(payload: bytes) -> tuple:
|
||||
"""Return (compressed_bytes, method) for the smaller of gzip/zstd output.
|
||||
|
||||
Method is "gzip" or "zstd". Falls back to gzip when the `zstandard`
|
||||
package is not installed.
|
||||
"""
|
||||
gz = _gzip.compress(payload, compresslevel=9, mtime=0)
|
||||
if _ZSTD_CCTX is None:
|
||||
return gz, "gzip"
|
||||
zs = _ZSTD_CCTX.compress(payload)
|
||||
if len(zs) < len(gz):
|
||||
return zs, "zstd"
|
||||
return gz, "gzip"
|
||||
|
||||
|
||||
def compress_blob(payload: bytes, label: str) -> bytes:
|
||||
"""Compress `payload` with whichever of gzip/zstd is smaller; vprint stats; return bytes.
|
||||
|
||||
`label` is the human-readable name in the verbose log line, e.g. "sample+inst bin".
|
||||
"""
|
||||
out, method = best_compress(payload)
|
||||
vprint(f" {label}: {len(payload)} → {len(out)} bytes ({method})")
|
||||
return out
|
||||
|
||||
|
||||
# ── Taud container constants ─────────────────────────────────────────────────
|
||||
|
||||
TAUD_MAGIC = bytes([0x1F,0x54,0x53,0x56,0x4D,0x61,0x75,0x64])
|
||||
# Bumped 2026-05-07: envelope offset minifloat rebiased (smallest step 1/256 s,
|
||||
# max 15.75 s; previously 1/32 s, max 126 s). v1 .taud envelopes will play with
|
||||
# the wrong tempo on a v2 engine — re-convert from source.
|
||||
TAUD_VERSION = 1
|
||||
TAUD_HEADER_SIZE = 32 # magic(8)+ver(1)+numSongs(1)+compSize(4)+projOff(4)+sig(14)
|
||||
TAUD_SONG_ENTRY = 32 # full spec entry (see encode_song_entry)
|
||||
SAMPLEBIN_SIZE = 737280
|
||||
INSTBIN_SIZE = 49152 # 256 instruments × 192 bytes
|
||||
SAMPLEINST_SIZE = SAMPLEBIN_SIZE + INSTBIN_SIZE
|
||||
INST_RECORD_SIZE = 256 # widened 2026-05-06 (was 192). 256 inst × 256 = 64K.
|
||||
# Sample+instrument image (terranmon.txt:1985-1997, 2533-2564 — updated 2026-05-08).
|
||||
# Sample pool is now 8 MB, banked through MMIO 46 in 16 × 512 K windows.
|
||||
# Converters write the pool bank-major (bank 0's 512 K first, then bank 1's, ...);
|
||||
# the runtime decompresses the whole blob straight into native peripheral storage,
|
||||
# so converters just lay out an 8 MB linear array as if banking didn't exist.
|
||||
SAMPLE_BANK_SIZE = 524288 # 512 K per bank
|
||||
SAMPLE_BANK_COUNT = 16 # 16 banks × 512 K = 8 MB
|
||||
SAMPLEBIN_SIZE = SAMPLE_BANK_SIZE * SAMPLE_BANK_COUNT # 8 MB
|
||||
INSTBIN_SIZE = INST_RECORD_SIZE * 256 # 65536 = 64K
|
||||
SAMPLEINST_SIZE = SAMPLEBIN_SIZE + INSTBIN_SIZE # 8454144 = 8256 kB
|
||||
PATTERN_ROWS = 64
|
||||
PATTERN_BYTES = PATTERN_ROWS * 8 # 512
|
||||
NUM_PATTERNS_MAX = 4095
|
||||
@@ -40,12 +89,30 @@ NUM_CUES = 1024
|
||||
CUE_SIZE = 32
|
||||
NUM_VOICES = 20
|
||||
|
||||
# Per-sample length cap. Taud instrument records carry the sample length as
|
||||
# a u16 (terranmon.txt:2001+ — bytes 4..5), so any single sample must fit in
|
||||
# 65535 bytes. Converters resample over-long samples individually after the
|
||||
# global pool-overflow pass and rescale the affected channel's TOP_O args.
|
||||
SAMPLE_LEN_LIMIT = 65535
|
||||
|
||||
# Note word sentinels
|
||||
NOTE_NOP = 0xFFFF
|
||||
NOTE_KEYOFF = 0x0000
|
||||
NOTE_CUT = 0xFFFE
|
||||
NOTE_NOP = 0x0000
|
||||
NOTE_KEYOFF = 0x0001
|
||||
NOTE_CUT = 0x0002
|
||||
TAUD_C4 = 0x5000 # The audio engine's Middle C
|
||||
|
||||
# Cue sheet instruction byte (cue offset 30; offset 31 = arg byte for 2-byte forms).
|
||||
# Per terranmon.txt §"Cue Sheet":
|
||||
# 00000010 00xxxxxx (LEN) pattern length: rows = (xxxxxx) + 1, range 1..64
|
||||
# 00000001 (HALT) end of song
|
||||
# 00000000 (NOP) default 64-row cue
|
||||
# 1000xxxx yyyyyyyy (BAK) go back 12-bit arg
|
||||
# 1001xxxx yyyyyyyy (FWD) skip forward 12-bit arg
|
||||
# 1111xxxx yyyyyyyy (JMP) go to absolute pattern
|
||||
CUE_INST_NOP = 0x00
|
||||
CUE_INST_HALT = 0x01
|
||||
CUE_INST_LEN = 0x02
|
||||
|
||||
# Taud effect opcodes (base-36: 0..9 → 0x00..0x09, A..Z → 0x0A..0x23)
|
||||
TOP_NONE = 0x00
|
||||
TOP_A = 0x0A
|
||||
@@ -60,7 +127,10 @@ TOP_I = 0x12
|
||||
TOP_J = 0x13
|
||||
TOP_K = 0x14
|
||||
TOP_L = 0x15
|
||||
TOP_M = 0x16
|
||||
TOP_N = 0x17
|
||||
TOP_O = 0x18
|
||||
TOP_P = 0x19
|
||||
TOP_Q = 0x1A
|
||||
TOP_R = 0x1B
|
||||
TOP_S = 0x1C
|
||||
@@ -90,6 +160,69 @@ EFF_U = 21; EFF_V = 22; EFF_W = 23; EFF_X = 24; EFF_Y = 25
|
||||
EFF_Z = 26
|
||||
|
||||
|
||||
# ── Envelope offset minifloat ────────────────────────────────────────────────
|
||||
#
|
||||
# Mirror of tsvm_core/.../ThreeFiveMinifloat.kt — used by every *2taud
|
||||
# converter that emits envelope nodes. 3.5 unsigned minifloat (3-bit exponent
|
||||
# + 5-bit mantissa) rebiased so the smallest non-zero step is 1/256 s ≈ 3.91
|
||||
# ms and the maximum is 15.75 s. The previous bias (1/32-step, max 126 s)
|
||||
# under-resolved single-tick deltas at typical tracker BPMs. Every value here
|
||||
# is the original LUT divided by 8.
|
||||
|
||||
MINUFLOAT_LUT = (
|
||||
0.0, 0.00390625, 0.0078125, 0.01171875, 0.015625, 0.01953125, 0.0234375, 0.02734375,
|
||||
0.03125, 0.03515625, 0.0390625, 0.04296875, 0.046875, 0.05078125, 0.0546875, 0.05859375,
|
||||
0.0625, 0.06640625, 0.0703125, 0.07421875, 0.078125, 0.08203125, 0.0859375, 0.08984375,
|
||||
0.09375, 0.09765625, 0.1015625, 0.10546875, 0.109375, 0.11328125, 0.1171875, 0.12109375,
|
||||
0.125, 0.12890625, 0.1328125, 0.13671875, 0.140625, 0.14453125, 0.1484375, 0.15234375,
|
||||
0.15625, 0.16015625, 0.1640625, 0.16796875, 0.171875, 0.17578125, 0.1796875, 0.18359375,
|
||||
0.1875, 0.19140625, 0.1953125, 0.19921875, 0.203125, 0.20703125, 0.2109375, 0.21484375,
|
||||
0.21875, 0.22265625, 0.2265625, 0.23046875, 0.234375, 0.23828125, 0.2421875, 0.24609375,
|
||||
0.25, 0.2578125, 0.265625, 0.2734375, 0.28125, 0.2890625, 0.296875, 0.3046875,
|
||||
0.3125, 0.3203125, 0.328125, 0.3359375, 0.34375, 0.3515625, 0.359375, 0.3671875,
|
||||
0.375, 0.3828125, 0.390625, 0.3984375, 0.40625, 0.4140625, 0.421875, 0.4296875,
|
||||
0.4375, 0.4453125, 0.453125, 0.4609375, 0.46875, 0.4765625, 0.484375, 0.4921875,
|
||||
0.5, 0.515625, 0.53125, 0.546875, 0.5625, 0.578125, 0.59375, 0.609375,
|
||||
0.625, 0.640625, 0.65625, 0.671875, 0.6875, 0.703125, 0.71875, 0.734375,
|
||||
0.75, 0.765625, 0.78125, 0.796875, 0.8125, 0.828125, 0.84375, 0.859375,
|
||||
0.875, 0.890625, 0.90625, 0.921875, 0.9375, 0.953125, 0.96875, 0.984375,
|
||||
1.0, 1.03125, 1.0625, 1.09375, 1.125, 1.15625, 1.1875, 1.21875,
|
||||
1.25, 1.28125, 1.3125, 1.34375, 1.375, 1.40625, 1.4375, 1.46875,
|
||||
1.5, 1.53125, 1.5625, 1.59375, 1.625, 1.65625, 1.6875, 1.71875,
|
||||
1.75, 1.78125, 1.8125, 1.84375, 1.875, 1.90625, 1.9375, 1.96875,
|
||||
2.0, 2.0625, 2.125, 2.1875, 2.25, 2.3125, 2.375, 2.4375,
|
||||
2.5, 2.5625, 2.625, 2.6875, 2.75, 2.8125, 2.875, 2.9375,
|
||||
3.0, 3.0625, 3.125, 3.1875, 3.25, 3.3125, 3.375, 3.4375,
|
||||
3.5, 3.5625, 3.625, 3.6875, 3.75, 3.8125, 3.875, 3.9375,
|
||||
4.0, 4.125, 4.25, 4.375, 4.5, 4.625, 4.75, 4.875,
|
||||
5.0, 5.125, 5.25, 5.375, 5.5, 5.625, 5.75, 5.875,
|
||||
6.0, 6.125, 6.25, 6.375, 6.5, 6.625, 6.75, 6.875,
|
||||
7.0, 7.125, 7.25, 7.375, 7.5, 7.625, 7.75, 7.875,
|
||||
8.0, 8.25, 8.5, 8.75, 9.0, 9.25, 9.5, 9.75,
|
||||
10.0, 10.25, 10.5, 10.75, 11.0, 11.25, 11.5, 11.75,
|
||||
12.0, 12.25, 12.5, 12.75, 13.0, 13.25, 13.5, 13.75,
|
||||
14.0, 14.25, 14.5, 14.75, 15.0, 15.25, 15.5, 15.75,
|
||||
)
|
||||
|
||||
|
||||
def nearest_minifloat(sec: float) -> int:
|
||||
"""Return the ThreeFiveMiniUfloat index (0..255) for the LUT entry nearest to `sec`."""
|
||||
if sec <= 0.0:
|
||||
return 0
|
||||
if sec >= MINUFLOAT_LUT[-1]:
|
||||
return 255
|
||||
lo, hi = 0, len(MINUFLOAT_LUT) - 1
|
||||
while lo < hi:
|
||||
mid = (lo + hi) // 2
|
||||
if MINUFLOAT_LUT[mid] < sec:
|
||||
lo = mid + 1
|
||||
else:
|
||||
hi = mid
|
||||
if lo > 0 and abs(MINUFLOAT_LUT[lo - 1] - sec) < abs(MINUFLOAT_LUT[lo] - sec):
|
||||
return lo - 1
|
||||
return lo
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def d_arg_to_col(arg: int):
|
||||
@@ -131,8 +264,72 @@ def resample_linear(data: bytes, ratio: float) -> bytes:
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def encode_cue(patterns12: list, instruction: int) -> bytearray:
|
||||
"""Encode a 32-byte cue entry for up to 20 voices with 12-bit pattern numbers."""
|
||||
def rescale_offset_effects(pat_bin: bytes, ratio: float) -> bytes:
|
||||
"""Scale TOP_O sample-offset args in raw pattern bytes by `ratio`.
|
||||
|
||||
Each row is 8 bytes; byte 5 is the effect opcode, bytes 6-7 are the
|
||||
little-endian 16-bit arg (= byte offset into the sample). When the
|
||||
sample bin overflows and every sample is downsampled globally, the
|
||||
offset commands must shrink the same amount or O-jumps land past
|
||||
the new end of sample.
|
||||
"""
|
||||
if ratio == 1.0 or not pat_bin:
|
||||
return pat_bin
|
||||
out = bytearray(pat_bin)
|
||||
for i in range(0, len(out) - 7, 8):
|
||||
if out[i + 5] == TOP_O:
|
||||
arg = out[i + 6] | (out[i + 7] << 8)
|
||||
arg = max(0, min(0xFFFF, int(arg * ratio + 0.5)))
|
||||
out[i + 6] = arg & 0xFF
|
||||
out[i + 7] = (arg >> 8) & 0xFF
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def rescale_offset_effects_per_slot(pat_bin: bytes,
|
||||
num_cues: int,
|
||||
num_channels: int,
|
||||
slot_ratios: dict) -> bytes:
|
||||
"""Scale TOP_O args using a per-slot ratio map.
|
||||
|
||||
`pat_bin` is laid out as `num_cues × num_channels` consecutive
|
||||
PATTERN_BYTES (=512) blocks, channel-minor within each cue. For each
|
||||
channel, walk the rows in cue order and track the most recently
|
||||
written slot byte (row offset 2). When a TOP_O effect appears, scale
|
||||
its arg by `slot_ratios[active_slot]`, falling back to ratio 1.0 if
|
||||
the slot is unknown (e.g. row hits an O before any inst byte has
|
||||
selected a sample for the channel).
|
||||
"""
|
||||
if not pat_bin or not slot_ratios:
|
||||
return pat_bin
|
||||
if all(r == 1.0 for r in slot_ratios.values()):
|
||||
return pat_bin
|
||||
out = bytearray(pat_bin)
|
||||
active = [0] * num_channels
|
||||
for cue in range(num_cues):
|
||||
for ch in range(num_channels):
|
||||
block = (cue * num_channels + ch) * PATTERN_BYTES
|
||||
for row in range(PATTERN_ROWS):
|
||||
rb = block + row * 8
|
||||
inst = out[rb + 2]
|
||||
if inst != 0:
|
||||
active[ch] = inst
|
||||
if out[rb + 5] == TOP_O:
|
||||
ratio = slot_ratios.get(active[ch], 1.0)
|
||||
if ratio != 1.0:
|
||||
arg = out[rb + 6] | (out[rb + 7] << 8)
|
||||
arg = max(0, min(0xFFFF, int(arg * ratio + 0.5)))
|
||||
out[rb + 6] = arg & 0xFF
|
||||
out[rb + 7] = (arg >> 8) & 0xFF
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def encode_cue(patterns12: list, instruction) -> bytearray:
|
||||
"""Encode a 32-byte cue entry for up to 20 voices with 12-bit pattern numbers.
|
||||
|
||||
`instruction` is either an int (legacy single-byte value placed at byte 30,
|
||||
byte 31 = 0) or a 2-tuple `(byte30, byte31)` for two-byte forms such as
|
||||
LEN (CUE_INST_LEN with row count - 1).
|
||||
"""
|
||||
pats = list(patterns12) + [0xFFF] * NUM_VOICES
|
||||
pats = pats[:NUM_VOICES]
|
||||
entry = bytearray(CUE_SIZE)
|
||||
@@ -141,10 +338,25 @@ def encode_cue(patterns12: list, instruction: int) -> bytearray:
|
||||
entry[i] = ((v0 & 0xF) << 4) | (v1 & 0xF) # low nybbles
|
||||
entry[10 + i] = (((v0 >> 4) & 0xF) << 4) | ((v1 >> 4) & 0xF) # mid nybbles
|
||||
entry[20 + i] = (((v0 >> 8) & 0xF) << 4) | ((v1 >> 8) & 0xF) # high nybbles
|
||||
entry[30] = instruction & 0xFF
|
||||
if isinstance(instruction, tuple):
|
||||
b30, b31 = instruction
|
||||
entry[30] = b30 & 0xFF
|
||||
entry[31] = b31 & 0xFF
|
||||
else:
|
||||
entry[30] = instruction & 0xFF
|
||||
return entry
|
||||
|
||||
|
||||
def cue_instruction_len(rows: int) -> tuple:
|
||||
"""Build the 2-byte LEN cue instruction for `rows` (1..64).
|
||||
|
||||
Returns (byte30, byte31) where byte30 = 0x02 and byte31 = (rows - 1) & 0x3F.
|
||||
"""
|
||||
if not 1 <= rows <= 64:
|
||||
raise ValueError(f"LEN row count must be 1..64, got {rows}")
|
||||
return (CUE_INST_LEN, (rows - 1) & 0x3F)
|
||||
|
||||
|
||||
def deduplicate_patterns(pat_bin: bytes, num_pats: int) -> tuple:
|
||||
"""Consolidate identical 512-byte Taud patterns into a single copy.
|
||||
|
||||
@@ -199,33 +411,330 @@ def encode_song_entry(song_offset: int, num_voices: int, num_patterns: int,
|
||||
return entry
|
||||
|
||||
|
||||
# ── Subsong detection (multi-song .taud emission) ────────────────────────────
|
||||
#
|
||||
# Modules and trackers don't natively carry a subsong table; subsongs emerge
|
||||
# from the order-list flow graph. OpenMPT-style: take the lowest unvisited
|
||||
# non-terminator order as the next subsong entry, do forward reachability via
|
||||
# fall-through (oi→oi+1) plus pattern-Bxx targets, mark all reached orders
|
||||
# visited, repeat until no entries remain.
|
||||
#
|
||||
# Fall-through is treated as dead when the pattern at oi has a Bxx on its
|
||||
# absolute last row — the convention every tracker uses for "song ends here,
|
||||
# loop back" — which lets non-looping subsongs separated by Bxx-terminated
|
||||
# predecessors be detected even without an explicit 0xFF marker.
|
||||
#
|
||||
# WHEN.s3m → 4 subsongs (0xFF separators); Insaniq2.it → 8 subsongs (Bxx-row-63
|
||||
# terminators, no 0xFF separators). Single-song files collapse to 1 subsong.
|
||||
|
||||
def detect_subsongs(orders, pattern_bxx_fn, *,
|
||||
terminators=(0xFF,), skip_marker=0xFE):
|
||||
"""Detect subsongs by repeated forward reachability.
|
||||
|
||||
Args:
|
||||
orders: list of raw order bytes from the source file. Each element is
|
||||
either a pattern index (0..n-1), a skip value (transparently
|
||||
skipped), or a terminator value (ends a path).
|
||||
pattern_bxx_fn: callable(pattern_idx) → (set_of_bxx_target_order_indices,
|
||||
kills_fallthrough). `kills_fallthrough` is True when the pattern's
|
||||
last row carries a Bxx (unconditional terminator); when False,
|
||||
fall-through to oi+1 is kept as a graph edge.
|
||||
terminators: int, or iterable of ints. Order values that end a path
|
||||
(default 0xFF). Pass an empty iterable for formats without a
|
||||
terminator marker (XM).
|
||||
skip_marker: int, or iterable of ints. Order values that are
|
||||
transparently passed during traversal (default 0xFE). XM passes
|
||||
`range(pattern_count, 256)` to skip out-of-range pattern refs.
|
||||
|
||||
Returns:
|
||||
List of subsongs in entry-order. Each subsong is a dict:
|
||||
'entry': original order-list position of the entry (int)
|
||||
'positions': list of original order-list positions belonging to this
|
||||
subsong, in cue-sheet order (entry first, then ascending index
|
||||
wrap-around). Each position's pattern index = orders[pos].
|
||||
For a single-song file the result has one element whose 'positions'
|
||||
covers the whole order list (minus terminators/skips). For files where
|
||||
every order is a terminator/skip, the result is empty.
|
||||
"""
|
||||
n = len(orders)
|
||||
term = {terminators} if isinstance(terminators, int) else set(terminators)
|
||||
skips = ({skip_marker} if isinstance(skip_marker, int)
|
||||
else set(skip_marker))
|
||||
|
||||
def _is_traversable(pos: int) -> bool:
|
||||
if pos < 0 or pos >= n:
|
||||
return False
|
||||
v = orders[pos]
|
||||
return v not in term and v not in skips
|
||||
|
||||
visited = set()
|
||||
songs = []
|
||||
|
||||
while True:
|
||||
# Lowest unvisited traversable position = next subsong entry.
|
||||
entry = next((i for i in range(n)
|
||||
if i not in visited and _is_traversable(i)), None)
|
||||
if entry is None:
|
||||
break
|
||||
|
||||
# Reachability claims orders for this subsong, stopping at orders
|
||||
# already owned by a previous subsong.
|
||||
owned = set()
|
||||
stack = [entry]
|
||||
while stack:
|
||||
oi = stack.pop()
|
||||
if oi in owned or oi in visited:
|
||||
continue
|
||||
if oi < 0 or oi >= n:
|
||||
continue
|
||||
v = orders[oi]
|
||||
if v in term:
|
||||
continue
|
||||
if v in skips:
|
||||
if oi + 1 < n:
|
||||
stack.append(oi + 1)
|
||||
continue
|
||||
owned.add(oi)
|
||||
tgts, kills = pattern_bxx_fn(v)
|
||||
for t in tgts:
|
||||
if 0 <= t < n:
|
||||
stack.append(t)
|
||||
if not kills and oi + 1 < n:
|
||||
stack.append(oi + 1)
|
||||
|
||||
if not owned:
|
||||
# Avoid infinite loop on a degenerate entry (shouldn't happen
|
||||
# since _is_traversable already filtered terminators / skips).
|
||||
visited.add(entry)
|
||||
continue
|
||||
visited |= owned
|
||||
|
||||
# Cue-sheet order: ascending index, rotated so entry comes first.
|
||||
# The natural order-list traversal is sequential, so increasing index
|
||||
# matches the play sequence when fall-through is alive; rotation
|
||||
# ensures cue 0 is the entry order.
|
||||
sorted_owned = sorted(owned)
|
||||
rot = sorted_owned.index(entry)
|
||||
positions = sorted_owned[rot:] + sorted_owned[:rot]
|
||||
|
||||
songs.append({'entry': entry, 'positions': positions})
|
||||
|
||||
return songs
|
||||
|
||||
|
||||
# ── Project Data section (terranmon.txt:2601+) ───────────────────────────────
|
||||
|
||||
PROJECT_DATA_MAGIC = bytes([0x1E, 0x54, 0x61, 0x75, 0x64, 0x50, 0x72, 0x4A]) # \x1ETaudPrJ
|
||||
PROJECT_DATA_HEADER_SIZE = 16 # 8-byte magic + 8 reserved
|
||||
|
||||
|
||||
def _name_table_blob(names) -> bytes:
|
||||
"""Encode a list of names (slot-indexed; slot 0 is left empty in source) as
|
||||
0x1E-separated UTF-8 bytes. Trailing empty slots are trimmed to save space.
|
||||
Returns b'' when every name is empty.
|
||||
"""
|
||||
if not names:
|
||||
return b''
|
||||
end = len(names)
|
||||
while end > 0 and not names[end - 1]:
|
||||
end -= 1
|
||||
if end == 0:
|
||||
return b''
|
||||
return b'\x1E'.join((n or '').encode('utf-8', 'replace') for n in names[:end])
|
||||
|
||||
|
||||
# ── Ixmp encoder (terranmon.txt §Project Data → Ixmp) ───────────────────────
|
||||
|
||||
# Per-patch byte layout. Field offsets must match AudioJSR223Delegate.uploadInstrumentPatches
|
||||
# (Kotlin parser) and terranmon.txt "Ixmp. Instrument extra samples".
|
||||
IXMP_PATCH_SIZE = 31
|
||||
IXMP_PAN_NO_OVERRIDE = 0xFF
|
||||
IXMP_DNV_NO_OVERRIDE = 0
|
||||
IXMP_VIBWAVE_NO_OVERRIDE = 0xFF
|
||||
|
||||
|
||||
def encode_ixmp_patch(p: dict) -> bytes:
|
||||
"""Encode a single patch dict into 31 bytes.
|
||||
|
||||
Expected keys (numeric values; defaults are applied for missing optional fields):
|
||||
pitch_start, pitch_end : Taud 4096-TET noteVal (Uint16)
|
||||
volume_start, volume_end : 0..63 (Uint8)
|
||||
sample_ptr : Uint32 (sample bin offset)
|
||||
sample_length : Uint16
|
||||
play_start, loop_start, loop_end : Uint16
|
||||
sampling_rate : Uint16 (same encoding as base inst byte 6-7)
|
||||
sample_detune : Int16, signed 4096-TET (default 0)
|
||||
loop_mode : Uint8 (default 0)
|
||||
default_pan : Uint8, 0xFF = no override (default 0xFF)
|
||||
default_note_volume : Uint8 IT-scaled (0 = no override, default 0)
|
||||
vibrato_speed/sweep/depth/rate: Uint8 (default 0)
|
||||
vibrato_waveform : Uint8 (0..7 or 0xFF for no override, default 0xFF)
|
||||
"""
|
||||
pitch_start = max(0, min(0xFFFF, int(p['pitch_start'])))
|
||||
pitch_end = max(0, min(0xFFFF, int(p['pitch_end'])))
|
||||
vol_start = max(0, min(63, int(p.get('volume_start', 0))))
|
||||
vol_end = max(0, min(63, int(p.get('volume_end', 63))))
|
||||
sample_ptr = int(p['sample_ptr']) & 0xFFFFFFFF
|
||||
sample_len = max(0, min(0xFFFF, int(p['sample_length'])))
|
||||
play_start = max(0, min(0xFFFF, int(p.get('play_start', 0))))
|
||||
loop_start = max(0, min(0xFFFF, int(p.get('loop_start', 0))))
|
||||
loop_end = max(0, min(0xFFFF, int(p.get('loop_end', 0))))
|
||||
rate = max(0, min(0xFFFF, int(p.get('sampling_rate', 0))))
|
||||
detune = max(-0x8000, min(0x7FFF, int(p.get('sample_detune', 0))))
|
||||
return struct.pack(
|
||||
'<BHHBBIHHHHHhBBBBBBBB',
|
||||
1, # patch version
|
||||
pitch_start, pitch_end,
|
||||
vol_start, vol_end,
|
||||
sample_ptr,
|
||||
sample_len,
|
||||
play_start, loop_start, loop_end,
|
||||
rate,
|
||||
detune,
|
||||
int(p.get('loop_mode', 0)) & 0x07,
|
||||
int(p.get('default_pan', IXMP_PAN_NO_OVERRIDE)) & 0xFF,
|
||||
int(p.get('default_note_volume', IXMP_DNV_NO_OVERRIDE)) & 0xFF,
|
||||
int(p.get('vibrato_speed', 0)) & 0xFF,
|
||||
int(p.get('vibrato_sweep', 0)) & 0xFF,
|
||||
int(p.get('vibrato_depth', 0)) & 0xFF,
|
||||
int(p.get('vibrato_rate', 0)) & 0xFF,
|
||||
int(p.get('vibrato_waveform', IXMP_VIBWAVE_NO_OVERRIDE)) & 0xFF,
|
||||
)
|
||||
|
||||
|
||||
def encode_ixmp_payload(patches_by_inst: dict) -> bytes:
|
||||
"""Encode a dict {instrument_id: [patch_dict, ...]} as one Ixmp section payload
|
||||
(the body that follows the FourCC + length header). Instruments are written in
|
||||
ascending id order. Overlapping pitch+volume rectangles within one instrument
|
||||
are INVALID per spec and the caller is responsible for keeping them disjoint."""
|
||||
if not patches_by_inst:
|
||||
return b''
|
||||
out = bytearray()
|
||||
for inst_id in sorted(patches_by_inst):
|
||||
patches = patches_by_inst[inst_id]
|
||||
if not patches:
|
||||
continue
|
||||
out.append(int(inst_id) & 0xFF)
|
||||
cnt = len(patches)
|
||||
out += bytes([cnt & 0xFF, (cnt >> 8) & 0xFF, (cnt >> 16) & 0xFF]) # Uint24 LE
|
||||
for patch in patches:
|
||||
out += encode_ixmp_patch(patch)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def build_project_data(*, project_name: str = '',
|
||||
author: str = '',
|
||||
copyright_str: str = '',
|
||||
sample_names=None,
|
||||
instrument_names=None,
|
||||
pattern_names=None,
|
||||
song_metadata=None,
|
||||
ixmp_patches=None) -> bytes:
|
||||
"""Build the optional PROJECT DATA section payload.
|
||||
|
||||
Returns the full block (8-byte magic + 8 reserved bytes + concatenated
|
||||
FourCC sections), or b'' when there's nothing to write so the caller can
|
||||
leave the header's projOff field at zero.
|
||||
|
||||
`sample_names` / `instrument_names` / `pattern_names` are slot-indexed
|
||||
lists (entry 0 is typically empty since slot 0 is reserved); they are
|
||||
encoded as 0x1E-separated UTF-8 strings inside SNam / INam / pNam blocks.
|
||||
|
||||
`song_metadata` is an optional list of dicts, one per song:
|
||||
{ 'index': int (0..255),
|
||||
'notation': int = 0,
|
||||
'beat_pri': int = 4,
|
||||
'beat_sec': int = 16,
|
||||
'name': str = '',
|
||||
'composer': str = '',
|
||||
'copyright': str = '' }
|
||||
"""
|
||||
sections = []
|
||||
|
||||
def add(fourcc: bytes, payload: bytes) -> None:
|
||||
if not payload:
|
||||
return
|
||||
sections.append(fourcc + struct.pack('<I', len(payload)) + payload)
|
||||
|
||||
if project_name:
|
||||
add(b'PNam', project_name.encode('utf-8', 'replace'))
|
||||
if author:
|
||||
add(b'PCom', author.encode('utf-8', 'replace'))
|
||||
if copyright_str:
|
||||
add(b'PCpr', copyright_str.encode('utf-8', 'replace'))
|
||||
|
||||
add(b'INam', _name_table_blob(instrument_names))
|
||||
add(b'SNam', _name_table_blob(sample_names))
|
||||
add(b'pNam', _name_table_blob(pattern_names))
|
||||
|
||||
if song_metadata:
|
||||
smet = bytearray()
|
||||
for entry in song_metadata:
|
||||
idx = entry.get('index', 0) & 0xFF
|
||||
notation = entry.get('notation', 0) & 0xFFFF
|
||||
beat_pri = entry.get('beat_pri', 4) & 0xFF
|
||||
beat_sec = entry.get('beat_sec', 16) & 0xFF
|
||||
name_b = entry.get('name', '').encode('utf-8', 'replace') + b'\x00'
|
||||
comp_b = entry.get('composer', '').encode('utf-8', 'replace') + b'\x00'
|
||||
copr_b = entry.get('copyright', '').encode('utf-8', 'replace') + b'\x00'
|
||||
payload = (struct.pack('<HBB', notation, beat_pri, beat_sec)
|
||||
+ name_b + comp_b + copr_b)
|
||||
smet.append(idx)
|
||||
smet += struct.pack('<I', len(payload))
|
||||
smet += payload
|
||||
add(b'sMet', bytes(smet))
|
||||
|
||||
if ixmp_patches:
|
||||
add(b'Ixmp', encode_ixmp_payload(ixmp_patches))
|
||||
|
||||
if not sections:
|
||||
return b''
|
||||
|
||||
return PROJECT_DATA_MAGIC + b'\x00' * 8 + b''.join(sections)
|
||||
|
||||
|
||||
# ── Sample normalisation ─────────────────────────────────────────────────────
|
||||
|
||||
def normalise_sample(raw: bytes, signed: bool, is_16bit: bool,
|
||||
is_stereo: bool, name: str) -> bytes:
|
||||
"""Return unsigned 8-bit mono sample bytes, downmixing/depthing as needed."""
|
||||
"""Return unsigned 8-bit mono sample bytes, downmixing/depthing as needed.
|
||||
|
||||
Stereo samples are stored as a split (non-interleaved) layout — the full
|
||||
left channel block followed by the full right channel block — matching the
|
||||
on-disk format used by IT, S3M, and XM (Schism's SF_SS).
|
||||
"""
|
||||
out = []
|
||||
stride = (2 if is_16bit else 1) * (2 if is_stereo else 1)
|
||||
i = 0
|
||||
while i + stride <= len(raw):
|
||||
bps = 2 if is_16bit else 1
|
||||
chans = 2 if is_stereo else 1
|
||||
n_frames = len(raw) // (bps * chans)
|
||||
chan_bytes = n_frames * bps
|
||||
|
||||
for i in range(n_frames):
|
||||
if is_16bit:
|
||||
if is_stereo:
|
||||
l16 = struct.unpack_from('<h', raw, i)[0]
|
||||
r16 = struct.unpack_from('<h', raw, i+2)[0]
|
||||
l16 = struct.unpack_from('<h', raw, i*2)[0]
|
||||
r16 = struct.unpack_from('<h', raw, chan_bytes + i*2)[0]
|
||||
s = (l16 + r16) >> 1
|
||||
else:
|
||||
s = struct.unpack_from('<h', raw, i)[0]
|
||||
s = struct.unpack_from('<h', raw, i*2)[0]
|
||||
v = (s >> 8) + 128
|
||||
else:
|
||||
if is_stereo:
|
||||
l8 = raw[i]; r8 = raw[i+1]
|
||||
raw_s = (l8 + r8) // 2
|
||||
l8 = raw[i]
|
||||
r8 = raw[chan_bytes + i]
|
||||
if signed:
|
||||
l_s = l8 - 256 if l8 >= 0x80 else l8
|
||||
r_s = r8 - 256 if r8 >= 0x80 else r8
|
||||
v = ((l_s + r_s) >> 1) + 128
|
||||
else:
|
||||
v = (l8 + r8) >> 1
|
||||
else:
|
||||
raw_s = raw[i]
|
||||
if signed:
|
||||
v = (raw_s ^ 0x80) & 0xFF
|
||||
else:
|
||||
v = raw_s
|
||||
if signed:
|
||||
v = (raw_s ^ 0x80) & 0xFF
|
||||
else:
|
||||
v = raw_s
|
||||
out.append(v & 0xFF)
|
||||
i += stride
|
||||
if is_16bit or is_stereo:
|
||||
vprint(f" info: '{name}' converted to unsigned 8-bit mono ({len(out)} samples)")
|
||||
return bytes(out)
|
||||
|
||||
613
terranmon.txt
613
terranmon.txt
@@ -49,7 +49,13 @@ MMIO
|
||||
0..31 RO: Raw Keyboard Buffer read. Won't shift the key buffer
|
||||
32..33 RO: Mouse X pos
|
||||
34..35 RO: Mouse Y pos
|
||||
36 RO: Mouse down? (1 for TRUE, 0 for FALSE)
|
||||
36 RO: Mouse down?
|
||||
bit 0: left
|
||||
bit 1: right
|
||||
bit 2: middle
|
||||
|
||||
bit 6: wheel up
|
||||
bit 7: wheel down
|
||||
37 RW: Read/Write single key input. Key buffer will be shifted. Manual writing is
|
||||
usually unnecessary as such action must be automatically managed via LibGDX
|
||||
input processing.
|
||||
@@ -1985,16 +1991,83 @@ Synchronisation between playheads are not guaranteed. Do not play music in multi
|
||||
|
||||
Memory Space
|
||||
|
||||
0..737279 RW: Sample bin (720k)
|
||||
737280..786431 RW: Instrument bin (256 instruments, 192 bytes each; instrument 0 does nothing; 48k)
|
||||
0..524287 RW: Sample bin window (512k)
|
||||
720896..786431 RW: Instrument bin (256 instruments, 256 bytes each; instrument 0 does nothing; 64k)
|
||||
786432..851967 RW: Play data 1 (currently exposed bank; 64k)
|
||||
851968..917503 RW: Play data 2 (currently exposed bank; 64k)
|
||||
917504..983039 RW: TAD Input Buffer (64k)
|
||||
983040..1048575 RW: TAD Decode Output (64k)
|
||||
|
||||
Sample bin: just raw sample data thrown in there. You need to keep track of starting point for each sample
|
||||
Sample bin: just raw sample data thrown in there. You need to keep track of starting point for each sample. Actual sample memory is 8 MB and are banked. Write to MMIO address 46 to switch banks.
|
||||
|
||||
Instrument bin: Registry for 256 instruments, formatted as:
|
||||
|
||||
The instrument record is 256 bytes wide. Envelopes are described by FOUR
|
||||
independent regions per envelope (vol / pan / pitch-filter):
|
||||
1. The 25 envelope nodes (offsets 21 / 71 / 121).
|
||||
2. The LOOP word (offsets 15 / 17 / 19) — defines an always-active
|
||||
wrap region. When enabled (b=1) and the envelope position reaches
|
||||
loop_end, it wraps back to loop_start. Active regardless of key
|
||||
state. This is the IT/FT2 envelope loop.
|
||||
3. The SUSTAIN word (offsets 189 / 191 / 193) — defines a wrap
|
||||
region that is ONLY active while the key is on. When the key
|
||||
goes off the sustain "releases" and the envelope position is
|
||||
free to walk past sus_end. Concretely:
|
||||
- FT2-style "sustain point": store sus_start == sus_end (single
|
||||
index). Engine wraps that index → itself, so the envelope
|
||||
holds at the point until key-off.
|
||||
- IT-style "sustain loop": store sus_start <= sus_end. Engine
|
||||
wraps sus_end → sus_start while key is on, so the envelope
|
||||
loops within the sustain range until key-off.
|
||||
4. (none — there is no separate "release loop"; once sustain releases
|
||||
the envelope walks forward and is captured by the LOOP region if
|
||||
the LOOP region exists and the position enters it.)
|
||||
|
||||
Priority during playback follows schismtracker player/sndmix.c:480-499:
|
||||
if SUSTAIN.b == 1 and !key_off : wrap (sus_start, sus_end)
|
||||
elif LOOP.b == 1 : wrap (loop_start, loop_end)
|
||||
else : hold at last node
|
||||
|
||||
This means SUSTAIN takes precedence over LOOP while the key is on; once
|
||||
the key is released, LOOP becomes the active wrap region. Setting both
|
||||
to b=0 disables envelope wrapping entirely (envelope plays once and holds
|
||||
at its last node).
|
||||
|
||||
The b flag is the SOLE enable bit for each region; the historical 't'
|
||||
(sustain breaks on key-off) and 'u' (sustain/loop enable) flags are NOT
|
||||
present in this encoding — sustain vs loop is now a structural
|
||||
distinction (different word at a different offset), not a flag bit.
|
||||
|
||||
Envelope PRESENCE — distinct from LOOP/SUSTAIN enable — is signalled by
|
||||
the `P` bit at LOOP-word bit 13 (the high byte's bit 5; offsets 16/18/20
|
||||
bit 5). Added 2026-05-06 to disambiguate two cases that the wrap-enable
|
||||
bits cannot tell apart on their own:
|
||||
P=0: the source had no envelope of this kind. Engine ignores the
|
||||
node array entirely and the mixer skips envelope-driven output
|
||||
for this voice (pan reads from channelPan only, cutoff/pitch
|
||||
reads from sample defaults only). The 25 node slots may still
|
||||
be left as default-fill garbage; nothing reads them.
|
||||
P=1: envelope is defined. Engine evaluates the nodes every tick.
|
||||
Wrap behaviour is independently controlled by LOOP.b and
|
||||
SUSTAIN.b — when both are 0 the envelope walks once forward
|
||||
and holds at its terminator (the IT idiom for envelope-driven
|
||||
decay tails / shaped attacks).
|
||||
The P bit was introduced to fix a gating ambiguity for pan and pitch/
|
||||
filter envelopes: the engine could not distinguish "no envelope at all"
|
||||
(treat as absent) from "envelope present but neither LOOP nor SUSTAIN
|
||||
wrap is enabled" (evaluate and apply, just don't wrap). Volume envelope
|
||||
evaluation has always been unconditional in the engine (a default
|
||||
single-point envelope at value 63 is harmlessly held at unity), so
|
||||
P_vol is currently informational only — converters should still set it
|
||||
when the source defines a volume envelope, for consistency and to
|
||||
support future per-voice gating.
|
||||
|
||||
P is the SOLE presence signal: converters MUST set P=1 whenever they
|
||||
emit envelope nodes, regardless of whether the source enables LOOP or
|
||||
SUSTAIN. Pre-2026-05-06 .taud files predate the P bit and will not have
|
||||
their pan / pf envelopes evaluated by the current engine — re-convert
|
||||
from source.
|
||||
|
||||
0 Uint32 Sample Pointer
|
||||
4 Uint16 Sample length
|
||||
6 Uint16 Sampling rate at C4 (note number 0x5000)
|
||||
@@ -2006,66 +2079,139 @@ Instrument bin: Registry for 256 instruments, formatted as:
|
||||
pp: loop mode. 0-no loop, 1-loop, 2-backandforth, 3-oneshot (ignores note length unless overridden by other notes)
|
||||
s: loop is sustain (key-off escapes the loop)
|
||||
- IT: look for sample's SusLoop flag
|
||||
15 Bit16 Volume envelope sustain/loops and flags
|
||||
* Sustain is implemented by enabling 't' flag. FastTracker has no 'Sus Loop' but only 'Sus Point'; use same value for start and end index
|
||||
0b 0ut sssss 0cb eeeee
|
||||
s: sustain/loop start index
|
||||
e: sustain/loop end index
|
||||
|
||||
b: use envelope
|
||||
c: envelope carry
|
||||
|
||||
t: the loop must sustain (key-off escapes the loop)
|
||||
u: set to enable the sustain/loop
|
||||
17 Bit16 Panning envelope sustain/loops and flags
|
||||
* Sustain is implemented by enabling 't' flag
|
||||
0b 0ut sssss pcb eeeee
|
||||
s: sustain/loop start index
|
||||
e: sustain/loop end index
|
||||
|
||||
b: use envelope
|
||||
c: envelope carry
|
||||
p: use default pan (see offset 177 "Default pan value" below)
|
||||
|
||||
t: the loop must sustain (key-off escapes the loop)
|
||||
u: set to enable the sustain/loop
|
||||
19 Bit16 Pitch/Filter envelope sustain/loops and flags
|
||||
* Sustain is implemented by enabling 't' flag
|
||||
0b 0ut sssss mcb eeeee
|
||||
s: sustain/loop start index
|
||||
e: sustain/loop end index
|
||||
|
||||
b: use envelope
|
||||
c: envelope carry
|
||||
m: mode (0: on pitch, 1: on filter)
|
||||
|
||||
t: the loop must sustain (key-off escapes the loop)
|
||||
u: set to enable the sustain/loop
|
||||
15 Bit16 Volume envelope LOOP word
|
||||
* Always-active wrap region for the volume envelope. See SUSTAIN word at offset 189 for the key-on-only wrap.
|
||||
* IMPORTANT: the `b` bit gates only the LOOP wrap behaviour. The volume
|
||||
envelope itself is always evaluated whenever the per-voice volume-envelope
|
||||
toggle is on (default true on note-on; switched by effect S $7x / S $8x).
|
||||
This matches IT/Schism (player/sndmix.c:470-502): CHN_VOLENV is independent
|
||||
of ENV_VOLLOOP / ENV_VOLSUSTAIN. An envelope with no LOOP and no SUSTAIN
|
||||
(both `b` bits = 0) walks once from start to its terminator and holds —
|
||||
which is the IT idiom for envelope-driven decay tails.
|
||||
* The cut rule: when the volume envelope walks past the last real node in
|
||||
fall-through (no active sustain or loop wrap) AND that node's value is 0,
|
||||
the engine deactivates the voice (player/sndmix.c:493-498). Without this,
|
||||
instruments with stored fadeout=0 + envelope ending at 0 would silently
|
||||
hold their voices forever.
|
||||
0b 00P_sssss_0cb_eeeee
|
||||
s (bits 12..8) : loop start index (0..24)
|
||||
e (bits 4..0) : loop end index (0..24)
|
||||
b (bit 5) : enable the LOOP wrap (0 = envelope walks once to its
|
||||
terminator and holds; non-zero loops between s and e)
|
||||
c (bit 6) : envelope carry (cross-trigger envelope position carry)
|
||||
P (bit 13) : envelope present in source (informational for vol —
|
||||
engine evaluates vol env unconditionally; converters
|
||||
should set P=1 when emitting nodes for consistency
|
||||
with pan/pf envelopes, see file-header preamble)
|
||||
(bits 7, 14..15 reserved — set to 0)
|
||||
17 Bit16 Panning envelope LOOP word
|
||||
* Always-active wrap region for the pan envelope.
|
||||
0b 00P_sssss_pcb_eeeee
|
||||
s (bits 12..8) : loop start index
|
||||
e (bits 4..0) : loop end index
|
||||
b (bit 5) : enable the LOOP
|
||||
c (bit 6) : envelope carry
|
||||
p (bit 7) : use default pan (see offset 177 "Default pan value" below).
|
||||
Independent of LOOP enable; the engine reads this bit
|
||||
from the LOOP word as the canonical home for envelope-
|
||||
level meta flags.
|
||||
P (bit 13) : envelope present in source. Gates whether the mixer
|
||||
applies envelope-driven pan at all. P=0 ⇒ mixer uses
|
||||
channelPan only and the node array is ignored. P=1 ⇒
|
||||
evaluate every tick, even when both LOOP.b and SUSTAIN.b
|
||||
are 0 (envelope walks once and holds — IT pan-env
|
||||
flag=0x01 idiom).
|
||||
(bits 14..15 reserved)
|
||||
19 Bit16 Pitch/Filter envelope LOOP word
|
||||
* Always-active wrap region for the pitch/filter envelope.
|
||||
0b 00P_sssss_mcb_eeeee
|
||||
s (bits 12..8) : loop start index
|
||||
e (bits 4..0) : loop end index
|
||||
b (bit 5) : enable the LOOP
|
||||
c (bit 6) : envelope carry
|
||||
m (bit 7) : mode — 0 = pitch envelope, 1 = filter envelope
|
||||
P (bit 13) : envelope present in source. Same semantics as the
|
||||
pan envelope's P bit: gates whether the mixer applies
|
||||
envelope-driven pitch / cutoff at all. P=0 ⇒ no
|
||||
envelope contribution (sample plays at its own pitch /
|
||||
default cutoff). P=1 ⇒ evaluate every tick regardless
|
||||
of LOOP.b / SUSTAIN.b.
|
||||
(bits 14..15 reserved)
|
||||
21 Bit16x25 Volume envelopes
|
||||
Byte 1: Volume (00..3F)
|
||||
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat). 0 = hold at this point indefinitely.
|
||||
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat, biased; range 0..15.75 s, smallest non-zero step 1/256 s ≈ 3.91 ms — chosen so single tracker ticks resolve at every supported BPM). 0 = hold at this point indefinitely.
|
||||
71 Bit16x25 Panning envelopes
|
||||
Byte 1: Pan (00..FF)
|
||||
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat). 0 = hold at this point indefinitely.
|
||||
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat, biased; range 0..15.75 s, smallest non-zero step 1/256 s ≈ 3.91 ms — chosen so single tracker ticks resolve at every supported BPM). 0 = hold at this point indefinitely.
|
||||
121 Bit16x25 Pitch/Filter envelopes
|
||||
Byte 1: Value (00..FF)
|
||||
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat). 0 = hold at this point indefinitely.
|
||||
Byte 2: Time until the next point, in seconds (3.5 Unsigned Minifloat, biased; range 0..15.75 s, smallest non-zero step 1/256 s ≈ 3.91 ms — chosen so single tracker ticks resolve at every supported BPM). 0 = hold at this point indefinitely.
|
||||
171 Uint8 Instrument Global Volume (0..255)
|
||||
* ImpulseTracker has range of 0..128; multiply by (255/128) then round to int
|
||||
- ImpulseTracker also has samplewise default volume (0..64) and samplewise global volume (0..64), and they must be taken into account because Taud has no samplewise config, following the ImpulseTracker spec
|
||||
* FastTracker2 has range of 0..64; multiply by (255/64) then round to int
|
||||
* Continuous multiplier applied on every output sample (matches IT's
|
||||
`chan->instrument_volume`, see Schism player/csndfile.c:1317 and
|
||||
player/sndmix.c:1171). Independent of the volume column / Mxx /
|
||||
Nxx — the volume column writes the per-note axis (noteVolume),
|
||||
Mxx/Nxx write the per-channel axis (channelVolume); IGV scales
|
||||
the final mix unconditionally and is orthogonal to both.
|
||||
* ImpulseTracker has separate `inst.gv` (0..128) and samplewise
|
||||
`sample.gv` (0..64). Since Taud has no samplewise record, fold
|
||||
the two factors into a single 0..255 value:
|
||||
taud_igv = round(inst.gv * sample.gv * 255 / (128 * 64))
|
||||
The samplewise `sample.vol` (0..64) is NOT folded here — it is the
|
||||
per-trigger default chan_volume in IT (replaceable by V column),
|
||||
and Taud carries it in byte 196 ("Default Note Volume"). Folding
|
||||
it here was the cause of the "low-number voleffs are too quiet"
|
||||
regression (TODO §2350, fixed 2026-05-09).
|
||||
* FastTracker2 has range of 0..64 with no instrumentwise multiplier
|
||||
beyond it; multiply by (255/64) and round. The XM samplewise
|
||||
volume goes into byte 196.
|
||||
172 Uint8 Volume Fadeout low bits
|
||||
173 Bit8 Fadeout and vibrato
|
||||
173 Bit8 Volume Fadeout high bits
|
||||
0b 0000 ffff
|
||||
f: Volume Fadeout high bits
|
||||
* Combined 12-bit fadeout value is the engine's per-tick decrement, in 1/65536 units
|
||||
(a unity-volume voice silenced over (65536 / fadeout) ticks after key-off).
|
||||
* Stored 0: behaviour depends on Global Behaviour bit 'm' (see Song Table) —
|
||||
IT mode (m=0) leaves the voice unfaded; FT2 mode (m=1) cuts on key-off.
|
||||
* Source-format mapping:
|
||||
- IT: stored fadeout (0..1024) MUST be doubled on import (taud = it × 2);
|
||||
Taud's per-tick scale matches FT2 natively, so IT values are scaled to match.
|
||||
- FT2: stored fadeout (0..0xFFF) is passed through unchanged.
|
||||
f: Volume Fadeout high bits (low nibble of byte 173; high nibble reserved, must be zero)
|
||||
* Combined 12-bit unsigned value (range 0..4095). The engine maintains
|
||||
a per-voice fadeoutVolume ∈ [0, 1] initialised to 1.0 on note-on, and
|
||||
while the voice is in key-off or NNA Note-Fade state applies once per
|
||||
song tick:
|
||||
fadeoutVolume -= storedFadeout / 1024.0
|
||||
clamp fadeoutVolume to [0, 1]
|
||||
if fadeoutVolume == 0: voice deactivates
|
||||
The voice's amplitude is multiplied by fadeoutVolume each tick.
|
||||
* Stored value semantics (no separate "use fadeout" flag — like IT and
|
||||
FT2 file formats, "no fade" and "instant cut" are both encoded as
|
||||
extreme values of this same field):
|
||||
- 0 : no fade. fadeoutVolume never moves; the voice plays
|
||||
at envelope-driven volume indefinitely. Termination
|
||||
must come from the volume envelope reaching a final
|
||||
0-valued node, the sample ending, or a note-cut.
|
||||
- 1..1023 : graduated fade. Completes in (1024 / storedFadeout)
|
||||
ticks. e.g. 1 → 1024 ticks; 32 → 32 ticks.
|
||||
- 1024 : exact 1-tick cut. fadeoutVolume goes 1.0 → 0.0 in
|
||||
one tick (the canonical "kill on key-off" value).
|
||||
- 1025..4095 : also a 1-tick cut (clamped at 0). The 4× headroom
|
||||
over 1024 lets converters carry out-of-spec source
|
||||
values without saturating prematurely.
|
||||
* Tick-rate worked example at default 50 Hz (BPM 125, speed 6):
|
||||
- storedFadeout = 1 → fade ≈ 20.5 s
|
||||
- storedFadeout = 32 → fade ≈ 640 ms
|
||||
- storedFadeout = 1024 → ~20 ms (one tick)
|
||||
* Source-format mapping (converters scale source units → Taud field):
|
||||
- IT: 16-bit field at IT instrument record offset 0x14, range
|
||||
0..1024 per ITTECH (some loaders accept up to 2048). Schism's
|
||||
per-tick decrement is stored / 1024 of unit volume — identical
|
||||
to Taud's unit. Pass-through with clamp:
|
||||
taud_fadeout = min(it_fadeout & 0xFFFF, 0x0FFF)
|
||||
- FT2/XM: 16-bit field. Spec range 0..0xFFF; MilkyTracker writes
|
||||
up to 32767 to encode the "cut" UI slider position
|
||||
(SectionInstruments.cpp:499-500). FT2's per-tick decrement is
|
||||
stored / 32768 of unit volume — to match Taud's stored / 1024
|
||||
rate, divide source by 32 (round-to-nearest):
|
||||
taud_fadeout = min((xm_fadeout + 16) // 32, 0x0FFF)
|
||||
XM stored 1..15 round to Taud 0 (originals were >11 min at 50 Hz
|
||||
— effectively "no fade" anyway). Stored 32 → Taud 1 (~20 s).
|
||||
Stored 32767 (Milky cut sentinel) → Taud 1024 (1-tick cut).
|
||||
- MOD/S3M/MON: no instrument-level fadeout in source; converters
|
||||
write 0 (notes retire on sample-end or pattern note-cut).
|
||||
174 Uint8 Volume swing (0..255 full range)
|
||||
175 Uint8 Vibrato speed
|
||||
* ImpulseTracker has samplewise vibrato speed (0..64), and they must be taken into account because Taud has no samplewise config
|
||||
@@ -2090,7 +2236,81 @@ Instrument bin: Registry for 256 instruments, formatted as:
|
||||
* FastTracker2 has range of 0..16; multiply by (255/16) then round to int
|
||||
188 Uint8 Vibrato Rate (0..255 full range)
|
||||
* ImpulseTracker sample config. The spec follows ImpulseTracker precisely
|
||||
189 Byte[3] Reserved
|
||||
189 Bit16 Volume envelope SUSTAIN word
|
||||
* Wrap region active ONLY while key is on. Released on key-off.
|
||||
* FT2 single-point sustain: store sus_start == sus_end (the engine
|
||||
wraps that index → itself, so the envelope holds there).
|
||||
* IT sustain loop: store sus_start <= sus_end (engine wraps the range
|
||||
while key is on; same shape as the LOOP word).
|
||||
0b 000_sssss_00b_eeeee
|
||||
s (bits 12..8) : sustain start index (0..24)
|
||||
e (bits 4..0) : sustain end index (0..24)
|
||||
b (bit 5) : enable the SUSTAIN (0 = no sustain wrap)
|
||||
(bits 6..7, 13..15 reserved — the 'c' carry bit lives in the LOOP word)
|
||||
191 Bit16 Panning envelope SUSTAIN word
|
||||
* Same encoding as offset 189, applied to the pan envelope.
|
||||
0b 000_sssss_00b_eeeee
|
||||
193 Bit16 Pitch/Filter envelope SUSTAIN word
|
||||
* Same encoding as offset 189, applied to the pitch/filter envelope.
|
||||
0b 000_sssss_00b_eeeee
|
||||
195 Bit8 Duplicate Check / Action (IT-only; FT2 leaves this 0)
|
||||
0b 0000 dcdt
|
||||
dt (bits 0..1) : Duplicate Check Type. 0=off, 1=note, 2=sample, 3=instrument.
|
||||
dc (bits 2..3) : Duplicate Check Action. 0=note cut, 1=note off, 2=note fade.
|
||||
* Relocated from offset 189 (which is now the volume sustain word) on 2026-05-06.
|
||||
* Semantics (matches IT/Schism player/effects.c:1664-1764 csf_check_nna):
|
||||
- Fires on every fresh foreground note trigger on a channel, BEFORE the
|
||||
NNA-spawn step that would ghost the existing voice. Does NOT fire on
|
||||
tone portamento, on note-off (0x0001), on note-cut (0x0002), or on
|
||||
empty cells.
|
||||
- The DCT/DCA values consulted belong to the EXISTING voice's instrument
|
||||
(i.e. the OLD note's instrument, not the incoming note's). Different
|
||||
instruments on the same channel can therefore have asymmetric duplicate
|
||||
behaviour — IT-correct.
|
||||
- Targets: the foreground voice on the same channel AND every background
|
||||
(NNA-ghost) voice spawned earlier from that channel. Each is checked
|
||||
independently against the new (instrument, note) pair.
|
||||
- DCT match conditions:
|
||||
off (0) : never matches; DCA never fires
|
||||
note (1) : same noteVal AND same instrumentId
|
||||
sample (2) : same instrumentId AND same canonical sample (matched
|
||||
by samplePtr + sampleLength)
|
||||
instrument (3) : same instrumentId
|
||||
- DCA actions on a matching voice:
|
||||
note cut (0) : fadeoutVolume := 0; voice deactivates this tick
|
||||
note off (1) : keyOff := true (sustain releases; volume envelope
|
||||
continues past the sustain point; if the instrument
|
||||
carries a non-zero fadeout, the fadeout decay starts
|
||||
per byte 172/173 semantics)
|
||||
note fade (2) : noteFading := true (begin fadeout immediately, no
|
||||
sustain release — sample/envelope loops continue)
|
||||
- Order with NNA: applyDuplicateCheck → maybeSpawnBackgroundForNNA →
|
||||
triggerNote. So when DCA flags the foreground voice, the NNA-ghost it
|
||||
spawns inherits that DCA-modified state (e.g. noteFading carries over).
|
||||
- The new note then triggers normally on the foreground channel.
|
||||
196 Uint8 Default Note Volume (0..255)
|
||||
* Per-trigger default for the per-note volume axis (`noteVolume` in
|
||||
the engine, analog of IT's `chan->volume`) when the row carries a
|
||||
fresh note + instrument byte but no explicit volume column (matches
|
||||
IT's `chan->volume = psmp->volume` on note-on, Schism
|
||||
player/effects.c:1302 and :1432). The 8-bit value rescales to
|
||||
Taud's 0..63 note-volume range:
|
||||
note_default = round(default_note_volume * 63 / 255)
|
||||
Any explicit V column SET on the trigger row OVERRIDES this — i.e.
|
||||
noteVolume = vol_value, exactly mirroring IT's "V column replaces
|
||||
chan->volume" rule. The per-channel axis (`channelVolume`, set by
|
||||
Mxx / Nxx) is independent and is NOT reset on re-trigger.
|
||||
* Source-format mapping:
|
||||
- IT: taud_dnv = round(sample.vol * 255 / 64) # 0..64 → 0..255
|
||||
- XM: taud_dnv = round(sample.volume * 255 / 64) # 0..64 → 0..255
|
||||
- S3M: taud_dnv = round(min(inst.volume, 64) * 255 / 64)
|
||||
- MOD: taud_dnv = round(min(sample.volume, 64) * 255 / 64)
|
||||
* .taud files written before 2026-05-09 stored sample.vol folded into
|
||||
byte 171 (IGV) and left this byte zero. Engines reading those older
|
||||
files SHOULD treat default_note_volume == 0 as "field not present"
|
||||
and fall back to row_default = 63 — preserving the pre-fix behaviour
|
||||
for legacy files where IGV already carries sample.vol.
|
||||
197..255 Reserved (59 bytes free for future per-instrument fields)
|
||||
|
||||
|
||||
|
||||
@@ -2112,22 +2332,115 @@ TODO:
|
||||
[x] 4THSYM.it: pitchbend is wrong, some notes keep playing (loudly!) even if new notes are emitted
|
||||
[x] `*2taud.py`: some notes are emitted with wrong volume-set command. Tested with GSLINGER.mod: on order 0x15 channel 1, mod2taud.py emits volume 8 -- also many of the effects are dropped. Suggested solution: currently all converters write default volume to the voleff when original modules (.mod/.s3m/.it) specify nothing; we should also write nothing and let the engine resolve the value just like other trackers do (also we now have "Instrument Global Volume" on instrument definition unlike the other time). This bug may affecting other formats, not just mod2taud.py, as well
|
||||
[x] nearly_there_.mod: `C#5 SD300 / ... / C-5 SD200 / A#4 / G#4 (at tickspeed 4)`: every `C-5 SD200` (there are four occurances) gets skipped
|
||||
[ ] scale Oxxxx when samples get resampled
|
||||
[ ] implement bitcrusher and overdrive (eff sym '8' and '9')
|
||||
[x] scale Oxxxx when samples get resampled
|
||||
[x] implement bitcrusher and overdrive (eff sym '8' and '9')
|
||||
[x] note trigger with inst and note fx set (e.g. portamento) but no volume set is not getting their default volume but getting what was before instead (SATELL.taud ptn 23) -- and simulateRowState() of taut.js always shows old volume instead of default volume, regardless of note fx's existence
|
||||
[x] how does fadeout=0 work on IT? On XM, the note don't decay at all (that's why there's separate CUT value). Also see what Global Behaviour 'm' flag actually do on Taud (or, which slop AI had fed me *sigh*). `slumberjack.xm` plays normally but notes of `4THSYM.it` don't decay at all
|
||||
Resolution: confirmed against schismtracker (player/sndmix.c:330-342) and
|
||||
ft2-clone (src/ft2_replayer.c:1467-1481). Both IT and FT2 treat stored
|
||||
fadeout=0 as "no fade" — there is no separate "use fadeout" flag in
|
||||
either file format; "cut" is just the slider-extreme of the same
|
||||
magnitude (MilkyTracker SectionInstruments.cpp:499-500 maps the slider's
|
||||
4097th position to internal 32767). The 'm' flag's claim that FT2 cuts
|
||||
on key-off when fadeout=0 was AI slop. Dropped the flag entirely; the
|
||||
engine now uses a single divisor (1024) and converters scale their
|
||||
source units to match (IT pass-through, XM ÷32). See byte 172-173 of
|
||||
the instrument record for engine semantics.
|
||||
Subsequent fixes for the 4THSYM.it hang:
|
||||
(1) Implemented Schism's envelope-end + last-value-0 ⇒ cut rule
|
||||
(player/sndmix.c:493-498) in AudioAdapter.kt advanceEnvelope.
|
||||
(2) Volume envelope evaluation ungated from LOOP/SUSTAIN `b` bits.
|
||||
IT envelopes with flags=0x01 (enabled-no-loop-no-sustain) had been
|
||||
skipped because vEnvActive required either b bit. Now evaluation
|
||||
is gated only by voice.volEnvOn (matches CHN_VOLENV in Schism).
|
||||
See byte 15 spec for the LOOP word.
|
||||
[x] Same gate fix needed for pan and pitch/filter envelopes.
|
||||
Resolution (2026-05-06): added P (envelope present) bit at LOOP-word
|
||||
bit 13 (offsets 16/18/20 bit 5) for all three envelopes. Engine
|
||||
gates pan/pf envelope evaluation on P alone; converters set P=1
|
||||
whenever they emit envelope nodes, regardless of LOOP/SUSTAIN
|
||||
enable, so an enabled-no-wrap envelope (IT pan-env flag=0x01)
|
||||
animates correctly. Mixer's hasPanEnv/hasPfEnv read the same gate,
|
||||
so absent envelopes still bypass envelope-driven output. Pre-
|
||||
2026-05-06 .taud files predate the P bit and need re-conversion
|
||||
for pan/pf envelopes to play. See byte 15/17/19 spec for the LOOP
|
||||
word bit layout.
|
||||
[x] slumberjack.xm: E6x commands are not processed
|
||||
[x] implement linear-freq tone mode (MONOTONE compat)
|
||||
Resolution: ff=2 in song-table flags byte (was reserved). E / F / G
|
||||
arguments are interpreted as Hz/tick at A4 = 440 Hz / C4 ≈ 261.6256 Hz
|
||||
reference, exactly matching MONOTONE's MT_PLAY.PAS `Frequency`
|
||||
arithmetic (MTSRC/MT_PLAY.PAS:606-630). Per-voice `linearFreq` cache
|
||||
in AudioAdapter.kt preserves sub-noteVal precision across ticks; the
|
||||
Voice cache reseeds on note trigger, fine slides, S$2x finetune, and
|
||||
the start of a fresh multi-tick coarse slide. mon2taud.py now emits
|
||||
Hz values verbatim (no SLIDE_UNITS_PER_HZ scaling) and sets the
|
||||
linear-freq flag in the song-table flags byte. Spec details in
|
||||
TAUD_NOTE_EFFECTS.md §1, §E, §F, §G.
|
||||
[x] milkytracker-style volume ramping (on sample-end only)
|
||||
[x] make Cues tab move faster
|
||||
Resolution: Cues panel now uses memory-shift (`shiftOrdersAreaHorizontal`)
|
||||
for LEFT/RIGHT and `shiftPatternArea` for UP/DOWN, plus per-row
|
||||
(`drawOrdersRowAt`) and per-column (`drawOrdersVoiceColumnAt`) helpers,
|
||||
replacing the full-panel redraw on every keystroke.
|
||||
[x] volume and panning policy to match note effect policy: when note is "retriggerred" (note command with instrument specified), the volume/pan must take default value; if not (note command with instrument 0) the volume/pan must stay at the old value. Make both audio engine and taut.js simulator changes.
|
||||
[x] xm volume column commands (+x, -x, Dx, Lx, Mx, Px, Rx, Sx, Ux, Vx) are completely ignored
|
||||
[x] theday.xm order 0x28, channel 6..8 has 'note trigger with inst 1 but no volume -> key-off -> set-volume to 0x20 -> key-off -> set-volume to 0x10 -> key-off -> ...' and it sounds like gating: key-off silences the output, set-volume turns on the output again; notably, this behaviour only works when volume envelope is turned off (any fadeouts progress normally). FT2's keyOff (ft2_replayer.c:411-435) zeroes realVol/outVol when the volume envelope is disabled — IT/Schism does not, and Taud's engine follows IT semantics (no fade when fadeStep == 0). Resolved in xm2taud.py: a pre-pass tracks per-channel bound XM instrument across the order-list walk, and any key-off cell whose bound instrument has vol_env_type & XM_ENV_ON == 0 is paired with `SEL_SET vol=0` in the same row. A subsequent vol-col SET on the channel restores audibility — exactly mirroring FT2's outVol/realVol gate without diverging the engine. Engine semantics stay IT-pure.
|
||||
[x] FT2/MOD double effects with 00 as arg (500, 600) missing volume column -> easiest solution: fully implement `L xy00` and `K xy00` and map 5xx to L, 6xx to K (xm2taud, mod2taud), Kxy and Lxy verbatim (s3m2taud.py, it2taud.py). This is justified because the volume effects rely on memory when 00 is given, and said memory effect only get recalled when NoteFx is used. TAUD_NOTE_EFFECTS already has detailed implementation notes. Mark those two commands as implemented sorely for tracker compatibility.
|
||||
Also document then implement `Mxx` (set channel volume, not just a note: 0x00 to 0x3F) `Nxy` (channel volume slide: similar to Dxy, but applies to the current channel's volume, not just a note) `Pxy` (channel panning slide. Similar to Dxx: P0y - to the right, Px0 - to the left, PFy - fine pan right, PxF - fine pan left) effects
|
||||
[x] 8 MB sample RAM via 512k banks
|
||||
[x] remove panning mode selection and replace global panning rule to equal energy, also move the 'ff' flags to bit 0..1
|
||||
[x] low-number voleffs are too quiet (resolved 2026-05-09).
|
||||
Root cause: the converters folded IT `sample.vol` into IGV (byte 171),
|
||||
and the engine multiplied by IGV continuously — so any V-column override
|
||||
on a sample with default vol < 64 was attenuated a second time, while
|
||||
IT/Schism's V column replaces `chan->volume` outright (sample.vol does
|
||||
not feature in the continuous `instrument_volume` factor — see
|
||||
player/csndfile.c:1317 and player/sndmix.c:1171).
|
||||
Fix: split the two concepts apart. Byte 171 (IGV) is now pure
|
||||
`inst.gv * sample.gv` continuous multiplier; new byte 196 ("Default
|
||||
Note Volume") carries `sample.vol` and is consulted by triggerNote
|
||||
when no V column is present. Engine + all four `*2taud` converters
|
||||
updated; legacy `.taud` files (byte 196 == 0) fall back to the
|
||||
previous "row volume default = 63" behaviour.
|
||||
[x] physical_presence order 0x1F chn 2: note cuts unexpectedly fast — engine fix
|
||||
[x] GSLINGER order 0x03 chn 1: L 0100 fades unexpectedly fast? — converter fix
|
||||
[x] do not reset tickspeed on pattern view play / add key to modify tick speed ('[' down/']' up)
|
||||
[x] expose song table on UI (test with `insaniq2.taud`)
|
||||
[x] 0x0000 - no-op; 0x0001 - key-off; 0x0002 - note-cut 0x0010..0x001F - INT0..INTF
|
||||
[ ] establish hooks for the interrupts
|
||||
[x] Samples and Instruments view (viewer on taut.js; editor on separate .js)
|
||||
follow the ImpulseTracker design first, then improve from there
|
||||
[?] Sample desig for instrument in Pitch-Volume space (one rectangle = one patch). If undefined, the old sample pointer falls thru
|
||||
[ ] Needs .it and .xm test file to complete it2taud and xm2taud
|
||||
|
||||
TODO - list of demo songs that MUST ship with Microtone:
|
||||
* 4THSYM (rename to Fourth Symmetriad) — excellent piece for demonstrating NNAs and filter envelopes
|
||||
(C) Skaven 1998
|
||||
* Slumberjack — for demonstrating XM-compatible instrument definitions
|
||||
(C) raina 2005
|
||||
* Space Debris — MOD with tons of effects
|
||||
(C) Captain/Image 1991
|
||||
* Changing Waves — for Funk Repeat emulation
|
||||
(C) 4mat/orb 2023
|
||||
* Aboriginal Derivatives — for demonstrating Monotone compatibility.
|
||||
(C) Jakim 2010
|
||||
* SWINGIN1 (rename to Swinging Waste) — for demonstrating Monotone compatibility.
|
||||
(C) Phoenix/Hornet 2015
|
||||
|
||||
Play Data: play data are series of tracker-like instructions, visualised as:
|
||||
|
||||
rr||NOTE|Ins|E.Vol|E.Pan|EE.ff|
|
||||
63||FFFF|255|3 63|3 63|FF FFFF| (8 bytes per line, 512 bytes per pattern, 128 patterns on 64 kB bank, 32 banks available (pattern 0xFFF -- bank 31, pattern 127 is a sentinel value for no-pattern))
|
||||
|
||||
notes are tuned as 4096 Tone-Equal Temperament. Tuning is set per-sample using their Sampling rate value.
|
||||
notes are tuned as 4096 Tone-Equal Temperament. Tuning is set per-sample using their Sampling rate value. 0x1000: C at zeroth octave; 0xF000: C at 14th octave; 0xFFFF: ~C at 15th octave; 0x0000..0x001F: reserved for sentinels (valid playable note range is 0x0020..0xFFFF)
|
||||
|
||||
Special values:
|
||||
|
||||
note 0xFFFF: no-op
|
||||
note 0xFFFE: note cut
|
||||
note 0x0000: key-off
|
||||
note 0x0000: no-op
|
||||
note 0x0001: key-off
|
||||
note 0x0002: note cut
|
||||
note 0x0010..0x001F: Interrupt 0..F (notation: Int0..IntF) — reserved interrupt slots; engine has no default handler.
|
||||
|
||||
inst 0: no instrument change
|
||||
|
||||
@@ -2158,7 +2471,7 @@ Audio Adapter MMIO
|
||||
Write 16 to initialise the MP2 context (call this before the decoding of NEW music)
|
||||
Write 1 to decode the frame as MP2
|
||||
|
||||
Calling with more than one bit set will result in UNDEFINED BEHAVIOUR
|
||||
Calling with more than one bit set will result in UNDEFINED BEHAVIOUR — except for the flag 0x11, in which the hardware must initialise then immediately start decoding.
|
||||
|
||||
41 RO: MP2 Decoder Status
|
||||
Non-zero value indicates the decoder is busy. Different value may indicate different decoder status.
|
||||
@@ -2169,11 +2482,17 @@ Audio Adapter MMIO
|
||||
44 RW: TAD Decoder Status
|
||||
Non-zero value indicates the decoder is busy. Different value may indicate different decoder status.
|
||||
45 RW: Select PCM Bin for playhead (writing causes side effects)
|
||||
46 RW: Select current sample bank for tracker, exposed at memory space 0..524287
|
||||
|
||||
64..2367 RW: MP2 Decoded Samples (unsigned 8-bit stereo)
|
||||
2368..4095 RW: MP2 Frame to be decoded
|
||||
4096..4097 RO: MP2 Frame guard bytes; always return 0 on read
|
||||
|
||||
4098..4353 RW: tracker per-voice fader (0: full volume, 255: full silence) for playhead #0
|
||||
4354..4609 RW: tracker per-voice fader (0: full volume, 255: full silence) for playhead #1
|
||||
4610..4865 RW: tracker per-voice fader (0: full volume, 255: full silence) for playhead #2
|
||||
4866..5121 RW: tracker per-voice fader (0: full volume, 255: full silence) for playhead #3
|
||||
|
||||
Sound Hardware Info
|
||||
- Sampling rate: 32000 Hz
|
||||
- Bit depth: 8 bits/sample, unsigned
|
||||
@@ -2214,14 +2533,14 @@ Play Head Flags
|
||||
Byte 2
|
||||
- PCM Mode: Write non-zero value to start uploading; always 0 when read
|
||||
- Tracker Mode: Global mixer flags. Maps directly to Taud effect symbol '1'
|
||||
0b 0000 00fp
|
||||
p: panning mode (0: linear, 1: equal-power)
|
||||
f: pitchshift mode (0: tone-linear, 1: Amiga)
|
||||
0b 0000 00ff
|
||||
ff: pitchshift mode (0: linear pitch slides, 1: Amiga period slides, 2: linear-frequency slides, 3: reserved)
|
||||
Tracker command may change the mixer state, but the changes WILL NOT BE REFLECTED BACK.
|
||||
Starting a new song will use whatever written to this register. In other words, changes
|
||||
made by songs will not persist.
|
||||
Panning law is fixed to the equal-energy; there is no runtime selection.
|
||||
Byte 3 (Tracker Mode)
|
||||
- BPM (24 to 279. Play Data will change this register)
|
||||
- BPM (25 to 280. Play Data will change this register)
|
||||
Byte 4 (Tracker Mode)
|
||||
- Tick Rate (Play Data will change this register)
|
||||
|
||||
@@ -2238,48 +2557,55 @@ Play Head Flags
|
||||
Byte 11..20: 0b miV1 miV2, 0b miV3 miV4, 0b miV5 miV6, ... 0b miV19 miV20
|
||||
Byte 21..30: 0b hiV1 hiV2, 0b hiV3 hiV4, 0b hiV5 hiV6, ... 0b hiV19 hiV20
|
||||
Byte 31..32: instruction
|
||||
1000xxxx yyyyyyyy - Go back 0bxxxxyyyyyyyy patterns
|
||||
1001xxxx yyyyyyyy - Skip forward 0bxxxxyyyyyyyy patterns
|
||||
1111xxxx yyyyyyyy - Go to absolute pattern number 0bxxxxyyyyyyyy
|
||||
00000001 - Halt
|
||||
1000xxxx yyyyyyyy (BAK000) - Go back 0bxxxxyyyyyyyy patterns
|
||||
1001xxxx yyyyyyyy (FWD000) - Skip forward 0bxxxxyyyyyyyy patterns
|
||||
1111xxxx yyyyyyyy (JMP000) - Go to absolute pattern number 0bxxxxyyyyyyyy
|
||||
00000010 00xxxxxx (LEN 00) - Pattern length for this cue (0..63), where 0: 1 row, 63: 64 rows (decoded by AudioAdapter as of 2026-05-05; emitted by xm2taud / it2taud for non-multiple-of-64 source patterns)
|
||||
00000001 00000000 - Halt (HALT ) - Play the full length of the pattern then stop the playback
|
||||
00000001 00xxxxxx - Fadeout (FADOUT) - Gradually decrease global volume such that at row 0bxxxxxx it reaches zero, then stop the playback
|
||||
00000000 - No operation
|
||||
|
||||
65536..131071 RW: PCM Sample buffer
|
||||
|
||||
Table of 3.5 Minifloat values (CSV)
|
||||
Table of 3.5 Minifloat values (CSV).
|
||||
Rebiased 2026-05-07 so the smallest non-zero step is 1/256 s and the maximum
|
||||
is 15.75 s — every cell is the original LUT value divided by 8. Chosen for
|
||||
tracker envelopes: a single song tick (≈ 8.9 ms at BPM 280, ≈ 100 ms at
|
||||
BPM 25) now lands within ±17 % of an LUT entry across the whole supported
|
||||
BPM range; the previous bias was ±150 % at common BPMs.
|
||||
,000,001,010,011,100,101,110,111,MSB
|
||||
00000,0,1,2,4,8,16,32,64
|
||||
00001,0.03125,1.03125,2.0625,4.125,8.25,16.5,33,66
|
||||
00010,0.0625,1.0625,2.125,4.25,8.5,17,34,68
|
||||
00011,0.09375,1.09375,2.1875,4.375,8.75,17.5,35,70
|
||||
00100,0.125,1.125,2.25,4.5,9,18,36,72
|
||||
00101,0.15625,1.15625,2.3125,4.625,9.25,18.5,37,74
|
||||
00110,0.1875,1.1875,2.375,4.75,9.5,19,38,76
|
||||
00111,0.21875,1.21875,2.4375,4.875,9.75,19.5,39,78
|
||||
01000,0.25,1.25,2.5,5,10,20,40,80
|
||||
01001,0.28125,1.28125,2.5625,5.125,10.25,20.5,41,82
|
||||
01010,0.3125,1.3125,2.625,5.25,10.5,21,42,84
|
||||
01011,0.34375,1.34375,2.6875,5.375,10.75,21.5,43,86
|
||||
01100,0.375,1.375,2.75,5.5,11,22,44,88
|
||||
01101,0.40625,1.40625,2.8125,5.625,11.25,22.5,45,90
|
||||
01110,0.4375,1.4375,2.875,5.75,11.5,23,46,92
|
||||
01111,0.46875,1.46875,2.9375,5.875,11.75,23.5,47,94
|
||||
10000,0.5,1.5,3,6,12,24,48,96
|
||||
10001,0.53125,1.53125,3.0625,6.125,12.25,24.5,49,98
|
||||
10010,0.5625,1.5625,3.125,6.25,12.5,25,50,100
|
||||
10011,0.59375,1.59375,3.1875,6.375,12.75,25.5,51,102
|
||||
10100,0.625,1.625,3.25,6.5,13,26,52,104
|
||||
10101,0.65625,1.65625,3.3125,6.625,13.25,26.5,53,106
|
||||
10110,0.6875,1.6875,3.375,6.75,13.5,27,54,108
|
||||
10111,0.71875,1.71875,3.4375,6.875,13.75,27.5,55,110
|
||||
11000,0.75,1.75,3.5,7,14,28,56,112
|
||||
11001,0.78125,1.78125,3.5625,7.125,14.25,28.5,57,114
|
||||
11010,0.8125,1.8125,3.625,7.25,14.5,29,58,116
|
||||
11011,0.84375,1.84375,3.6875,7.375,14.75,29.5,59,118
|
||||
11100,0.875,1.875,3.75,7.5,15,30,60,120
|
||||
11101,0.90625,1.90625,3.8125,7.625,15.25,30.5,61,122
|
||||
11110,0.9375,1.9375,3.875,7.75,15.5,31,62,124
|
||||
11111,0.96875,1.96875,3.9375,7.875,15.75,31.5,63,126
|
||||
00000,0,0.125,0.25,0.5,1,2,4,8
|
||||
00001,0.00390625,0.12890625,0.2578125,0.515625,1.03125,2.0625,4.125,8.25
|
||||
00010,0.0078125,0.1328125,0.265625,0.53125,1.0625,2.125,4.25,8.5
|
||||
00011,0.01171875,0.13671875,0.2734375,0.546875,1.09375,2.1875,4.375,8.75
|
||||
00100,0.015625,0.140625,0.28125,0.5625,1.125,2.25,4.5,9
|
||||
00101,0.01953125,0.14453125,0.2890625,0.578125,1.15625,2.3125,4.625,9.25
|
||||
00110,0.0234375,0.1484375,0.296875,0.59375,1.1875,2.375,4.75,9.5
|
||||
00111,0.02734375,0.15234375,0.3046875,0.609375,1.21875,2.4375,4.875,9.75
|
||||
01000,0.03125,0.15625,0.3125,0.625,1.25,2.5,5,10
|
||||
01001,0.03515625,0.16015625,0.3203125,0.640625,1.28125,2.5625,5.125,10.25
|
||||
01010,0.0390625,0.1640625,0.328125,0.65625,1.3125,2.625,5.25,10.5
|
||||
01011,0.04296875,0.16796875,0.3359375,0.671875,1.34375,2.6875,5.375,10.75
|
||||
01100,0.046875,0.171875,0.34375,0.6875,1.375,2.75,5.5,11
|
||||
01101,0.05078125,0.17578125,0.3515625,0.703125,1.40625,2.8125,5.625,11.25
|
||||
01110,0.0546875,0.1796875,0.359375,0.71875,1.4375,2.875,5.75,11.5
|
||||
01111,0.05859375,0.18359375,0.3671875,0.734375,1.46875,2.9375,5.875,11.75
|
||||
10000,0.0625,0.1875,0.375,0.75,1.5,3,6,12
|
||||
10001,0.06640625,0.19140625,0.3828125,0.765625,1.53125,3.0625,6.125,12.25
|
||||
10010,0.0703125,0.1953125,0.390625,0.78125,1.5625,3.125,6.25,12.5
|
||||
10011,0.07421875,0.19921875,0.3984375,0.796875,1.59375,3.1875,6.375,12.75
|
||||
10100,0.078125,0.203125,0.40625,0.8125,1.625,3.25,6.5,13
|
||||
10101,0.08203125,0.20703125,0.4140625,0.828125,1.65625,3.3125,6.625,13.25
|
||||
10110,0.0859375,0.2109375,0.421875,0.84375,1.6875,3.375,6.75,13.5
|
||||
10111,0.08984375,0.21484375,0.4296875,0.859375,1.71875,3.4375,6.875,13.75
|
||||
11000,0.09375,0.21875,0.4375,0.875,1.75,3.5,7,14
|
||||
11001,0.09765625,0.22265625,0.4453125,0.890625,1.78125,3.5625,7.125,14.25
|
||||
11010,0.1015625,0.2265625,0.453125,0.90625,1.8125,3.625,7.25,14.5
|
||||
11011,0.10546875,0.23046875,0.4609375,0.921875,1.84375,3.6875,7.375,14.75
|
||||
11100,0.109375,0.234375,0.46875,0.9375,1.875,3.75,7.5,15
|
||||
11101,0.11328125,0.23828125,0.4765625,0.953125,1.90625,3.8125,7.625,15.25
|
||||
11110,0.1171875,0.2421875,0.484375,0.96875,1.9375,3.875,7.75,15.5
|
||||
11111,0.12109375,0.24609375,0.4921875,0.984375,1.96875,3.9375,7.875,15.75
|
||||
LSB
|
||||
|
||||
## Tracker Note Effects
|
||||
@@ -2295,6 +2621,17 @@ This is a file format for Taud tracker data. Taud can be extended with Microtone
|
||||
|
||||
Endianness: Little
|
||||
|
||||
# Conformance language
|
||||
(RFC 2119+8174)
|
||||
- **MUST** / **MUST NOT** / **REQUIRED** / **SHALL** / **SHALL NOT** — absolute requirements / prohibitions. A conforming implementation **SHALL** observe every such rule; an implementation that violates one is non-conforming.
|
||||
- **SHOULD** / **SHOULD NOT** / **RECOMMENDED** / **NOT RECOMMENDED** — strong guidance. An implementation **MAY** deviate in particular circumstances, but the full implications **MUST** be understood and weighed before doing so.
|
||||
- **MAY** / **OPTIONAL** — truly optional. Implementations that include the feature and implementations that omit it are equally conforming, and each **MUST** be prepared to interoperate with the other (with reduced functionality where the optional feature is the means of interoperation).
|
||||
(IMPLEMENTATION DETAILS)
|
||||
- **INVALID.** Blame the encoder; decoder MUST stop decoding with appropriate errors.
|
||||
- **UNDEFINED BEHAVIOUR.** Encoder MAY encode it; decoder MAY do whatever it wants to, including spawning a daemon out of your nose.
|
||||
- **IGNORED.** Encoder MAY encode it; decoder MUST skip past it.
|
||||
- **RESERVED.** Encoder MUST NOT encode it. Decoder MUST skip past it.
|
||||
|
||||
# File Structure
|
||||
\x1F T S V M a u d
|
||||
[HEADER]
|
||||
@@ -2318,28 +2655,29 @@ Endianness: Little
|
||||
Uint32 Offset to Project Data. Zero if Project Data is nonexistent
|
||||
Byte[14]Tracker/Converter signature
|
||||
|
||||
## Sample and Instrument bin image
|
||||
8256 kB when decompressed. First 8 MB holds samples.
|
||||
|
||||
## Song Table
|
||||
* Rows of 32 bytes:
|
||||
Uint32 Song offset
|
||||
Uint8 Number of voices
|
||||
Uint16 Number of patterns (0 is invalid. pattern bin length = numPats * 8 bytes)
|
||||
Uint8 Initial BPM (bias of -24. 0x00=24, 0xFF=279)
|
||||
Uint8 Initial BPM (bias of -25. 0x00=25, 0xFF=280)
|
||||
Uint8 Initial Tickrate (0 is invalid)
|
||||
Uint16 Current Tuning base note (1..65533). A4 (western default) is 0x5C00. C9 (tracker default) is 0xA000. If zero, assume the tracker default value
|
||||
Float32 Frequency at the base note. Tracker default is 8363.0. If zero, assume the tracker default
|
||||
Uint8 Flags for Global Behaviour (effect symbol '1')
|
||||
0b 0000 0mfp
|
||||
p: panning law (0=linear, 1=equal-power)
|
||||
f: tone mode (0=linear pitch slides, 1=Amiga period slides)
|
||||
m: fadeout-zero policy (0=IT — stored fadeout 0 means no fadeout;
|
||||
1=FT2 — stored fadeout 0 means cut on key-off)
|
||||
0b 000 rrr ff
|
||||
ff: tone mode (0: linear pitch slides, 1: Amiga period slides, 2: linear-frequency slides, 3: RESERVED)
|
||||
rrr: interpolation mode (0: default, 1: none, 2: Amiga 500, 3: Amiga 1200, 4: SNES 4-tap Gaussian, 5: NES DPCM simulation)
|
||||
Uint8 Song global volume
|
||||
* ImpulseTracker has range of 0..128; multiply by (255/128) then round to int
|
||||
Uint8 Song mixing volume
|
||||
* ImpulseTracker has range of 0..128; multiply by (255/128) then round to int
|
||||
Uint32 Compressed size of PATTERN BIN for this song
|
||||
Uint32 Compressed size of CUE SHEET for this song
|
||||
Byte[6] Reserved
|
||||
Byte[6] RESERVED
|
||||
|
||||
Taud device can queue up to 2 "playdata" in its buffer, which can be interpreted as a song.
|
||||
|
||||
@@ -2361,7 +2699,7 @@ Endianness: Little
|
||||
Project Data is just a concatenation of blocks identified by their FourCC.
|
||||
|
||||
Byte[8] Magic (\x1E T a u d P r J)
|
||||
Byte[8] Reserved
|
||||
Byte[8] RESERVED
|
||||
* Repetition of
|
||||
Byte[4] Title of the section (fourcc)
|
||||
Uint32 Section length
|
||||
@@ -2380,6 +2718,7 @@ prefixes:
|
||||
* PCom. Project author. Encoding: UTF-8
|
||||
* PCpr. Project copyright string. Encoding: UTF-8
|
||||
* PNam. Project name. Encoding: UTF-8
|
||||
* Pmsg. Project message. Encoding: UTF-8
|
||||
|
||||
* INam. Instrument name table. Strings separated by 0x1E
|
||||
|
||||
@@ -2411,10 +2750,11 @@ prefixes:
|
||||
* Repetition of:
|
||||
Uint8 Notation index (starting from zero) used by songs
|
||||
Uint32 Size of this notation following this field
|
||||
Uint16 Reserved for flags
|
||||
Float32 Interval size (octave system = 2.0f). If you are not using an interval system (which means you are responsible for defining every note expressible), this must be NaN. 0f and Infinity are considered illegal
|
||||
Uint16 Notes between interval MINUS ONE (or octave); 12-TET will have value 11
|
||||
Byte[8] Reserved
|
||||
Uint16 RESERVED for flags
|
||||
Uint16 Interval size in 4096-TET lattice (octave = 0x1000, tritave = 0x195C). If you are not using an interval system (which means you are responsible for defining every note expressible), this must be 0.
|
||||
Uint16 RESERVED for float32 interval size (should it be in 4096-TET which is inexact or frequency multiplier which is exact but difficult to implement?)
|
||||
Uint16 Notes between interval (or octave) MINUS ONE; 12-TET will have value 11
|
||||
Byte[8] RESERVED
|
||||
Byte[*] Name, null terminated. Encoding: UTF-8
|
||||
Byte[*] Notation table. 0xFF-separated and null-terminated. Encoding: Taud charset
|
||||
Uint16[*] Frequency table. Size of the table is defined by "Notes between interval MINUS ONE". This is a lookup table of relative pitch offsets (against the base tuning note) in 4096-TET space. Index zero of this table will be 0x0 if you read the spec right
|
||||
@@ -2435,6 +2775,45 @@ prefixes:
|
||||
Uint8 Version (Ascii 'a')
|
||||
Bytes Notation definitions (see above)
|
||||
|
||||
* Ixmp. Instrument extra samples
|
||||
* Repetition of:
|
||||
Uint8 Instrument ID
|
||||
Uint24 Count of patches
|
||||
** Repetition of:
|
||||
Uint8 Patch definition version (always 1)
|
||||
Uint16 Pitch start ; Taud 4096-TET noteVal (same scale as pattern-cell note)
|
||||
Uint16 Pitch end (inclusive)
|
||||
Uint8 Volume start ; 0..63
|
||||
Uint8 Volume end (inclusive) ; 0..63
|
||||
- Above four parameters define a rectangle over the Pitch-Volume space. See Notes 4 and 5
|
||||
Uint32 Sample pointer
|
||||
Uint16 Sample length
|
||||
Uint16 Play Start (usually 0 but not always)
|
||||
Uint16 Loop Start (can be smaller than Play Start)
|
||||
Uint16 Loop End
|
||||
Uint16 samplingRate ; per-sample C-5 speed; same encoding as base instrument byte 6-7
|
||||
Int16 sampleDetune ; per-sample fine detune in signed 4096-TET units (XM finetune; IT samples leave 0)
|
||||
Uint8 loopMode ; same encoding as base instrument byte 14 (bits 0-1 = mode, bit 2 = sustain loop)
|
||||
Uint8 defaultPan ; per-sample default pan (0..255; 0x80 = centre); 0xFF = "no override"
|
||||
Uint8 defaultNoteVolume ; per-sample default note volume (0..255 scaled from IT 0..64); 0 = "no override"
|
||||
Uint8 vibratoSpeed ; per-sample auto-vibrato (mirrors base inst byte 175)
|
||||
Uint8 vibratoSweep ; per-sample auto-vibrato (mirrors base inst byte 176)
|
||||
Uint8 vibratoDepth ; per-sample auto-vibrato (mirrors base inst byte 187)
|
||||
Uint8 vibratoRate ; per-sample auto-vibrato (mirrors base inst byte 188)
|
||||
Uint8 vibratoWaveform ; bits 0-2 only (mirrors instrumentFlag bits 2-4); 0xFF = "no override"
|
||||
|
||||
Notes:
|
||||
0. this extension is made to support IT/XM instrument spec as well as partial compatibility to SF2 (Soundfont format two)
|
||||
1. Envelopes (vol/pan/pf), fadeout, NNA / DCT / DCA, pitch-pan, filter, IGV and any other "instrument-scope" parameters all follow the base instrument definition. Only sample-scope parameters (the patch fields listed above) override.
|
||||
2. overlapping regions are considered INVALID
|
||||
3. multiple Ixmp blocks pointing the same instrument are considered INVALID
|
||||
4. IT and XM does not define volumes. Keep the Volume rectangle at 0..63 — the engine clamps to that range when matching.
|
||||
5. SF2 does define volumes (because MIDI). Convert it using `round(velocity * (63/127))`
|
||||
On import, `initialAttenuation`, filters and ADSR shall be ignored
|
||||
6. Patch selection at trigger time walks the patch list in order; the first patch whose rectangle contains the trigger's (noteVal, rowVolume) wins. When no patch matches, the base instrument's sample fields are used unchanged.
|
||||
7. Sentinel values listed above ("no override") let a patch defer to the base instrument for a given field — used by converters that don't carry per-sample data for one of the dimensions (e.g. SF2 ignoring per-sample pan).
|
||||
8. Total per-patch payload is 31 bytes.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
**S3M (ScreamTracker 3) to Taud conversion notes**
|
||||
@@ -2496,7 +2875,7 @@ The halt instruction (byte value 0x01 at cue offset 30) is placed on the last ac
|
||||
|
||||
## Tempo mapping
|
||||
|
||||
S3M BPM is stored as a raw decimal value. Taud's initial BPM byte uses a bias of -24 (byte 0x00 = 24 BPM, 0xFF = 279 BPM). Conversion: taud_byte = bpm - 24. The converter also scans row 0 of the first pattern in the order list for A (set speed) and T (set tempo) effects and uses those values in preference to the S3M header defaults.
|
||||
S3M BPM is stored as a raw decimal value. Taud's initial BPM byte uses a bias of -25 (byte 0x00 = 25 BPM, 0xFF = 280 BPM). Conversion: taud_byte = bpm - 25. The converter also scans row 0 of the first pattern in the order list for A (set speed) and T (set tempo) effects and uses those values in preference to the S3M header defaults.
|
||||
|
||||
## Global volume
|
||||
|
||||
|
||||
@@ -23,7 +23,9 @@ import net.torvald.tsvm.peripheral.MP2Env
|
||||
* 8. Call `setCuePosition(playhead, 0)` then `play(playhead)`.
|
||||
*
|
||||
* Note values: 0x4000 = C3 (sample's native pitch), 4096 steps per octave.
|
||||
* Empty row: note = 0xFFFF (no trigger). All 256 instrument slots (0-255) are valid.
|
||||
* Empty row: note = 0x0000 (no trigger). Note sentinels (0x0000..0x001F): 0x0000 = no-op,
|
||||
* 0x0001 = key-off, 0x0002 = note cut, 0x0010..0x001F = Int0..IntF (reserved interrupts).
|
||||
* Valid playable notes are 0x0020..0xFFFF. All 256 instrument slots (0-255) are valid.
|
||||
*
|
||||
* ## How to upload PCM audio into a playhead
|
||||
*
|
||||
@@ -75,7 +77,7 @@ class AudioJSR223Delegate(private val vm: VM) {
|
||||
|
||||
fun startSampleUpload(playhead: Int) { getPlayhead(playhead)?.pcmUpload = true }
|
||||
|
||||
fun setBPM(playhead: Int, bpm: Int) { getPlayhead(playhead)?.bpm = (bpm - 24).and(255) + 24 }
|
||||
fun setBPM(playhead: Int, bpm: Int) { getPlayhead(playhead)?.bpm = (bpm - 25).and(255) + 25 }
|
||||
fun getBPM(playhead: Int) = getPlayhead(playhead)?.bpm
|
||||
|
||||
fun setTickRate(playhead: Int, rate: Int) { getPlayhead(playhead)?.tickRate = rate and 255 }
|
||||
@@ -91,11 +93,157 @@ class AudioJSR223Delegate(private val vm: VM) {
|
||||
|
||||
fun getTrackerRow(playhead: Int) = getPlayhead(playhead)?.trackerState?.rowIndex ?: 0
|
||||
|
||||
/** Mute is now a thin wrapper over the per-voice fader: muting writes 255 (silence),
|
||||
* unmuting clears the fader back to 0 (unity). Callers that want a partial attenuation
|
||||
* should use setVoiceFader directly. */
|
||||
fun setVoiceMute(playhead: Int, voice: Int, muted: Boolean) {
|
||||
getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19))?.muted = muted
|
||||
getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19))?.fader = if (muted) 255 else 0
|
||||
}
|
||||
fun getVoiceMute(playhead: Int, voice: Int): Boolean =
|
||||
getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19))?.muted ?: false
|
||||
(getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19))?.fader ?: 0) == 255
|
||||
|
||||
/** Externally-controlled per-voice fader. 0 = unity, 255 = silence; values are masked to 8 bits.
|
||||
* Mirrors MMIO 4098.. (256 bytes per playhead, first 20 entries map to live voice slots). */
|
||||
fun setVoiceFader(playhead: Int, voice: Int, fader: Int) {
|
||||
getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19))?.fader = fader and 255
|
||||
}
|
||||
fun getVoiceFader(playhead: Int, voice: Int): Int =
|
||||
getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19))?.fader ?: 0
|
||||
|
||||
/** Effective per-voice tracker volume (0.0..1.0) — what the mixer applies right now after the
|
||||
* envelope, fadeout, vol-column / D-slide / tremolo ramp, and the host-owned per-voice fader,
|
||||
* but BEFORE master/mixing/global volumes. Returns 0.0 for inactive voices. Mirrors the
|
||||
* perVoiceGain assembled in the per-sample mix loop (AudioAdapter.kt:3201). */
|
||||
fun getVoiceEffectiveVolume(playhead: Int, voice: Int): Double {
|
||||
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return 0.0
|
||||
if (!v.active) return 0.0
|
||||
val effEnvVol = if (v.volEnvOn) v.envVolMix else 1.0
|
||||
val faderGain = (255 - v.fader) / 255.0
|
||||
return (effEnvVol * v.fadeoutVolume * v.currentMixVolume * faderGain).coerceIn(0.0, 1.0)
|
||||
}
|
||||
|
||||
/** Effective per-voice tracker pan (0..255, 128 = centre) — channelPan modulated by the pan
|
||||
* envelope when it is active. Returns 128 (centre) for inactive voices. Mirrors the pan
|
||||
* selection in the per-sample mix loop (AudioAdapter.kt:3205). */
|
||||
fun getVoiceEffectivePan(playhead: Int, voice: Int): Int {
|
||||
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return 128
|
||||
if (!v.active) return 128
|
||||
return if (v.hasPanEnv && v.panEnvOn) {
|
||||
val envPanRaw = (v.envPan * 255.0).toInt().coerceIn(0, 255)
|
||||
(v.channelPan + envPanRaw - 128).coerceIn(0, 255)
|
||||
} else v.channelPan.coerceIn(0, 255)
|
||||
}
|
||||
|
||||
/** Whether the voice slot is currently sounding (i.e. owns an active sample). Mirrors
|
||||
* `Voice.active` which is the source of truth for "is this voice contributing to the mix
|
||||
* right now". Visualisers should treat this as the authoritative on/off bit. */
|
||||
fun getVoiceActive(playhead: Int, voice: Int): Boolean =
|
||||
getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19))?.active == true
|
||||
|
||||
/** Active-note counts per instrument id (index 0..255): how many notes are sounding *right
|
||||
* now* for each instrument, counting ~~BOTH~~ the live foreground voices ~~and the NNA background
|
||||
* ghosts in the mixer-private pool~~~. Lets visualisers colour by polyphony. The ghost pool is
|
||||
* mutated by the render thread, so it is read defensively by index and any transient
|
||||
* inconsistency is tolerated (a single best-effort frame). */
|
||||
fun getActiveNoteCounts(playhead: Int): IntArray {
|
||||
val counts = IntArray(256)
|
||||
val ts = getPlayhead(playhead)?.trackerState ?: return counts
|
||||
for (v in ts.voices) {
|
||||
if (v.active) counts[v.instrumentId and 0xFF]++
|
||||
}
|
||||
// disabling NNA for now
|
||||
/*try {
|
||||
val bg = ts.backgroundVoices
|
||||
for (i in 0 until bg.size) {
|
||||
val v = bg.getOrNull(i) ?: continue
|
||||
if (v.active) counts[v.instrumentId and 0xFF]++
|
||||
}
|
||||
} catch (_: Exception) { /* ghost pool mutated mid-read — counts are best-effort */ }
|
||||
*/
|
||||
return counts
|
||||
}
|
||||
|
||||
/** Funk-repeat (S$Fx) speed currently driving the voice: 0 = off, otherwise the per-tick
|
||||
* accumulator increment. A non-zero value on an active voice means the voice is live-inverting
|
||||
* its instrument's loop region right now — visualisers can use this to gate the funk overlay. */
|
||||
fun getVoiceFunkSpeed(playhead: Int, voice: Int): Int {
|
||||
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return 0
|
||||
if (!v.active) return 0
|
||||
return v.funkSpeed
|
||||
}
|
||||
|
||||
/** Snapshot of an instrument's funk-repeat XOR mask (one bit per loop-region byte; a set bit
|
||||
* flips that byte by 0xFF during playback). Returns the mask bytes as ints (0..255), or an
|
||||
* empty array when the instrument has never been funk-repeated. The render thread mutates the
|
||||
* live mask, so this returns a copy — the caller gets a stable single-frame view. */
|
||||
fun getInstrumentFunkMask(slot: Int): IntArray {
|
||||
val mask = getFirstSnd()?.instruments?.get(slot and 0xFF)?.funkMask ?: return IntArray(0)
|
||||
return IntArray(mask.size) { mask[it].toInt() and 0xFF }
|
||||
}
|
||||
|
||||
/** Live noteVal (0..65535, 4096-TET) of the foreground voice — the value the mixer is using
|
||||
* *right now* including any in-flight vibrato / arpeggio / portamento delta. Returns 0 for
|
||||
* inactive voices. */
|
||||
fun getVoiceNote(playhead: Int, voice: Int): Int {
|
||||
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return 0
|
||||
if (!v.active) return 0
|
||||
return v.noteVal and 0xFFFF
|
||||
}
|
||||
|
||||
/** Instrument id (0..255) currently bound to the voice slot, or 0 if the voice is inactive. */
|
||||
fun getVoiceInstrument(playhead: Int, voice: Int): Int {
|
||||
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return 0
|
||||
if (!v.active) return 0
|
||||
return v.instrumentId and 0xFF
|
||||
}
|
||||
|
||||
/** Current sample-frame playback position (fractional double) of the voice. Returns -1.0
|
||||
* when the voice is inactive so visualisers can distinguish "no cursor" from "cursor at 0". */
|
||||
fun getVoiceSamplePos(playhead: Int, voice: Int): Double {
|
||||
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return -1.0
|
||||
if (!v.active) return -1.0
|
||||
return v.samplePos
|
||||
}
|
||||
|
||||
/** Volume-envelope segment index — i.e. the node the voice is currently moving *away* from
|
||||
* (the next node it will hit is index + 1). Returns -1 when inactive. */
|
||||
fun getVoiceEnvVolIndex(playhead: Int, voice: Int): Int {
|
||||
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return -1
|
||||
if (!v.active) return -1
|
||||
return v.envIndex
|
||||
}
|
||||
/** Seconds elapsed *into* the current volume-envelope segment (0 ≤ t < segment.offset). */
|
||||
fun getVoiceEnvVolTime(playhead: Int, voice: Int): Double {
|
||||
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return 0.0
|
||||
if (!v.active) return 0.0
|
||||
return v.envTimeSec
|
||||
}
|
||||
|
||||
/** Pan-envelope segment index — see [getVoiceEnvVolIndex]. */
|
||||
fun getVoiceEnvPanIndex(playhead: Int, voice: Int): Int {
|
||||
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return -1
|
||||
if (!v.active) return -1
|
||||
return v.envPanIndex
|
||||
}
|
||||
/** Seconds elapsed into the current pan-envelope segment. */
|
||||
fun getVoiceEnvPanTime(playhead: Int, voice: Int): Double {
|
||||
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return 0.0
|
||||
if (!v.active) return 0.0
|
||||
return v.envPanTimeSec
|
||||
}
|
||||
|
||||
/** Pitch/filter-envelope segment index — see [getVoiceEnvVolIndex]. */
|
||||
fun getVoiceEnvPitchIndex(playhead: Int, voice: Int): Int {
|
||||
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return -1
|
||||
if (!v.active) return -1
|
||||
return v.envPfIndex
|
||||
}
|
||||
/** Seconds elapsed into the current pitch/filter-envelope segment. */
|
||||
fun getVoiceEnvPitchTime(playhead: Int, voice: Int): Double {
|
||||
val v = getPlayhead(playhead)?.trackerState?.voices?.getOrNull(voice.coerceIn(0, 19)) ?: return 0.0
|
||||
if (!v.active) return 0.0
|
||||
return v.envPfTimeSec
|
||||
}
|
||||
|
||||
/** Set the starting row for the next play call, resetting per-row timing and silencing active voices. */
|
||||
fun setTrackerRow(playhead: Int, row: Int) {
|
||||
@@ -117,6 +265,64 @@ class AudioJSR223Delegate(private val vm: VM) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Upload an Ixmp "extra samples" block for instrument [slot] (0-255). The payload is
|
||||
* a flat byte array of `count × 31` patch records — see terranmon.txt "Ixmp. Instrument
|
||||
* extra samples" for the on-wire field layout. Passing an empty array clears any
|
||||
* previously-installed patches on this instrument. */
|
||||
fun uploadInstrumentPatches(slot: Int, bytes: IntArray) {
|
||||
val inst = getFirstSnd()?.instruments?.get(slot and 0xFF) ?: return
|
||||
val recordSize = 31
|
||||
if (bytes.isEmpty() || bytes.size < recordSize) {
|
||||
inst.extraPatches = null
|
||||
return
|
||||
}
|
||||
val count = bytes.size / recordSize
|
||||
if (count == 0) { inst.extraPatches = null; return }
|
||||
fun u8 (o: Int) = bytes[o] and 0xFF
|
||||
fun u16(o: Int) = (bytes[o] and 0xFF) or ((bytes[o + 1] and 0xFF) shl 8)
|
||||
fun s16(o: Int): Int { val v = u16(o); return if (v >= 0x8000) v - 0x10000 else v }
|
||||
fun u32(o: Int) = (bytes[o] and 0xFF) or
|
||||
((bytes[o + 1] and 0xFF) shl 8) or
|
||||
((bytes[o + 2] and 0xFF) shl 16) or
|
||||
((bytes[o + 3] and 0xFF) shl 24)
|
||||
val patches = Array(count) { i ->
|
||||
val o = i * recordSize
|
||||
// Patch version byte at offset 0 is parsed but only version 1 is recognised;
|
||||
// a future version bump would gate alternate field layouts here.
|
||||
AudioAdapter.TaudInstPatch(
|
||||
pitchStart = u16(o + 1),
|
||||
pitchEnd = u16(o + 3),
|
||||
volumeStart = u8 (o + 5),
|
||||
volumeEnd = u8 (o + 6),
|
||||
samplePtr = u32(o + 7),
|
||||
sampleLength = u16(o + 11),
|
||||
playStart = u16(o + 13),
|
||||
loopStart = u16(o + 15),
|
||||
loopEnd = u16(o + 17),
|
||||
samplingRate = u16(o + 19),
|
||||
sampleDetune = s16(o + 21),
|
||||
loopMode = u8 (o + 23),
|
||||
defaultPan = u8 (o + 24),
|
||||
defaultNoteVolume = u8 (o + 25),
|
||||
vibratoSpeed = u8 (o + 26),
|
||||
vibratoSweep = u8 (o + 27),
|
||||
vibratoDepth = u8 (o + 28),
|
||||
vibratoRate = u8 (o + 29),
|
||||
vibratoWaveform = u8 (o + 30)
|
||||
)
|
||||
}
|
||||
inst.extraPatches = patches
|
||||
}
|
||||
|
||||
/** Number of Ixmp patches currently installed on instrument [slot], or 0 if none. */
|
||||
fun getInstrumentPatchCount(slot: Int): Int =
|
||||
getFirstSnd()?.instruments?.get(slot and 0xFF)?.extraPatches?.size ?: 0
|
||||
|
||||
/** Clear any Ixmp patches previously uploaded to instrument [slot]. */
|
||||
fun clearInstrumentPatches(slot: Int) {
|
||||
getFirstSnd()?.instruments?.get(slot and 0xFF)?.extraPatches = null
|
||||
}
|
||||
|
||||
/** Upload 512 bytes (64 rows × 8 bytes) defining pattern `slot` (0-4094). */
|
||||
fun uploadPattern(slot: Int, bytes: IntArray) {
|
||||
getFirstSnd()?.playdata?.get(slot and 0xFFF)?.let { pat ->
|
||||
@@ -134,11 +340,7 @@ class AudioJSR223Delegate(private val vm: VM) {
|
||||
fun setTrackerMixerFlags(playhead: Int, flags: Int) {
|
||||
getFirstSnd()?.playheads?.get(playhead)?.let { ph ->
|
||||
ph.initialGlobalFlags = flags
|
||||
ph.trackerState?.let { ts ->
|
||||
ts.panLaw = flags and 1
|
||||
ts.amigaMode = (flags and 2) != 0
|
||||
ts.fadeoutCutOnZero = (flags and 4) != 0
|
||||
}
|
||||
ph.updateTrackerGlobalBehaviour(flags)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +373,13 @@ class AudioJSR223Delegate(private val vm: VM) {
|
||||
getPlayhead(playhead)?.resetParams()
|
||||
}
|
||||
|
||||
/** Clear funk-repeat (S$Fx) state (per-voice run-state + per-instrument loop-inversion masks)
|
||||
* without disturbing tempo / volume / position. Call on a fresh play-from-start so stale funk
|
||||
* state from a prior playback doesn't bleed into the replay. */
|
||||
fun resetFunkState(playhead: Int) {
|
||||
getPlayhead(playhead)?.resetFunkState()
|
||||
}
|
||||
|
||||
fun purgeQueue(playhead: Int) {
|
||||
getPlayhead(playhead)?.purgeQueue()
|
||||
}
|
||||
@@ -184,6 +393,61 @@ class AudioJSR223Delegate(private val vm: VM) {
|
||||
|
||||
fun getBaseAddr(): Int? = getFirstSnd()?.let { return it.vm.findPeriSlotNum(it)?.times(-131072)?.minus(1) }
|
||||
fun getMemAddr(): Int? = getFirstSnd()?.let { return it.vm.findPeriSlotNum(it)?.times(-1048576)?.minus(1) }
|
||||
|
||||
/** Switch the sample-bin window (peripheral memory 0..524287) to bank `bank` (0..15).
|
||||
* The 8 MB sample pool is organised as 16 × 512 K banks; only the selected bank
|
||||
* is visible through the window. (terranmon.txt:1985-1997, MMIO 46.) */
|
||||
fun setSampleBank(bank: Int) { getFirstSnd()?.mmio_write(46L, bank.toByte()) }
|
||||
fun getSampleBank(): Int? = getFirstSnd()?.sampleBank
|
||||
|
||||
/** Decompress a Taud sample+instrument blob (gzip or zstd) directly into the
|
||||
* audio adapter's 8 MB sample pool and 64 K instrument bin, bypassing the user
|
||||
* memory staging buffer. The decompressed payload must be exactly
|
||||
* `SAMPLE_BIN_TOTAL + 65536` bytes (8 MB samples followed by 64 K instruments).
|
||||
*
|
||||
* Needed because user space is capped at 8 MB and cannot hold the full 8256 kB
|
||||
* decompressed image as a contiguous buffer. */
|
||||
fun uploadSampleInstBlob(srcPtr: Int, srcLen: Int): Int {
|
||||
val snd = getFirstSnd() ?: return 0
|
||||
val inbytes = ByteArray(srcLen) { vm.peek(srcPtr.toLong() + it)!! }
|
||||
val bytes = CompressorDelegate.decomp(inbytes)
|
||||
val sampleSize = AudioAdapter.SAMPLE_BIN_TOTAL.toInt()
|
||||
val instSize = 65536
|
||||
if (bytes.size < sampleSize + instSize) return 0
|
||||
UnsafeHelper.memcpyRaw(
|
||||
bytes, UnsafeHelper.getArrayOffset(bytes),
|
||||
null, snd.sampleBin.ptr,
|
||||
sampleSize.toLong()
|
||||
)
|
||||
for (i in 0 until instSize) {
|
||||
snd.instruments[i / 256].setByte(i % 256, bytes[sampleSize + i].toInt() and 0xFF)
|
||||
}
|
||||
return bytes.size
|
||||
}
|
||||
|
||||
/** Compress the audio adapter's full 8 MB sample pool + 64 K instrument bin
|
||||
* (8256 kB total) and write the resulting gzip/zstd blob to user-memory `dstPtr`.
|
||||
* Returns the compressed size. The caller must ensure `dstMaxLen` is large
|
||||
* enough; for incompressible noise the worst case is ~8.3 MB which exceeds
|
||||
* user space — but realistic sample data compresses easily. */
|
||||
fun captureSampleInstBlob(dstPtr: Int, dstMaxLen: Int): Int {
|
||||
val snd = getFirstSnd() ?: return 0
|
||||
val sampleSize = AudioAdapter.SAMPLE_BIN_TOTAL.toInt()
|
||||
val instSize = 65536
|
||||
val raw = ByteArray(sampleSize + instSize)
|
||||
UnsafeHelper.memcpyRaw(
|
||||
null, snd.sampleBin.ptr,
|
||||
raw, UnsafeHelper.getArrayOffset(raw),
|
||||
sampleSize.toLong()
|
||||
)
|
||||
for (i in 0 until instSize) {
|
||||
raw[sampleSize + i] = snd.instruments[i / 256].getByte(i % 256)
|
||||
}
|
||||
val compressed = CompressorDelegate.comp(raw)
|
||||
val n = minOf(compressed.size, dstMaxLen)
|
||||
for (i in 0 until n) vm.poke((dstPtr + i).toLong(), compressed[i])
|
||||
return compressed.size
|
||||
}
|
||||
fun mp2Init() = getFirstSnd()?.mmio_write(40L, 16)
|
||||
fun mp2Decode() = getFirstSnd()?.mmio_write(40L, 1)
|
||||
fun mp2InitThenDecode() = getFirstSnd()?.mmio_write(40L, 17)
|
||||
@@ -224,6 +488,7 @@ class AudioJSR223Delegate(private val vm: VM) {
|
||||
|
||||
|
||||
|
||||
// while the following code does work, it was decided that MP3 is "too new" for tsvm and thus removed.
|
||||
/*
|
||||
js-mp3
|
||||
https://github.com/soundbus-technologies/js-mp3
|
||||
|
||||
@@ -149,6 +149,90 @@ class GraphicsJSR223Delegate(private val vm: VM) {
|
||||
}
|
||||
}
|
||||
|
||||
fun plotRect(x: Int, y: Int, w: Int, h: Int, colour: Int) = plotRect(x, y, w, h, colour, 0)
|
||||
|
||||
/**
|
||||
* @param eff plot effect. 0 — solid, 1 — 50% checkerboard, 2 — 25% checkerboard
|
||||
*/
|
||||
fun plotRect(x: Int, y: Int, w: Int, h: Int, colour: Int, eff: Int) {
|
||||
val xs = min(x, x+w).toLong()
|
||||
val xe = max(x, x+w).toLong()
|
||||
val ys = min(y, y+h).toLong()
|
||||
val ye = max(y, y+h).toLong()
|
||||
|
||||
getFirstGPU()?.let {
|
||||
val forYcond = if (eff == 2) (ys until ye step 2) else (ys until ye)
|
||||
|
||||
for (py in forYcond) {
|
||||
when (eff) {
|
||||
0 -> for (px in xs until xe) {
|
||||
if (px in 0 until it.config.width && py in 0 until it.config.height) {
|
||||
it.poke(py * it.config.width + px, colour.toByte())
|
||||
}
|
||||
}
|
||||
1 -> {
|
||||
val parity = py % 2
|
||||
val forXcond = if (parity == 0L) (xs until xe step 2) else ((xs+1) until xe step 2)
|
||||
|
||||
for (px in forXcond) {
|
||||
if (px in 0 until it.config.width && py in 0 until it.config.height) {
|
||||
it.poke(py * it.config.width + px, colour.toByte())
|
||||
}
|
||||
}
|
||||
}
|
||||
2 -> for (px in xs until xe step 2) {
|
||||
if (px in 0 until it.config.width && py in 0 until it.config.height) {
|
||||
it.poke(py * it.config.width + px, colour.toByte())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
it.applyDelay()
|
||||
}
|
||||
}
|
||||
|
||||
fun plotRect2(x: Int, y: Int, w: Int, h: Int, colour: Int) = plotRect2(x, y, w, h, colour, 0)
|
||||
|
||||
/**
|
||||
* @param eff plot effect. 0 — solid, 1 — 50% checkerboard, 2 — 25% checkerboard
|
||||
*/
|
||||
fun plotRect2(x: Int, y: Int, w: Int, h: Int, colour: Int, eff: Int) {
|
||||
val xs = min(x, x+w).toLong()
|
||||
val xe = max(x, x+w).toLong()
|
||||
val ys = min(y, y+h).toLong()
|
||||
val ye = max(y, y+h).toLong()
|
||||
|
||||
getFirstGPU()?.let {
|
||||
val forYcond = if (eff == 2) (ys until ye step 2) else (ys until ye)
|
||||
|
||||
for (py in forYcond) {
|
||||
when (eff) {
|
||||
0 -> for (px in xs until xe) {
|
||||
if (px in 0 until it.config.width && py in 0 until it.config.height) {
|
||||
it.poke(262144 + py * it.config.width + px, colour.toByte())
|
||||
}
|
||||
}
|
||||
1 -> {
|
||||
val parity = py % 2
|
||||
val forXcond = if (parity == 0L) (xs until xe step 2) else ((xs+1) until xe step 2)
|
||||
|
||||
for (px in forXcond) {
|
||||
if (px in 0 until it.config.width && py in 0 until it.config.height) {
|
||||
it.poke(py * it.config.width + px, colour.toByte())
|
||||
}
|
||||
}
|
||||
}
|
||||
2 -> for (px in xs until xe step 2) {
|
||||
if (px in 0 until it.config.width && py in 0 until it.config.height) {
|
||||
it.poke(py * it.config.width + px, colour.toByte())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
it.applyDelay()
|
||||
}
|
||||
}
|
||||
|
||||
fun plotPixelMode1(x: Int, y: Int, colour: Int, plane: Int) {
|
||||
getFirstGPU()?.let {
|
||||
val planesize = it.config.width * it.config.height / 4
|
||||
@@ -159,6 +243,51 @@ class GraphicsJSR223Delegate(private val vm: VM) {
|
||||
}
|
||||
}
|
||||
|
||||
fun plotRectMode1(x: Int, y: Int, w: Int, h: Int, colour: Int, plane: Int) = plotRectMode1(x, y, w, h, colour, plane, 0)
|
||||
|
||||
/**
|
||||
* @param eff plot effect. 0 — solid, 1 — 50% checkerboard, 2 — 25% checkerboard
|
||||
*/
|
||||
fun plotRectMode1(x: Int, y: Int, w: Int, h: Int, colour: Int, plane: Int, eff: Int) {
|
||||
val xs = min(x, x+w).toLong()
|
||||
val xe = max(x, x+w).toLong()
|
||||
val ys = min(y, y+h).toLong()
|
||||
val ye = max(y, y+h).toLong()
|
||||
|
||||
getFirstGPU()?.let {
|
||||
val halfW = it.config.width / 2
|
||||
val halfH = it.config.height / 2
|
||||
val planesize = it.config.width * it.config.height / 4
|
||||
val forYcond = if (eff == 2) (ys until ye step 2) else (ys until ye)
|
||||
|
||||
for (py in forYcond) {
|
||||
when (eff) {
|
||||
0 -> for (px in xs until xe) {
|
||||
if (px in 0 until halfW && py in 0 until halfH) {
|
||||
it.poke(py * halfW + px + planesize * plane, colour.toByte())
|
||||
}
|
||||
}
|
||||
1 -> {
|
||||
val parity = py % 2
|
||||
val forXcond = if (parity == 0L) (xs until xe step 2) else ((xs+1) until xe step 2)
|
||||
|
||||
for (px in forXcond) {
|
||||
if (px in 0 until halfW && py in 0 until halfH) {
|
||||
it.poke(py * halfW + px + planesize * plane, colour.toByte())
|
||||
}
|
||||
}
|
||||
}
|
||||
2 -> for (px in xs until xe step 2) {
|
||||
if (px in 0 until halfW && py in 0 until halfH) {
|
||||
it.poke(py * halfW + px + planesize * plane, colour.toByte())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
it.applyDelay()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets absolute position of scrolling
|
||||
*/
|
||||
@@ -5433,6 +5562,18 @@ class GraphicsJSR223Delegate(private val vm: VM) {
|
||||
|
||||
private val TAV_QLUT = intArrayOf(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)
|
||||
|
||||
// Zstd magic = 0x28 0xB5 0x2F 0xFD (little-endian frame magic).
|
||||
// Newer TAV files default to no Zstd (Video Flags bit 4); detecting the magic
|
||||
// lets the decoder accept both compressed and raw payloads transparently.
|
||||
private fun tavDecompressIfZstd(data: ByteArray): ByteArray {
|
||||
if (data.size >= 4 &&
|
||||
data[0] == 0x28.toByte() && data[1] == 0xB5.toByte() &&
|
||||
data[2] == 0x2F.toByte() && data[3] == 0xFD.toByte()) {
|
||||
return ZstdInputStream(ByteArrayInputStream(data)).use { it.readBytes() }
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// New tavDecode function that accepts compressed data and decompresses internally
|
||||
fun tavDecodeCompressed(compressedDataPtr: Long, compressedSize: Int, currentRGBAddr: Long, prevRGBAddr: Long,
|
||||
width: Int, height: Int, qIndex: Int, qYGlobal: Int, qCoGlobal: Int, qCgGlobal: Int, channelLayout: Int,
|
||||
@@ -5445,12 +5586,9 @@ class GraphicsJSR223Delegate(private val vm: VM) {
|
||||
}
|
||||
|
||||
return try {
|
||||
// Decompress using Zstd
|
||||
val bais = ByteArrayInputStream(compressedData)
|
||||
val zis = ZstdInputStream(bais)
|
||||
val decompressedData = zis.readBytes()
|
||||
zis.close()
|
||||
bais.close()
|
||||
// Decompress with Zstd if the payload starts with the Zstd frame magic;
|
||||
// otherwise pass through (TAV files written without --zstd-level).
|
||||
val decompressedData = tavDecompressIfZstd(compressedData)
|
||||
|
||||
// Allocate buffer for decompressed data
|
||||
val decompressedBuffer = vm.malloc(decompressedData.size)
|
||||
@@ -6725,9 +6863,9 @@ class GraphicsJSR223Delegate(private val vm: VM) {
|
||||
)
|
||||
|
||||
val decompressedData = try {
|
||||
ZstdInputStream(java.io.ByteArrayInputStream(compressedData)).use { zstd ->
|
||||
zstd.readBytes()
|
||||
}
|
||||
// Decompress with Zstd if the payload starts with the Zstd frame magic;
|
||||
// otherwise pass through (TAV files written without --zstd-level).
|
||||
tavDecompressIfZstd(compressedData)
|
||||
} catch (e: Exception) {
|
||||
println("ERROR: Zstd decompression failed: ${e.message}")
|
||||
return arrayOf(0, dbgOut)
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
package net.torvald.tsvm
|
||||
|
||||
/**
|
||||
* Created by minjaesong on 2022-12-30.
|
||||
* 3.5 unsigned minifloat (3-bit exponent + 5-bit mantissa), scaled so the
|
||||
* smallest non-zero step is 1/256 s ≈ 3.91 ms and the maximum representable
|
||||
* value is 15.75 s. Used for Taud envelope point offsets — the resolution at
|
||||
* the low end is fine enough to resolve individual tracker ticks at every
|
||||
* supported BPM (worst case ±17 % at BPM 250+, vs. ±150 % under the original
|
||||
* 1/32-step bias).
|
||||
*
|
||||
* Created by minjaesong on 2022-12-30. Rebiased for tracker tick resolution
|
||||
* on 2026-05-07 (entire LUT divided by 8).
|
||||
*/
|
||||
@JvmInline
|
||||
value class ThreeFiveMiniUfloat(val index: Int = 0) {
|
||||
@@ -11,7 +19,7 @@ value class ThreeFiveMiniUfloat(val index: Int = 0) {
|
||||
}
|
||||
|
||||
companion object {
|
||||
val LUT = floatArrayOf(0f,0.03125f,0.0625f,0.09375f,0.125f,0.15625f,0.1875f,0.21875f,0.25f,0.28125f,0.3125f,0.34375f,0.375f,0.40625f,0.4375f,0.46875f,0.5f,0.53125f,0.5625f,0.59375f,0.625f,0.65625f,0.6875f,0.71875f,0.75f,0.78125f,0.8125f,0.84375f,0.875f,0.90625f,0.9375f,0.96875f,1f,1.03125f,1.0625f,1.09375f,1.125f,1.15625f,1.1875f,1.21875f,1.25f,1.28125f,1.3125f,1.34375f,1.375f,1.40625f,1.4375f,1.46875f,1.5f,1.53125f,1.5625f,1.59375f,1.625f,1.65625f,1.6875f,1.71875f,1.75f,1.78125f,1.8125f,1.84375f,1.875f,1.90625f,1.9375f,1.96875f,2f,2.0625f,2.125f,2.1875f,2.25f,2.3125f,2.375f,2.4375f,2.5f,2.5625f,2.625f,2.6875f,2.75f,2.8125f,2.875f,2.9375f,3f,3.0625f,3.125f,3.1875f,3.25f,3.3125f,3.375f,3.4375f,3.5f,3.5625f,3.625f,3.6875f,3.75f,3.8125f,3.875f,3.9375f,4f,4.125f,4.25f,4.375f,4.5f,4.625f,4.75f,4.875f,5f,5.125f,5.25f,5.375f,5.5f,5.625f,5.75f,5.875f,6f,6.125f,6.25f,6.375f,6.5f,6.625f,6.75f,6.875f,7f,7.125f,7.25f,7.375f,7.5f,7.625f,7.75f,7.875f,8f,8.25f,8.5f,8.75f,9f,9.25f,9.5f,9.75f,10f,10.25f,10.5f,10.75f,11f,11.25f,11.5f,11.75f,12f,12.25f,12.5f,12.75f,13f,13.25f,13.5f,13.75f,14f,14.25f,14.5f,14.75f,15f,15.25f,15.5f,15.75f,16f,16.5f,17f,17.5f,18f,18.5f,19f,19.5f,20f,20.5f,21f,21.5f,22f,22.5f,23f,23.5f,24f,24.5f,25f,25.5f,26f,26.5f,27f,27.5f,28f,28.5f,29f,29.5f,30f,30.5f,31f,31.5f,32f,33f,34f,35f,36f,37f,38f,39f,40f,41f,42f,43f,44f,45f,46f,47f,48f,49f,50f,51f,52f,53f,54f,55f,56f,57f,58f,59f,60f,61f,62f,63f,64f,66f,68f,70f,72f,74f,76f,78f,80f,82f,84f,86f,88f,90f,92f,94f,96f,98f,100f,102f,104f,106f,108f,110f,112f,114f,116f,118f,120f,122f,124f,126f)
|
||||
val LUT = floatArrayOf(0f,0.00390625f,0.0078125f,0.01171875f,0.015625f,0.01953125f,0.0234375f,0.02734375f,0.03125f,0.03515625f,0.0390625f,0.04296875f,0.046875f,0.05078125f,0.0546875f,0.05859375f,0.0625f,0.06640625f,0.0703125f,0.07421875f,0.078125f,0.08203125f,0.0859375f,0.08984375f,0.09375f,0.09765625f,0.1015625f,0.10546875f,0.109375f,0.11328125f,0.1171875f,0.12109375f,0.125f,0.12890625f,0.1328125f,0.13671875f,0.140625f,0.14453125f,0.1484375f,0.15234375f,0.15625f,0.16015625f,0.1640625f,0.16796875f,0.171875f,0.17578125f,0.1796875f,0.18359375f,0.1875f,0.19140625f,0.1953125f,0.19921875f,0.203125f,0.20703125f,0.2109375f,0.21484375f,0.21875f,0.22265625f,0.2265625f,0.23046875f,0.234375f,0.23828125f,0.2421875f,0.24609375f,0.25f,0.2578125f,0.265625f,0.2734375f,0.28125f,0.2890625f,0.296875f,0.3046875f,0.3125f,0.3203125f,0.328125f,0.3359375f,0.34375f,0.3515625f,0.359375f,0.3671875f,0.375f,0.3828125f,0.390625f,0.3984375f,0.40625f,0.4140625f,0.421875f,0.4296875f,0.4375f,0.4453125f,0.453125f,0.4609375f,0.46875f,0.4765625f,0.484375f,0.4921875f,0.5f,0.515625f,0.53125f,0.546875f,0.5625f,0.578125f,0.59375f,0.609375f,0.625f,0.640625f,0.65625f,0.671875f,0.6875f,0.703125f,0.71875f,0.734375f,0.75f,0.765625f,0.78125f,0.796875f,0.8125f,0.828125f,0.84375f,0.859375f,0.875f,0.890625f,0.90625f,0.921875f,0.9375f,0.953125f,0.96875f,0.984375f,1f,1.03125f,1.0625f,1.09375f,1.125f,1.15625f,1.1875f,1.21875f,1.25f,1.28125f,1.3125f,1.34375f,1.375f,1.40625f,1.4375f,1.46875f,1.5f,1.53125f,1.5625f,1.59375f,1.625f,1.65625f,1.6875f,1.71875f,1.75f,1.78125f,1.8125f,1.84375f,1.875f,1.90625f,1.9375f,1.96875f,2f,2.0625f,2.125f,2.1875f,2.25f,2.3125f,2.375f,2.4375f,2.5f,2.5625f,2.625f,2.6875f,2.75f,2.8125f,2.875f,2.9375f,3f,3.0625f,3.125f,3.1875f,3.25f,3.3125f,3.375f,3.4375f,3.5f,3.5625f,3.625f,3.6875f,3.75f,3.8125f,3.875f,3.9375f,4f,4.125f,4.25f,4.375f,4.5f,4.625f,4.75f,4.875f,5f,5.125f,5.25f,5.375f,5.5f,5.625f,5.75f,5.875f,6f,6.125f,6.25f,6.375f,6.5f,6.625f,6.75f,6.875f,7f,7.125f,7.25f,7.375f,7.5f,7.625f,7.75f,7.875f,8f,8.25f,8.5f,8.75f,9f,9.25f,9.5f,9.75f,10f,10.25f,10.5f,10.75f,11f,11.25f,11.5f,11.75f,12f,12.25f,12.5f,12.75f,13f,13.25f,13.5f,13.75f,14f,14.25f,14.5f,14.75f,15f,15.25f,15.5f,15.75f)
|
||||
|
||||
private fun fromFloatToIndex(fval: Float): Int {
|
||||
val (llim, hlim) = binarySearchInterval(fval, LUT)
|
||||
|
||||
@@ -29,8 +29,10 @@ internal object UnsafeHelper {
|
||||
return UnsafePtr(ptr, size, caller)
|
||||
}
|
||||
|
||||
fun memcpy(src: UnsafePtr, fromIndex: Long, dest: UnsafePtr, toIndex: Long, copyLength: Long) =
|
||||
fun memcpy(src: UnsafePtr, fromIndex: Long, dest: UnsafePtr, toIndex: Long, copyLength: Long) {
|
||||
if (src.destroyed || dest.destroyed) return
|
||||
unsafe.copyMemory(src.ptr + fromIndex, dest.ptr + toIndex, copyLength)
|
||||
}
|
||||
fun memcpy(srcAddress: Long, destAddress: Long, copyLength: Long) =
|
||||
unsafe.copyMemory(srcAddress, destAddress, copyLength)
|
||||
fun memcpyRaw(srcObj: Any?, srcPos: Long, destObj: Any?, destPos: Long, len: Long) =
|
||||
@@ -84,81 +86,96 @@ internal class UnsafePtr(pointer: Long, allocSize: Long, private val caller: Any
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun checkNullPtr(index: Long) { // ignore what IDEA says and do inline this
|
||||
//// commenting out because of the suspected (or minor?) performance impact.
|
||||
//// You may break the glass and use this tool when some fucking incomprehensible bugs ("vittujen vitun bugit")
|
||||
//// appear (e.g. getting garbage values when it fucking shouldn't)
|
||||
|
||||
// if (destroyed) { throw DanglingPointerException("The pointer is already destroyed ($this)") }
|
||||
// if (index !in 0 until size) throw AddressOverflowException("Index: $index; alloc size: $size; pointer: ${this}\n${Thread.currentThread().stackTrace.joinToString("\n", limit=10) { " $it" }}")
|
||||
/**
|
||||
* Returns true when the operation should proceed; false when the pointer is destroyed
|
||||
* (so the caller short-circuits to a safe no-op / zero return).
|
||||
*
|
||||
* Why no exception: a JS worker thread that survives killVMenv (because it wasn't
|
||||
* tracked in vm.contexts, e.g. raw java.lang.Thread spawned by JS code) will keep
|
||||
* poking peripheral memory for one or more iterations after dispose(). Letting it
|
||||
* actually call unsafe.putByte on freed memory corrupts the malloc heap and crashes
|
||||
* the JVM with `free_list_checksum_botch`. Returning quietly turns the race into a
|
||||
* harmless no-op until the thread drains.
|
||||
*/
|
||||
private inline fun aliveAt(index: Long): Boolean {
|
||||
if (destroyed) return false
|
||||
if (index < 0 || index >= size) return false
|
||||
return true
|
||||
}
|
||||
|
||||
operator fun get(index: Long): Byte {
|
||||
checkNullPtr(index)
|
||||
if (!aliveAt(index)) return 0
|
||||
return UnsafeHelper.unsafe.getByte(ptr + index)
|
||||
}
|
||||
|
||||
operator fun set(index: Long, value: Byte) {
|
||||
checkNullPtr(index)
|
||||
if (!aliveAt(index)) return
|
||||
UnsafeHelper.unsafe.putByte(ptr + index, value)
|
||||
}
|
||||
|
||||
|
||||
fun getFloatFree(index: Long): Float {
|
||||
checkNullPtr(index)
|
||||
if (!aliveAt(index + 3)) return 0f
|
||||
return UnsafeHelper.unsafe.getFloat(ptr + index)
|
||||
}
|
||||
fun getFloat(unit: Long): Float {
|
||||
checkNullPtr(unit * 4L)
|
||||
return UnsafeHelper.unsafe.getFloat(ptr + (unit * 4L))
|
||||
val idx = unit * 4L
|
||||
if (!aliveAt(idx + 3)) return 0f
|
||||
return UnsafeHelper.unsafe.getFloat(ptr + idx)
|
||||
}
|
||||
|
||||
fun getIntFree(index: Long): Int {
|
||||
checkNullPtr(index)
|
||||
if (!aliveAt(index + 3)) return 0
|
||||
return UnsafeHelper.unsafe.getInt(ptr + index)
|
||||
}
|
||||
fun getInt(unit: Long): Int {
|
||||
checkNullPtr(unit * 4L)
|
||||
return UnsafeHelper.unsafe.getInt(ptr + (unit * 4L))
|
||||
val idx = unit * 4L
|
||||
if (!aliveAt(idx + 3)) return 0
|
||||
return UnsafeHelper.unsafe.getInt(ptr + idx)
|
||||
}
|
||||
|
||||
fun getShortFree(index: Long): Short {
|
||||
checkNullPtr(index)
|
||||
if (!aliveAt(index + 1)) return 0
|
||||
return UnsafeHelper.unsafe.getShort(ptr + index)
|
||||
}
|
||||
fun getShort(unit: Long): Short {
|
||||
checkNullPtr(unit * 2L)
|
||||
return UnsafeHelper.unsafe.getShort(ptr + (unit * 2L))
|
||||
val idx = unit * 2L
|
||||
if (!aliveAt(idx + 1)) return 0
|
||||
return UnsafeHelper.unsafe.getShort(ptr + idx)
|
||||
}
|
||||
|
||||
fun setFloatFree(index: Long, value: Float) {
|
||||
checkNullPtr(index)
|
||||
if (!aliveAt(index + 3)) return
|
||||
UnsafeHelper.unsafe.putFloat(ptr + index, value)
|
||||
}
|
||||
fun setFloat(unit: Long, value: Float) {
|
||||
checkNullPtr(unit * 4L)
|
||||
UnsafeHelper.unsafe.putFloat(ptr + (unit * 4L), value)
|
||||
val idx = unit * 4L
|
||||
if (!aliveAt(idx + 3)) return
|
||||
UnsafeHelper.unsafe.putFloat(ptr + idx, value)
|
||||
}
|
||||
|
||||
fun setIntFree(index: Long, value: Int) {
|
||||
checkNullPtr(index)
|
||||
if (!aliveAt(index + 3)) return
|
||||
UnsafeHelper.unsafe.putInt(ptr + index, value)
|
||||
}
|
||||
fun setInt(unit: Long, value: Int) {
|
||||
checkNullPtr(unit * 4L)
|
||||
UnsafeHelper.unsafe.putInt(ptr + (unit * 4L), value)
|
||||
val idx = unit * 4L
|
||||
if (!aliveAt(idx + 3)) return
|
||||
UnsafeHelper.unsafe.putInt(ptr + idx, value)
|
||||
}
|
||||
|
||||
fun setShortFree(index: Long, value: Short) {
|
||||
checkNullPtr(index)
|
||||
if (!aliveAt(index + 1)) return
|
||||
UnsafeHelper.unsafe.putShort(ptr + index, value)
|
||||
}
|
||||
fun setShortUnit(unit: Long, value: Short) {
|
||||
checkNullPtr(unit * 2L)
|
||||
UnsafeHelper.unsafe.putShort(ptr + (unit * 2L), value)
|
||||
val idx = unit * 2L
|
||||
if (!aliveAt(idx + 1)) return
|
||||
UnsafeHelper.unsafe.putShort(ptr + idx, value)
|
||||
}
|
||||
|
||||
fun fillWith(byte: Byte) {
|
||||
if (destroyed) return
|
||||
UnsafeHelper.unsafe.setMemory(ptr, size, byte)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import java.io.OutputStream
|
||||
import java.nio.charset.Charset
|
||||
import java.util.*
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.ceil
|
||||
|
||||
|
||||
@@ -325,8 +326,37 @@ class VM(
|
||||
}
|
||||
|
||||
fun killAllContexts() {
|
||||
contexts.forEach { it.interrupt() }
|
||||
// Snapshot first: interrupt() can race with the worker thread mutating `contexts`
|
||||
// (see Parallel.kill / attachProgram) and we want to wait on every one of them.
|
||||
val snapshot = contexts.toList()
|
||||
snapshot.forEach { it.interrupt() }
|
||||
snapshot.forEach {
|
||||
try { it.join(500L) } catch (_: InterruptedException) { Thread.currentThread().interrupt() }
|
||||
}
|
||||
contexts.clear()
|
||||
|
||||
// Some JS code (e.g. TVDOS) spawns workers that aren't routed through Parallel.attachProgram,
|
||||
// so they never land in `contexts`. We can still find them by walking the JVM thread set and
|
||||
// matching the per-VM suffix that VMRunnerFactory uses for thread names ("…!<vmId>").
|
||||
val suffix = "!${id.text}"
|
||||
val all = arrayOfNulls<Thread>(Thread.activeCount() * 2)
|
||||
val n = Thread.enumerate(all)
|
||||
for (i in 0 until n) {
|
||||
val t = all[i] ?: continue
|
||||
if (t === Thread.currentThread()) continue
|
||||
val name = t.name
|
||||
if (name.endsWith(suffix) || name == "VmRunner:${id.text}") {
|
||||
t.interrupt()
|
||||
}
|
||||
}
|
||||
for (i in 0 until n) {
|
||||
val t = all[i] ?: continue
|
||||
if (t === Thread.currentThread()) continue
|
||||
val name = t.name
|
||||
if (name.endsWith(suffix) || name == "VmRunner:${id.text}") {
|
||||
try { t.join(500L) } catch (_: InterruptedException) { Thread.currentThread().interrupt() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -520,7 +550,7 @@ class VM(
|
||||
// println("peek $addr -> ${offset}@${memspace?.javaClass?.canonicalName}")
|
||||
|
||||
return if (memspace == null)
|
||||
throw NullPointerException()//null
|
||||
throw OpenBusException(addr)//null
|
||||
else if (memspace is UnsafePtr) {
|
||||
if (addr >= memspace.size)
|
||||
throw ErrorIllegalAccess(this, addr)
|
||||
@@ -535,7 +565,7 @@ class VM(
|
||||
val (memspace, offset) = translateAddr(addr)
|
||||
|
||||
return if (memspace == null)
|
||||
throw NullPointerException()//null
|
||||
throw OpenBusException(addr)//null
|
||||
else if (memspace is UnsafePtr) {
|
||||
if (addr >= memspace.size)
|
||||
throw ErrorIllegalAccess(this, addr)
|
||||
@@ -554,7 +584,7 @@ class VM(
|
||||
val (memspace, offset) = translateAddr(addr)
|
||||
|
||||
return if (memspace == null)
|
||||
throw NullPointerException()//null
|
||||
throw OpenBusException(addr)//null
|
||||
else if (memspace is UnsafePtr) {
|
||||
if (addr >= memspace.size)
|
||||
throw ErrorIllegalAccess(this, addr)
|
||||
@@ -579,7 +609,7 @@ class VM(
|
||||
val (memspace, offset) = translateAddr(addr)
|
||||
|
||||
return if (memspace == null)
|
||||
throw NullPointerException()//null
|
||||
throw OpenBusException(addr)//null
|
||||
else if (memspace is UnsafePtr) {
|
||||
if (addr >= memspace.size)
|
||||
throw ErrorIllegalAccess(this, addr)
|
||||
@@ -782,7 +812,9 @@ class VM(
|
||||
if (fromRel + len > 1048576) return null
|
||||
|
||||
return if (dev is AudioAdapter) {
|
||||
if (relPtrInDev(fromRel, len, 0, 114687)) dev.sampleBin.ptr + fromRel - 0
|
||||
// Sample-bin window: 0..524287 maps into the 8 MB pool through MMIO 46.
|
||||
if (relPtrInDev(fromRel, len, 0, 524287))
|
||||
dev.sampleBin.ptr + dev.sampleBank.toLong() * AudioAdapter.SAMPLE_BANK_SIZE + fromRel
|
||||
else null
|
||||
}
|
||||
else if (dev is GraphicsAdapter) {
|
||||
@@ -824,3 +856,10 @@ class PeripheralEntry2(
|
||||
)
|
||||
|
||||
internal fun Int.kB() = this * 1024L
|
||||
|
||||
fun Long.memAddrToReadable() = "'${this}' (bank " + this.absoluteValue.minus(if (this < 0) 1 else 0).div(1048576) +
|
||||
" offset " + this.absoluteValue.minus(if (this < 0) 1 else 0).mod(1048576) + ")"
|
||||
|
||||
class OpenBusException(addr: Long) : NullPointerException(
|
||||
"Address ${addr.memAddrToReadable()} is open bus"
|
||||
)
|
||||
@@ -62,7 +62,9 @@ class VMJSR223Delegate(private val vm: VM) {
|
||||
// System.err.println("MEMORY dev=${dev.typestring}, fromIndex=$fromIndex, fromRel=$fromRel")
|
||||
|
||||
return if (dev is AudioAdapter) {
|
||||
if (relPtrInDev(fromRel, len, 0, 114687)) dev.sampleBin.ptr + fromRel - 0
|
||||
// Sample-bin window: 0..524287 maps into the 8 MB pool through MMIO 46.
|
||||
if (relPtrInDev(fromRel, len, 0, 524287))
|
||||
dev.sampleBin.ptr + dev.sampleBank.toLong() * AudioAdapter.SAMPLE_BANK_SIZE + fromRel
|
||||
else null
|
||||
}
|
||||
else if (dev is GraphicsAdapter) {
|
||||
@@ -303,7 +305,6 @@ class VMJSR223Delegate(private val vm: VM) {
|
||||
fun sleep(time: Long) {
|
||||
vm.isIdle.set(true)
|
||||
Thread.sleep(time)
|
||||
Thread.sleep(4L)
|
||||
}
|
||||
|
||||
fun waitForMemChg(addr: Int, andMask: Int, xorMask: Int) {
|
||||
|
||||
@@ -22,6 +22,16 @@ object VMSetupBroker {
|
||||
* @param coroutineJobs Hashmap on the host of VMs that holds the coroutine-job object for the currently running VM-instance. Key: Int(VM's identifier), value: [kotlin.coroutines.Job]
|
||||
*/
|
||||
fun initVMenv(vm: VM, profileJson: JsonValue, profileName: String, gpu: GraphicsAdapter, vmRunners: HashMap<VmId, VMRunner>, coroutineJobs: HashMap<VmId, Thread>, whatToDoOnVmException: (Throwable) -> Unit) {
|
||||
// Refuse to start a new runner while the previous one is still alive:
|
||||
// running both concurrently would race on the VM's memory / IO and lead
|
||||
// to mixed text input, garbled rendering, and SIGSEGV on disposed peripherals.
|
||||
coroutineJobs[vm.id]?.let { old ->
|
||||
if (old.isAlive) {
|
||||
System.err.println("[VMSetupBroker] previous runner for ${vm.id} is still alive; tearing it down before re-init")
|
||||
killVMenv(vm, vmRunners, coroutineJobs)
|
||||
}
|
||||
}
|
||||
|
||||
vm.init()
|
||||
|
||||
try {
|
||||
@@ -61,9 +71,38 @@ object VMSetupBroker {
|
||||
*/
|
||||
fun killVMenv(vm: VM, vmRunners: HashMap<VmId, VMRunner>, coroutineJobs: HashMap<VmId, Thread>) {
|
||||
|
||||
// Order is critical: stop ALL execution first, then dispose peripherals.
|
||||
// If we disposed peripherals while the runner thread is still alive, the
|
||||
// thread would touch destroyed UnsafePtrs and SIGSEGV.
|
||||
|
||||
// 1. Stop parallel/child contexts. park() interrupts and joins them.
|
||||
vm.park()
|
||||
vm.poke(-90L, -128)
|
||||
|
||||
// 2. Interrupt the main runner thread and cancel the GraalVM context.
|
||||
// context.close(true) cancels in-flight script evaluation.
|
||||
val runnerThread = coroutineJobs[vm.id]
|
||||
runnerThread?.interrupt()
|
||||
try { vmRunners[vm.id]?.close() } catch (_: Throwable) {}
|
||||
|
||||
// 3. Wait for the main runner thread to actually finish.
|
||||
if (runnerThread != null && runnerThread !== Thread.currentThread()) {
|
||||
try {
|
||||
runnerThread.join(2000L)
|
||||
if (runnerThread.isAlive) {
|
||||
// Last resort: re-interrupt and accept that disposal will
|
||||
// happen with the thread still alive. This is logged so
|
||||
// diagnostics surface a stuck VM rather than failing silently.
|
||||
System.err.println("[VMSetupBroker] runner ${vm.id} did not exit within 2s; proceeding anyway")
|
||||
runnerThread.interrupt()
|
||||
}
|
||||
}
|
||||
catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Now it's safe to release native resources held by peripherals.
|
||||
for (i in 1 until vm.peripheralTable.size) {
|
||||
try {
|
||||
vm.peripheralTable[i].peripheral?.dispose()
|
||||
@@ -71,8 +110,9 @@ object VMSetupBroker {
|
||||
catch (_: Throwable) {}
|
||||
}
|
||||
|
||||
coroutineJobs[vm.id]?.interrupt()
|
||||
vmRunners[vm.id]?.close()
|
||||
// 5. Drop runner / job handles so a subsequent initVMenv won't see stale entries.
|
||||
vmRunners.remove(vm.id)
|
||||
coroutineJobs.remove(vm.id)
|
||||
|
||||
vm.getPrintStream = { TODO() }
|
||||
vm.getErrorStream = { TODO() }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -45,6 +45,7 @@ class CLCDDisplay(assetsRoot: String, vm: VM) : GraphicsAdapter(assetsRoot, vm,
|
||||
shader: ShaderProgram?,
|
||||
uiFBO: FrameBuffer?
|
||||
) {
|
||||
if (disposed) return
|
||||
batch.shader = null
|
||||
batch.inUse {
|
||||
batch.color = Color.WHITE
|
||||
|
||||
@@ -40,6 +40,7 @@ class CharacterLCDdisplay(assetsRoot: String, vm: VM) : GraphicsAdapter(assetsRo
|
||||
shader: ShaderProgram?,
|
||||
uiFBO: FrameBuffer?
|
||||
) {
|
||||
if (disposed) return
|
||||
batch.shader = null
|
||||
batch.inUse {
|
||||
batch.color = Color.WHITE
|
||||
|
||||
@@ -942,7 +942,11 @@ open class GraphicsAdapter(private val assetsRoot: String, val vm: VM, val confi
|
||||
try { this.dispose() } catch (_: GdxRuntimeException) {} catch (_: IllegalArgumentException) {}
|
||||
}
|
||||
|
||||
@Volatile var disposed = false; private set
|
||||
|
||||
override fun dispose() {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
//testTex.dispose()
|
||||
// paletteShader.tryDispose()
|
||||
// textShader.tryDispose()
|
||||
@@ -986,6 +990,11 @@ open class GraphicsAdapter(private val assetsRoot: String, val vm: VM, val confi
|
||||
private val isRefSize = (WIDTH == 560 && HEIGHT == 448)
|
||||
|
||||
open fun render(delta: Float, uiBatch: SpriteBatch, xoff: Float, yoff: Float, flipY: Boolean = false, shader: ShaderProgram? = null, uiFBO: FrameBuffer? = null) {
|
||||
// Bail out if the adapter has already been torn down. Otherwise touching
|
||||
// any of the disposed Pixmaps / Textures / native buffers below would
|
||||
// raise a GdxRuntimeException or SIGSEGV.
|
||||
if (disposed) return
|
||||
|
||||
uiFBO?.end()
|
||||
|
||||
|
||||
@@ -1362,8 +1371,12 @@ open class GraphicsAdapter(private val assetsRoot: String, val vm: VM, val confi
|
||||
textCursorIsOn = !textCursorIsOn
|
||||
}
|
||||
|
||||
// force light cursor up while typing
|
||||
textCursorIsOn = textCursorIsOn || ((1..254).any { Gdx.input.isKeyPressed(it) })
|
||||
// force light cursor up while typing -- only honour global key state when
|
||||
// this VM is the focused viewport; otherwise hidden VMs would react to
|
||||
// keypresses meant for the focused one.
|
||||
if (Gdx.input.inputProcessor === vm.getIO()) {
|
||||
textCursorIsOn = textCursorIsOn || ((1..254).any { Gdx.input.isKeyPressed(it) })
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import java.net.URL
|
||||
*/
|
||||
class HttpModem(private val vm: VM, private val artificialDelayBlockSize: Int = 1024, private val artificialDelayWaitTime: Int = -1) : BlockTransferInterface(false, true) {
|
||||
|
||||
private val DBGPRN = true
|
||||
private val DBGPRN = false
|
||||
|
||||
private fun printdbg(msg: Any) {
|
||||
if (DBGPRN) println("[WgetModem] $msg")
|
||||
|
||||
@@ -3,6 +3,8 @@ package net.torvald.tsvm.peripheral
|
||||
import com.badlogic.gdx.Gdx
|
||||
import com.badlogic.gdx.Input
|
||||
import com.badlogic.gdx.InputProcessor
|
||||
import com.badlogic.gdx.math.Vector2
|
||||
import com.badlogic.gdx.utils.viewport.Viewport
|
||||
import net.torvald.AddressOverflowException
|
||||
import net.torvald.DanglingPointerException
|
||||
import net.torvald.UnsafeHelper
|
||||
@@ -10,6 +12,7 @@ import net.torvald.tsvm.CircularArray
|
||||
import net.torvald.tsvm.VM
|
||||
import net.torvald.tsvm.isNonZero
|
||||
import net.torvald.tsvm.toInt
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.experimental.and
|
||||
|
||||
class IOSpace(val vm: VM) : PeriBase("io"), InputProcessor {
|
||||
@@ -18,10 +21,25 @@ class IOSpace(val vm: VM) : PeriBase("io"), InputProcessor {
|
||||
return vm
|
||||
}
|
||||
|
||||
/** Absolute x-position of the computer GUI */
|
||||
var guiPosX = 0
|
||||
/** Absolute y-position of the computer GUI */
|
||||
var guiPosY = 0
|
||||
/**
|
||||
* Viewport that maps screen pixels (as reported by `Gdx.input.x/y`) to the VM's
|
||||
* logical framebuffer coordinate space. The host application owns the rendering
|
||||
* camera, so the host is responsible for installing a viewport whose world
|
||||
* coordinates match the VM framebuffer (origin top-left, world size = framebuffer
|
||||
* size in pixels) and whose screen rectangle matches where the VM is drawn.
|
||||
*
|
||||
* If left null, `Gdx.input.x/y` is forwarded verbatim — only correct when the VM
|
||||
* occupies the entire window at 1:1 scale.
|
||||
*/
|
||||
var inputViewport: Viewport? = null
|
||||
private val tmpMouseVec = Vector2()
|
||||
// Letterbox offset and renderable area inside the inputViewport, set by the host VMGUI.
|
||||
// After unproject, mouse pixel coords are shifted by (inputOriginX, inputOriginY) and
|
||||
// clamped to (inputAreaW, inputAreaH) so apps see VM-screen pixel coords (0..drawWidth).
|
||||
var inputOriginX: Int = 0
|
||||
var inputOriginY: Int = 0
|
||||
var inputAreaW: Int = Int.MAX_VALUE
|
||||
var inputAreaH: Int = Int.MAX_VALUE
|
||||
|
||||
/** Accepts a keycode */
|
||||
private val keyboardBuffer = CircularArray<Byte>(32, true)
|
||||
@@ -98,7 +116,12 @@ class IOSpace(val vm: VM) : PeriBase("io"), InputProcessor {
|
||||
in 0..31 -> keyboardBuffer[(addr.toInt())] ?: -1
|
||||
in 32..33 -> (mouseX.toInt() shr (adi - 32).times(8)).toByte()
|
||||
in 34..35 -> (mouseY.toInt() shr (adi - 34).times(8)).toByte()
|
||||
36L -> mouseDown.toInt().toByte()
|
||||
36L -> {
|
||||
// bit 0: left, bit 1: right, bit 2: middle, bit 6: wheel up, bit 7: wheel down
|
||||
// Wheel bits are latched on scrolled() and cleared on read so a one-shot
|
||||
// detent fires exactly once for the polling app.
|
||||
(mouseButtons or wheelLatch.getAndSet(0)).toByte()
|
||||
}
|
||||
37L -> {
|
||||
val key = keyboardBuffer.removeTail() ?: -1
|
||||
keyPushed = !keyboardBuffer.isEmpty // Clear flag when buffer becomes empty
|
||||
@@ -280,29 +303,60 @@ class IOSpace(val vm: VM) : PeriBase("io"), InputProcessor {
|
||||
|
||||
private var mouseX: Short = 0
|
||||
private var mouseY: Short = 0
|
||||
private var mouseDown = false
|
||||
private var mouseButtons: Int = 0 // bit 0 = LEFT, bit 1 = RIGHT, bit 2 = MIDDLE
|
||||
// bits 6 (wheel up) and 7 (wheel down) — set by scrolled(), cleared on MMIO[36] read
|
||||
private val wheelLatch = AtomicInteger(0)
|
||||
private var systemUptime = 0L
|
||||
private var rtc = 0L
|
||||
|
||||
fun update(delta: Float) {
|
||||
// Only the VM whose IOSpace is wired up as the active InputProcessor (i.e. the
|
||||
// currently focused viewport) may observe global keyboard/mouse state. Otherwise
|
||||
// hidden VMs would all see the same keypresses as the focused one.
|
||||
val isFocused = Gdx.input.inputProcessor === this
|
||||
|
||||
if (rawInputFunctionLatched) {
|
||||
rawInputFunctionLatched = false
|
||||
|
||||
// store mouse info
|
||||
mouseX = (Gdx.input.x + guiPosX).toShort()
|
||||
mouseY = (Gdx.input.y + guiPosY).toShort()
|
||||
mouseDown = Gdx.input.isTouched
|
||||
|
||||
// strobe keys to fill the key read buffer
|
||||
var keysPushed = 0
|
||||
keyEventBuffers.fill(0)
|
||||
for (k in 1..254) {
|
||||
if (Gdx.input.isKeyPressed(k)) {
|
||||
keyEventBuffers[keysPushed] = k.toByte()
|
||||
keysPushed += 1
|
||||
}
|
||||
|
||||
if (keysPushed >= 8) break
|
||||
if (isFocused) {
|
||||
// store mouse info; unproject through the host-provided viewport so the
|
||||
// VM sees logical framebuffer pixels regardless of window magnification,
|
||||
// letterboxing or sub-region placement done by an embedding GDX app.
|
||||
val vp = inputViewport
|
||||
val rawX: Int
|
||||
val rawY: Int
|
||||
if (vp != null) {
|
||||
tmpMouseVec.set(Gdx.input.x.toFloat(), Gdx.input.y.toFloat())
|
||||
vp.unproject(tmpMouseVec)
|
||||
rawX = tmpMouseVec.x.toInt()
|
||||
rawY = tmpMouseVec.y.toInt()
|
||||
}
|
||||
else {
|
||||
rawX = Gdx.input.x
|
||||
rawY = Gdx.input.y
|
||||
}
|
||||
// Subtract the letterbox origin so apps see VM-screen pixel coords (0..drawWidth).
|
||||
mouseX = (rawX - inputOriginX).coerceIn(0, inputAreaW - 1).toShort()
|
||||
mouseY = (rawY - inputOriginY).coerceIn(0, inputAreaH - 1).toShort()
|
||||
mouseButtons = (if (Gdx.input.isButtonPressed(Input.Buttons.LEFT)) 1 else 0) or
|
||||
(if (Gdx.input.isButtonPressed(Input.Buttons.RIGHT)) 2 else 0) or
|
||||
(if (Gdx.input.isButtonPressed(Input.Buttons.MIDDLE)) 4 else 0)
|
||||
|
||||
// strobe keys to fill the key read buffer
|
||||
var keysPushed = 0
|
||||
for (k in 1..254) {
|
||||
if (Gdx.input.isKeyPressed(k)) {
|
||||
keyEventBuffers[keysPushed] = k.toByte()
|
||||
keysPushed += 1
|
||||
}
|
||||
|
||||
if (keysPushed >= 8) break
|
||||
}
|
||||
}
|
||||
else {
|
||||
mouseButtons = 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,26 +370,33 @@ class IOSpace(val vm: VM) : PeriBase("io"), InputProcessor {
|
||||
rtc = vm.worldInterface.currentTimeInMills()
|
||||
}
|
||||
|
||||
// SIGTERM key combination: Ctrl+Shift+T+R
|
||||
vm.stopDown = (Gdx.input.isKeyPressed(Input.Keys.SHIFT_LEFT) &&
|
||||
Gdx.input.isKeyPressed(Input.Keys.CONTROL_LEFT) &&
|
||||
Gdx.input.isKeyPressed(Input.Keys.T) &&
|
||||
Gdx.input.isKeyPressed(Input.Keys.R)) || Gdx.input.isKeyPressed(Input.Keys.PAUSE)
|
||||
if (vm.stopDown) println("[VM-${vm.id}] SIGTERM requested")
|
||||
if (isFocused) {
|
||||
// SIGTERM key combination: Ctrl+Shift+T+R
|
||||
vm.stopDown = (Gdx.input.isKeyPressed(Input.Keys.SHIFT_LEFT) &&
|
||||
Gdx.input.isKeyPressed(Input.Keys.CONTROL_LEFT) &&
|
||||
Gdx.input.isKeyPressed(Input.Keys.T) &&
|
||||
Gdx.input.isKeyPressed(Input.Keys.R)) || Gdx.input.isKeyPressed(Input.Keys.PAUSE)
|
||||
if (vm.stopDown) println("[VM-${vm.id}] SIGTERM requested")
|
||||
|
||||
// RESET key combination: Ctrl+Shift+R+S
|
||||
vm.resetDown = Gdx.input.isKeyPressed(Input.Keys.SHIFT_LEFT) &&
|
||||
Gdx.input.isKeyPressed(Input.Keys.CONTROL_LEFT) &&
|
||||
Gdx.input.isKeyPressed(Input.Keys.R) &&
|
||||
Gdx.input.isKeyPressed(Input.Keys.S)
|
||||
if (vm.resetDown) println("[VM-${vm.id}] RESET requested")
|
||||
// RESET key combination: Ctrl+Shift+R+S
|
||||
vm.resetDown = Gdx.input.isKeyPressed(Input.Keys.SHIFT_LEFT) &&
|
||||
Gdx.input.isKeyPressed(Input.Keys.CONTROL_LEFT) &&
|
||||
Gdx.input.isKeyPressed(Input.Keys.R) &&
|
||||
Gdx.input.isKeyPressed(Input.Keys.S)
|
||||
if (vm.resetDown) println("[VM-${vm.id}] RESET requested")
|
||||
|
||||
// SYSRQ key combination: Ctrl+Shift+S+Q
|
||||
vm.sysrqDown = (Gdx.input.isKeyPressed(Input.Keys.SHIFT_LEFT) &&
|
||||
Gdx.input.isKeyPressed(Input.Keys.CONTROL_LEFT) &&
|
||||
Gdx.input.isKeyPressed(Input.Keys.Q) &&
|
||||
Gdx.input.isKeyPressed(Input.Keys.S)) || Gdx.input.isKeyPressed(Input.Keys.PRINT_SCREEN)
|
||||
if (vm.sysrqDown) println("[VM-${vm.id}] SYSRQ requested")
|
||||
// SYSRQ key combination: Ctrl+Shift+S+Q
|
||||
vm.sysrqDown = (Gdx.input.isKeyPressed(Input.Keys.SHIFT_LEFT) &&
|
||||
Gdx.input.isKeyPressed(Input.Keys.CONTROL_LEFT) &&
|
||||
Gdx.input.isKeyPressed(Input.Keys.Q) &&
|
||||
Gdx.input.isKeyPressed(Input.Keys.S)) || Gdx.input.isKeyPressed(Input.Keys.PRINT_SCREEN)
|
||||
if (vm.sysrqDown) println("[VM-${vm.id}] SYSRQ requested")
|
||||
}
|
||||
else {
|
||||
vm.stopDown = false
|
||||
vm.resetDown = false
|
||||
vm.sysrqDown = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun touchUp(p0: Int, p1: Int, p2: Int, p3: Int): Boolean {
|
||||
@@ -358,8 +419,15 @@ class IOSpace(val vm: VM) : PeriBase("io"), InputProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
override fun scrolled(p0: Float, p1: Float): Boolean {
|
||||
return false
|
||||
override fun scrolled(amountX: Float, amountY: Float): Boolean {
|
||||
// LibGDX: amountY > 0 = scroll DOWN (toward user), amountY < 0 = scroll UP.
|
||||
// Latch bits 6/7 of MMIO[36]; the latch is cleared the next time MMIO[36] is read.
|
||||
if (Gdx.input.inputProcessor !== this) return false
|
||||
when {
|
||||
amountY < 0f -> wheelLatch.updateAndGet { it or 0x40 }
|
||||
amountY > 0f -> wheelLatch.updateAndGet { it or 0x80 }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
override fun keyUp(p0: Int): Boolean {
|
||||
|
||||
@@ -43,6 +43,7 @@ package net.torvald.tsvm.peripheral
|
||||
|
||||
import net.torvald.terrarum.modulecomputers.virtualcomputer.tvd.toUint
|
||||
import net.torvald.tsvm.VM
|
||||
import net.torvald.tsvm.memAddrToReadable
|
||||
import java.util.*
|
||||
import kotlin.collections.ArrayList
|
||||
import kotlin.math.ceil
|
||||
@@ -398,7 +399,7 @@ class MP2Env(val vm: VM) {
|
||||
};
|
||||
// check for valid header: syncword OK, MPEG-Audio Layer 2
|
||||
if ((syspeek(mp2_frame!!) != 0xFF) || ((syspeek(mp2_frame!! + 1*incr) and 0xFE) != 0xFC)){
|
||||
throw Error("Invalid MP2 header at $mp2_frame: ${syspeek(mp2_frame!!).toString(16)} ${syspeek(mp2_frame!! + 1*incr).toString(16)}")
|
||||
throw Error("Invalid MP2 header at ${(mp2_frame as Long).memAddrToReadable()}: ${syspeek(mp2_frame!!).toString(16)} ${syspeek(mp2_frame!! + 1*incr).toString(16)}")
|
||||
};
|
||||
|
||||
// set up the bitstream reader
|
||||
|
||||
@@ -561,7 +561,10 @@ class TestDiskDrive(private val vm: VM, private val driveNum: Int, theRootPath:
|
||||
statusCode.set(STATE_CODE_STANDBY)
|
||||
}
|
||||
else if (inputString.startsWith("USAGE")) {
|
||||
recipient?.writeout(composePositiveAns("USED123456/TOTAL654321"))
|
||||
val used = rootPath.walkTopDown().filter { it.isFile }.map { it.length() }.sum()
|
||||
.coerceIn(0L, Int.MAX_VALUE.toLong())
|
||||
val total = rootPath.totalSpace.coerceIn(0L, Int.MAX_VALUE.toLong())
|
||||
recipient?.writeout(composePositiveAns("USED$used/TOTAL$total"))
|
||||
statusCode.set(STATE_CODE_STANDBY)
|
||||
}
|
||||
else
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
@@ -12,5 +12,7 @@
|
||||
<orderEntry type="library" name="jetbrains.kotlin.reflect" level="project" />
|
||||
<orderEntry type="library" name="jetbrains.kotlin.test" level="project" />
|
||||
<orderEntry type="library" name="lib" level="project" />
|
||||
<orderEntry type="library" name="badlogicgames.gdx" level="project" />
|
||||
<orderEntry type="library" name="badlogicgames.gdx.backend.lwjgl3" level="project" />
|
||||
</component>
|
||||
</module>
|
||||
@@ -10,5 +10,7 @@
|
||||
<orderEntry type="library" name="TerranVirtualDisk" level="project" />
|
||||
<orderEntry type="module" module-name="tsvm_core" />
|
||||
<orderEntry type="library" name="lib" level="project" />
|
||||
<orderEntry type="library" name="badlogicgames.gdx" level="project" />
|
||||
<orderEntry type="library" name="badlogicgames.gdx.backend.lwjgl3" level="project" />
|
||||
</component>
|
||||
</module>
|
||||
@@ -54,8 +54,8 @@ public class AppLoader {
|
||||
|
||||
|
||||
ArrayList defaultPeripherals = new ArrayList();
|
||||
defaultPeripherals.add(new Pair(3, new PeripheralEntry2("net.torvald.tsvm.peripheral.AudioAdapter", vm)));
|
||||
defaultPeripherals.add(new Pair(4, new PeripheralEntry2("net.torvald.tsvm.peripheral.HostFileHSDPA", vm, "assets/diskMediabin/lnterz_013.mv2", "assets/diskMediabin/ba60d.mov", "", "", 999999999L)));
|
||||
defaultPeripherals.add(new Pair(2, new PeripheralEntry2("net.torvald.tsvm.peripheral.AudioAdapter", vm)));
|
||||
defaultPeripherals.add(new Pair(3, new PeripheralEntry2("net.torvald.tsvm.peripheral.HostFileHSDPA", vm, "assets/diskMediabin/lnterz_013.mv2", "assets/diskMediabin/ba60d.mov", "", "", 999999999L)));
|
||||
|
||||
|
||||
EmulInstance reference = new EmulInstance(vm, "net.torvald.tsvm.peripheral.ReferenceGraphicsAdapter", diskPath, 560, 448, defaultPeripherals);
|
||||
|
||||
@@ -8,7 +8,10 @@ import com.badlogic.gdx.graphics.Color
|
||||
import com.badlogic.gdx.graphics.g2d.SpriteBatch
|
||||
import net.torvald.reflection.extortField
|
||||
import net.torvald.terrarum.modulecomputers.virtualcomputer.tvd.toUint
|
||||
import net.torvald.tsvm.EmulatorGuiToolkit.Theme.COL_ACTIVE
|
||||
import net.torvald.tsvm.EmulatorGuiToolkit.Theme.COL_ACTIVE2
|
||||
import net.torvald.tsvm.EmulatorGuiToolkit.Theme.COL_ACTIVE3
|
||||
import net.torvald.tsvm.EmulatorGuiToolkit.Theme.COL_HIGHLIGHT
|
||||
import net.torvald.tsvm.EmulatorGuiToolkit.Theme.COL_HIGHLIGHT2
|
||||
import net.torvald.tsvm.EmulatorGuiToolkit.Theme.COL_WELL
|
||||
import net.torvald.tsvm.VMEmuExecutableWrapper.Companion.FONT
|
||||
@@ -18,6 +21,7 @@ import java.util.BitSet
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.ln
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
@@ -25,9 +29,25 @@ import kotlin.math.roundToInt
|
||||
*/
|
||||
class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMenu(parent, x, y, w, h) {
|
||||
|
||||
// Per-playhead view mode: 0=detailed pattern, 1=abridged pattern (stub), 2=super-abridged (stub), 3=cuesheet detail
|
||||
private val scopeMode = IntArray(4)
|
||||
// Per-playhead view mode: 0=detailed pattern, 1=abridged pattern (stub), 2=super-abridged (stub),
|
||||
// 3=cuesheet detail, 4=per-voice waveform
|
||||
private val scopeMode = IntArray(4) { 4 }
|
||||
private val scopeScrollHorz = IntArray(4)
|
||||
private val SCOPE_MODE_COUNT = 5
|
||||
|
||||
// Which playhead the big scope is showing. Status-panel clicks change this.
|
||||
private var selectedPlayhead = 0
|
||||
|
||||
// Layout — one big scope on top, four status panels along the bottom.
|
||||
private val bigScopeX = 7
|
||||
private val bigScopeY = 5
|
||||
private val bigScopeW = 622
|
||||
private val bigScopeH = 336
|
||||
private val statusW = 102
|
||||
private val statusH = 8 * FONT.H + 4
|
||||
private val statusY = bigScopeY + bigScopeH + 4
|
||||
// Spread the four status panels evenly across the big-scope width.
|
||||
private fun statusX(i: Int): Int = bigScopeX + i * (bigScopeW - statusW) / 3
|
||||
|
||||
override fun show() {
|
||||
}
|
||||
@@ -38,96 +58,71 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
private var guiClickLatched = arrayOf(false, false, false, false, false, false, false, false)
|
||||
private var guiKeypressLatched = BitSet(256)
|
||||
|
||||
private fun panelAtMouse(mx: Int, my: Int): Int {
|
||||
if (my !in statusY until (statusY + statusH)) return -1
|
||||
for (i in 0..3) {
|
||||
val sx = statusX(i)
|
||||
if (mx in sx until (sx + statusW)) return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
private fun mouseInBigScope(mx: Int, my: Int): Boolean =
|
||||
mx in bigScopeX until (bigScopeX + bigScopeW) &&
|
||||
my in bigScopeY until (bigScopeY + bigScopeH)
|
||||
|
||||
override fun update() {
|
||||
// mouse clicks
|
||||
val mx = Gdx.input.x - x
|
||||
val my = Gdx.input.y - y
|
||||
|
||||
// ── LEFT click ─────────────────────────────────────────────────────────────
|
||||
// On a status panel: select that playhead as the big-scope target.
|
||||
// On the big scope: cycle scope mode forward for the selected playhead.
|
||||
if (Gdx.input.isButtonPressed(Buttons.LEFT)) {
|
||||
if (!guiClickLatched[Buttons.LEFT]) {
|
||||
val mx = Gdx.input.x - x
|
||||
val my = Gdx.input.y - y
|
||||
|
||||
if (mx in 117..629) {
|
||||
for (i in 0..3) {
|
||||
val syTop = h - 7 - 115 * i - 8 * FONT.H
|
||||
val syBot = h - 3 - 115 * i
|
||||
if (my in syTop..syBot) {
|
||||
scopeMode[3 - i] = (scopeMode[3 - i] + 1) and 3
|
||||
break
|
||||
}
|
||||
}
|
||||
val panel = panelAtMouse(mx, my)
|
||||
if (panel >= 0) {
|
||||
selectedPlayhead = panel
|
||||
} else if (mouseInBigScope(mx, my)) {
|
||||
scopeMode[selectedPlayhead] =
|
||||
(scopeMode[selectedPlayhead] + 1) % SCOPE_MODE_COUNT
|
||||
}
|
||||
|
||||
guiClickLatched[Buttons.LEFT] = true
|
||||
}
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
guiClickLatched[Buttons.LEFT] = false
|
||||
}
|
||||
|
||||
// ── RIGHT click on the big scope: cycle scope mode backward. ────────────────
|
||||
if (Gdx.input.isButtonPressed(Buttons.RIGHT)) {
|
||||
if (!guiClickLatched[Buttons.RIGHT]) {
|
||||
val mx = Gdx.input.x - x
|
||||
val my = Gdx.input.y - y
|
||||
|
||||
if (mx in 117..629) {
|
||||
for (i in 0..3) {
|
||||
val syTop = h - 7 - 115 * i - 8 * FONT.H
|
||||
val syBot = h - 3 - 115 * i
|
||||
if (my in syTop..syBot) {
|
||||
scopeMode[3 - i] = (scopeMode[3 - i] - 1) and 3
|
||||
break
|
||||
}
|
||||
}
|
||||
if (mouseInBigScope(mx, my)) {
|
||||
scopeMode[selectedPlayhead] =
|
||||
(scopeMode[selectedPlayhead] + SCOPE_MODE_COUNT - 1) % SCOPE_MODE_COUNT
|
||||
}
|
||||
|
||||
guiClickLatched[Buttons.RIGHT] = true
|
||||
}
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
guiClickLatched[Buttons.RIGHT] = false
|
||||
}
|
||||
|
||||
// keyboard left/right
|
||||
// ── Keyboard left/right: scroll the selected playhead's pattern view. ───────
|
||||
if (Gdx.input.isKeyPressed(Input.Keys.LEFT)) {
|
||||
if (!guiKeypressLatched[Input.Keys.LEFT]) {
|
||||
val mx = Gdx.input.x - x
|
||||
val my = Gdx.input.y - y
|
||||
|
||||
if (mx in 117..629) {
|
||||
for (i in 0..3) {
|
||||
val syTop = h - 7 - 115 * i - 8 * FONT.H
|
||||
val syBot = h - 3 - 115 * i
|
||||
if (my in syTop..syBot) {
|
||||
scopeScrollHorz[3 - i] = (scopeScrollHorz[3 - i] - 1).coerceIn(0, 14)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scopeScrollHorz[selectedPlayhead] =
|
||||
(scopeScrollHorz[selectedPlayhead] - 1).coerceIn(0, 14)
|
||||
guiKeypressLatched[Input.Keys.LEFT] = true
|
||||
}
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
guiKeypressLatched[Input.Keys.LEFT] = false
|
||||
}
|
||||
if (Gdx.input.isKeyPressed(Input.Keys.RIGHT)) {
|
||||
if (!guiKeypressLatched[Input.Keys.RIGHT]) {
|
||||
val mx = Gdx.input.x - x
|
||||
val my = Gdx.input.y - y
|
||||
|
||||
if (mx in 117..629) {
|
||||
for (i in 0..3) {
|
||||
val syTop = h - 7 - 115 * i - 8 * FONT.H
|
||||
val syBot = h - 3 - 115 * i
|
||||
if (my in syTop..syBot) {
|
||||
scopeScrollHorz[3 - i] = (scopeScrollHorz[3 - i] + 1).coerceIn(0, 14)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scopeScrollHorz[selectedPlayhead] =
|
||||
(scopeScrollHorz[selectedPlayhead] + 1).coerceIn(0, 14)
|
||||
guiKeypressLatched[Input.Keys.RIGHT] = true
|
||||
}
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
guiKeypressLatched[Input.Keys.RIGHT] = false
|
||||
}
|
||||
}
|
||||
@@ -167,27 +162,32 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
val adev = parent.currentlyPersistentVM?.vm?.peripheralTable?.getOrNull(cardIndex ?: -1)?.peripheral as? AudioAdapter
|
||||
|
||||
if (adev != null) {
|
||||
val playheads = adev.extortField<Array<AudioAdapter.Playhead>>("playheads")!!
|
||||
|
||||
// draw status LCD
|
||||
// ── Big scope background (row 1) and status-panel backgrounds (row 2) ─────
|
||||
batch.inUse {
|
||||
// draw backgrounds
|
||||
batch.color = COL_WELL
|
||||
for (i in 0..3) { batch.fillRect(7, 5 + 115*i, 102, 8*FONT.H + 4) }
|
||||
}
|
||||
for (i in 0..3) {
|
||||
val ahead = adev.extortField<Array<AudioAdapter.Playhead>>("playheads")!![i]
|
||||
drawStatusLCD(adev, ahead, batch, i, 9f + 7, 7f + 7 + 115 * i)
|
||||
}
|
||||
|
||||
// draw Soundscope like this so that the overflown queue sparkline would not be overlaid on top of the envelopes
|
||||
batch.inUse {
|
||||
// draw backgrounds
|
||||
batch.color = COL_SOUNDSCOPE_BACK
|
||||
for (i in 0..3) { batch.fillRect(117, 5 + 115*i, 512, 8*FONT.H + 4) }
|
||||
batch.fillRect(bigScopeX, bigScopeY, bigScopeW, bigScopeH)
|
||||
|
||||
// Highlight border behind the selected status panel.
|
||||
batch.color = COL_ACTIVE
|
||||
val selX = statusX(selectedPlayhead)
|
||||
batch.fillRect(selX - 2, statusY - 2, statusW + 4, statusH + 4)
|
||||
|
||||
batch.color = COL_WELL
|
||||
for (i in 0..3) batch.fillRect(statusX(i), statusY, statusW, statusH)
|
||||
}
|
||||
|
||||
// ── Big scope contents — only the selected playhead ────────────────────────
|
||||
drawSoundscope(adev, playheads[selectedPlayhead], batch, selectedPlayhead,
|
||||
bigScopeX.toFloat(), bigScopeY.toFloat(), bigScopeW, bigScopeH)
|
||||
|
||||
// ── All four status LCDs along the bottom ──────────────────────────────────
|
||||
// Use the same (9, 9) inset from the panel as the original layout, so the
|
||||
// existing label-positioning math inside drawStatusLCD still fits cleanly.
|
||||
for (i in 0..3) {
|
||||
val ahead = adev.extortField<Array<AudioAdapter.Playhead>>("playheads")!![i]
|
||||
drawSoundscope(adev, ahead, batch, i, 117f, 5f + 115 * i)
|
||||
drawStatusLCD(adev, playheads[i], batch, i,
|
||||
statusX(i).toFloat() + 9f, statusY.toFloat() + 9f)
|
||||
}
|
||||
}
|
||||
else {
|
||||
@@ -203,11 +203,16 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
// NOTE: Samples count for PCM mode is drawn by drawSoundscope() function, not this one!
|
||||
|
||||
batch.inUse {
|
||||
// "P{n+1}" tag — bright on the selected playhead so the panel-as-button
|
||||
// affordance is obvious.
|
||||
batch.color = if (index == selectedPlayhead) COL_ACTIVE else Color.WHITE
|
||||
FONT.draw(batch, "P${index + 1}", x, y)
|
||||
|
||||
batch.color = Color.WHITE
|
||||
// PLAY icon
|
||||
// PLAY icon (shifted right to make room for the playhead tag)
|
||||
if (ahead.isPlaying)
|
||||
FONT.draw(batch, STR_PLAY, x, y)
|
||||
FONT.draw(batch, if (ahead.isPcmMode) "PCM" else "TRACKER", x + 21, y)
|
||||
FONT.draw(batch, STR_PLAY, x + 21, y)
|
||||
FONT.draw(batch, if (ahead.isPcmMode) "PCM" else "TRACKER", x + 42, y)
|
||||
|
||||
// PCM Mode labels
|
||||
if (ahead.isPcmMode) {
|
||||
@@ -238,7 +243,7 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
FONT.draw(batch, "Tickrate", x, y + 6*FONT.H)
|
||||
|
||||
batch.color = COL_ACTIVE3
|
||||
FONT.drawRalign(batch, "${ahead.trackerState?.cuePos?.toString(16)?.uppercase()?.padStart(2,'0')}:${ahead.trackerState?.rowIndex?.toString()?.uppercase()?.padStart(2,'0')}", x + 84, y + 2*FONT.H)
|
||||
FONT.drawRalign(batch, "${ahead.trackerState?.cuePos?.toString(16)?.uppercase()?.padStart(3,'0')}:${ahead.trackerState?.rowIndex?.toString()?.uppercase()?.padStart(2,'0')}", x + 84, y + 2*FONT.H)
|
||||
FONT.drawRalign(batch, "${ahead.masterVolume}", x + 84, y + 3*FONT.H)
|
||||
FONT.drawRalign(batch, "${ahead.masterPan}", x + 84, y + 4*FONT.H)
|
||||
FONT.drawRalign(batch, "${ahead.bpm}", x + 84, y + 5*FONT.H)
|
||||
@@ -261,58 +266,125 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
private fun bipolarCeil(d: Double) = (if (d >= 0.0) ceil(d) else floor(d)).toInt()
|
||||
private fun bipolarFloor(d: Double) = (if (d >= 0.0) floor(d) else ceil(d)).toInt()
|
||||
|
||||
private val VOX_PER_VIEW = arrayOf(6,20,20)
|
||||
/**
|
||||
* Find the most-recent rising-edge zero crossing in [buf] that has at least
|
||||
* [cellW]/2 samples of context on either side, and return its position as a
|
||||
* sub-sample-accurate "age" (samples since the oldest sample at [writePos]).
|
||||
* Returns -1.0 if no usable crossing exists — the caller should then fall back
|
||||
* to a free-running display.
|
||||
*/
|
||||
private fun findTriggerAge(buf: FloatArray, writePos: Int, cellW: Int): Double {
|
||||
val bufSize = buf.size
|
||||
val mask = bufSize - 1
|
||||
val halfW = cellW / 2
|
||||
val maxAge = bufSize - halfW // exclusive: rightmost trigger that still has cellW/2 right-side samples
|
||||
val minAge = halfW // inclusive: leftmost trigger that still has cellW/2 left-side samples
|
||||
if (maxAge - 1 <= minAge) return -1.0 // cell is too wide vs the buffer
|
||||
|
||||
// Walk newest → oldest within the search window. The most-recent crossing gives
|
||||
// the freshest snapshot on the right of the trigger, so the eye sees the least lag.
|
||||
var newer = buf[(writePos + maxAge - 1) and mask]
|
||||
for (age in maxAge - 2 downTo minAge) {
|
||||
val older = buf[(writePos + age) and mask]
|
||||
if (older < 0f && newer >= 0f) {
|
||||
// Linear interpolation between the two bracketing samples.
|
||||
val denom = (newer - older)
|
||||
val frac = if (denom > 1e-9f) (-older) / denom else 0f
|
||||
return age + frac.toDouble()
|
||||
}
|
||||
newer = older
|
||||
}
|
||||
return -1.0
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a cols × rows grid for `n` waveform cells inside an `areaW × areaH` box.
|
||||
* Optimises for cell aspect close to [targetAspect] (in log-space, so 6:1 and 1.5:1
|
||||
* are penalised equally relative to 3:1) and lightly penalises wasted cells. Wide
|
||||
* scope areas naturally get more columns than rows; tall ones flip the other way.
|
||||
*/
|
||||
private fun pickWaveformGrid(n: Int, areaW: Int, areaH: Int): IntArray {
|
||||
val targetAspect = 3.0
|
||||
val wastePenalty = 0.3
|
||||
var bestCols = 1
|
||||
var bestRows = n
|
||||
var bestScore = Double.POSITIVE_INFINITY
|
||||
for (cols in 1..n) {
|
||||
val rows = (n + cols - 1) / cols
|
||||
val cellW = areaW.toDouble() / cols
|
||||
val cellH = areaH.toDouble() / rows
|
||||
val aspect = cellW / cellH
|
||||
val score = abs(ln(aspect / targetAspect)) + wastePenalty * (cols * rows - n)
|
||||
if (score < bestScore) {
|
||||
bestScore = score
|
||||
bestCols = cols
|
||||
bestRows = rows
|
||||
}
|
||||
}
|
||||
return intArrayOf(bestCols, bestRows)
|
||||
}
|
||||
|
||||
private val VOX_PER_VIEW = arrayOf(10,20,20)
|
||||
private val VOL_SYM = arrayOf('@','^','&',' ')
|
||||
private val PAN_SYM = arrayOf('@','<','>',' ')
|
||||
|
||||
private fun drawSoundscope(audio: AudioAdapter, ahead: AudioAdapter.Playhead, batch: SpriteBatch, index: Int, x: Float, y: Float) {
|
||||
private fun drawSoundscope(audio: AudioAdapter, ahead: AudioAdapter.Playhead, batch: SpriteBatch, index: Int, x: Float, y: Float, w: Int, h: Int) {
|
||||
val gdxadev = ahead.audioDevice
|
||||
val bytes = gdxadev.extortField<ByteArray>("bytes")
|
||||
val bytesLen = gdxadev.extortField<Int>("bytesLength")!!
|
||||
val envelopeHalfHeight = 27
|
||||
val envelopeHalfHeight = h / 4
|
||||
val lCenterY = h / 4
|
||||
val rCenterY = 3 * h / 4
|
||||
val patOffY = 0
|
||||
|
||||
batch.inUse {
|
||||
if (ahead.isPcmMode && bytes != null) {
|
||||
val smpCnt = bytesLen / 4 - 1
|
||||
|
||||
for (s in 0..511) {
|
||||
val i = (smpCnt * (s / 511.0)).roundToInt().and(0xfffffe)
|
||||
try {
|
||||
for (s in 0 until w) {
|
||||
val i = (smpCnt * (s / (w - 1).toDouble())).roundToInt().and(0xfffffe)
|
||||
|
||||
val smpL = (bytes[i*4].toUint() or bytes[i*4+1].toUint().shl(8)).u16Tos16().toDouble().div(32767)
|
||||
val smpR = (bytes[i*4+2].toUint() or bytes[i*4+3].toUint().shl(8)).u16Tos16().toDouble().div(32767)
|
||||
val smpL =
|
||||
(bytes[i * 4].toUint() or bytes[i * 4 + 1].toUint().shl(8)).u16Tos16().toDouble().div(32767)
|
||||
val smpR = (bytes[i * 4 + 2].toUint() or bytes[i * 4 + 3].toUint().shl(8)).u16Tos16().toDouble()
|
||||
.div(32767)
|
||||
|
||||
val smpLH = smpL * envelopeHalfHeight
|
||||
val smpRH = smpR * envelopeHalfHeight
|
||||
val smpLH = smpL * envelopeHalfHeight
|
||||
val smpRH = smpR * envelopeHalfHeight
|
||||
|
||||
val smpLHi = bipolarFloor(smpLH)
|
||||
val smpRHi = bipolarFloor(smpRH)
|
||||
val smpLHi2 = bipolarCeil(smpLH)
|
||||
val smpRHi2 = bipolarCeil(smpRH)
|
||||
val smpLHi = bipolarFloor(smpLH)
|
||||
val smpRHi = bipolarFloor(smpRH)
|
||||
val smpLHi2 = bipolarCeil(smpLH)
|
||||
val smpRHi2 = bipolarCeil(smpRH)
|
||||
|
||||
val smpLHe = abs(smpLH - smpLHi).toFloat()
|
||||
val smpRHe = abs(smpRH - smpRHi).toFloat()
|
||||
val smpLHe = abs(smpLH - smpLHi).toFloat()
|
||||
val smpRHe = abs(smpRH - smpRHi).toFloat()
|
||||
|
||||
// antialias in y-axis
|
||||
if (smpLHi != smpLHi2) {
|
||||
batch.color = COL_SOUNDSCOPE_FORE.cpy().mul(smpLHe)
|
||||
batch.fillRect(x + s, y + 27, 1, smpLHi2)
|
||||
}
|
||||
if (smpRHi != smpRHi2) {
|
||||
batch.color = COL_SOUNDSCOPE_FORE.cpy().mul(smpRHe)
|
||||
batch.fillRect(x + s, y + 81, 1, smpRHi2)
|
||||
// antialias in y-axis
|
||||
if (smpLHi != smpLHi2) {
|
||||
batch.color = COL_SOUNDSCOPE_FORE.cpy().mul(smpLHe)
|
||||
batch.fillRect(x + s, y + lCenterY, 1, smpLHi2)
|
||||
}
|
||||
if (smpRHi != smpRHi2) {
|
||||
batch.color = COL_SOUNDSCOPE_FORE.cpy().mul(smpRHe)
|
||||
batch.fillRect(x + s, y + rCenterY, 1, smpRHi2)
|
||||
}
|
||||
|
||||
// base texture
|
||||
batch.color = COL_SOUNDSCOPE_FORE
|
||||
batch.fillRect(x + s, y + lCenterY, 1, smpLHi)
|
||||
batch.fillRect(x + s, y + rCenterY, 1, smpRHi)
|
||||
}
|
||||
|
||||
// base texture
|
||||
batch.color = COL_SOUNDSCOPE_FORE
|
||||
batch.fillRect(x + s, y + 27, 1, smpLHi)
|
||||
batch.fillRect(x + s, y + 81, 1, smpRHi)
|
||||
// PCM Samples count — drawn inside the scope (top-left) since the status
|
||||
// panels no longer sit beside it in the new single-scope layout.
|
||||
batch.color = Color.WHITE
|
||||
FONT.draw(batch, "Samples", x + 4, y + patOffY)
|
||||
batch.color = COL_ACTIVE3
|
||||
FONT.draw(batch, "${smpCnt + 1}", x + 4 + 8 * FONT.W, y + patOffY)
|
||||
}
|
||||
|
||||
batch.color = Color.WHITE
|
||||
FONT.draw(batch, "Samples", x - 101, y + 5*FONT.H + 9)
|
||||
batch.color = COL_ACTIVE3
|
||||
FONT.drawRalign(batch, "${smpCnt+1}", x - 17, y + 5*FONT.H + 9)
|
||||
|
||||
catch (_: ArrayIndexOutOfBoundsException) {}
|
||||
}
|
||||
else {
|
||||
// Tracker pattern visualiser.
|
||||
@@ -320,11 +392,13 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
val ts = ahead.trackerState
|
||||
if (ts == null) {
|
||||
batch.color = COL_SOUNDSCOPE_FORE
|
||||
FONT.draw(batch, "No tracker state", x, y + 4)
|
||||
FONT.draw(batch, "No tracker state", x, y + patOffY)
|
||||
} else {
|
||||
val cuePos = ts.cuePos
|
||||
val rowIdx = ts.rowIndex
|
||||
val ROWS = 17
|
||||
// Rows scale with available height — the original 17-row layout was sized
|
||||
// for the old 108-pixel scope; the big scope can show many more rows.
|
||||
val ROWS = (h / TINY.H).coerceAtLeast(1)
|
||||
val PTN_MAX_ROWS = 63
|
||||
|
||||
when (scopeMode[index]) {
|
||||
@@ -338,11 +412,11 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
val ci = cueFirst + r
|
||||
if (ci > 1023) break
|
||||
val here = ci == cuePos
|
||||
val ry = y + 4 + r * TINY.H
|
||||
val ry = y + patOffY + r * TINY.H
|
||||
|
||||
if (here) {
|
||||
batch.color = COL_TRACKER_ROW
|
||||
batch.fillRect(x, ry, 512, TINY.H)
|
||||
batch.fillRect(x, ry, w, TINY.H)
|
||||
}
|
||||
|
||||
var cx = x
|
||||
@@ -376,6 +450,91 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mode 4: Per-voice waveform ───────────────────────────────────
|
||||
// Tile one waveform cell per "currently used" voice (cue-sheet
|
||||
// pattern number != 0xFFF). The soundscope area is wide and short,
|
||||
// so a cols × rows grid uses the space far better than a vertical
|
||||
// stack — pickWaveformGrid() picks a layout that keeps cells roughly
|
||||
// 3:1 wide while minimising empty slots.
|
||||
4 -> {
|
||||
val cuePats = IntArray(20) { vi -> readCuePat12(audio, cuePos, vi) }
|
||||
val activeVoiceIndices = (0 until 20).filter { cuePats[it] != 0xFFF }
|
||||
if (activeVoiceIndices.isEmpty()) {
|
||||
batch.color = COL_SOUNDSCOPE_FORE
|
||||
FONT.draw(batch, "No active voices", x, y + 4)
|
||||
} else {
|
||||
val scopeH = h
|
||||
val scopeW = w
|
||||
val n = activeVoiceIndices.size
|
||||
val grid = pickWaveformGrid(n, scopeW, scopeH)
|
||||
val cols = grid[0]
|
||||
val rows = grid[1]
|
||||
val cellW = scopeW / cols
|
||||
val cellH = scopeH / rows
|
||||
val halfH = ((cellH - 2) / 2).coerceAtLeast(1)
|
||||
val voices = ts.voices
|
||||
val drawLabel = cellH >= TINY.H + 1 && cellW >= 12
|
||||
|
||||
// Faint grid separators between cells.
|
||||
batch.color = COL_TRACKER_ROW
|
||||
for (r in 1 until rows) batch.fillRect(x, y + r * cellH, scopeW, 1)
|
||||
for (c in 1 until cols) batch.fillRect(x + c * cellW, y, 1, scopeH)
|
||||
|
||||
for ((slot, vi) in activeVoiceIndices.withIndex()) {
|
||||
val voice = voices.getOrNull(vi) ?: continue
|
||||
val col = slot % cols
|
||||
val row = slot / cols
|
||||
val cellX = x + col * cellW
|
||||
val cellY = y + row * cellH
|
||||
val centerY = cellY + cellH / 2
|
||||
|
||||
// baseline
|
||||
batch.color = COL_TRACKER_ROW
|
||||
batch.fillRect(cellX, centerY, cellW, 1)
|
||||
|
||||
// waveform — anchor the cell centre on the most recent
|
||||
// sub-sample-accurate rising-edge zero crossing so that
|
||||
// periodic signals appear stationary (oscilloscope trigger).
|
||||
// Falls back to a free-running, oldest→newest sweep when no
|
||||
// usable trigger is found (e.g. silent voice or sub-sub-Hz tone).
|
||||
batch.color = COL_VOICE_PALETTE[vi % COL_VOICE_PALETTE.size]
|
||||
val buf = voice.scopeBuffer
|
||||
val bufSize = buf.size
|
||||
val mask = bufSize - 1
|
||||
val writePos = voice.scopeWritePos
|
||||
val centerCol = cellW / 2
|
||||
val triggerAge = findTriggerAge(buf, writePos, cellW)
|
||||
val freeRunStep = (bufSize - 1).toDouble() / (cellW - 1).coerceAtLeast(1)
|
||||
for (sx in 0 until cellW) {
|
||||
val readAge = if (triggerAge >= 0.0)
|
||||
triggerAge + (sx - centerCol).toDouble()
|
||||
else
|
||||
sx * freeRunStep
|
||||
val baseAge = floor(readAge).toInt()
|
||||
val frac = (readAge - baseAge).toFloat()
|
||||
val a = buf[(writePos + baseAge) and mask]
|
||||
val b = buf[(writePos + baseAge + 1) and mask]
|
||||
val v = ((1f - frac) * a + frac * b).coerceIn(-1f, 1f)
|
||||
val h = (v * halfH).roundToInt()
|
||||
if (h == 0) {
|
||||
batch.fillRect(cellX + sx, centerY, 1, 1)
|
||||
} else if (h > 0) {
|
||||
batch.fillRect(cellX + sx, centerY, 1, h)
|
||||
} else {
|
||||
batch.fillRect(cellX + sx, centerY + h, 1, -h)
|
||||
}
|
||||
}
|
||||
|
||||
// voice index label (top-left of cell), only when there is room
|
||||
if (drawLabel) {
|
||||
batch.color = COL_VOICE_PALETTE[vi % COL_VOICE_PALETTE.size]
|
||||
TINY.draw(batch, (vi+1).toString().padStart(2, '0').uppercase(),
|
||||
cellX + 1, cellY + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mode 0: Detailed pattern with colour-coded fields ────────────
|
||||
// ── Mode 1: Abridged pattern with colour-coded fields ────────────
|
||||
// ── Mode 2: Super-abridged pattern with colour-coded fields ────────────
|
||||
@@ -395,12 +554,12 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
batch.color = if (here) Color.WHITE else COL_SOUNDSCOPE_FORE
|
||||
TINY.draw(batch,
|
||||
"${if (here) ">" else " "}${ci.toString(16).padStart(3, '0').uppercase()}",
|
||||
x, y + 4 + r * TINY.H)
|
||||
x, y + patOffY + r * TINY.H)
|
||||
}
|
||||
|
||||
// Vertical separator
|
||||
batch.color = COL_SOUNDSCOPE_FORE
|
||||
for (r in 0 until ROWS) TINY.draw(batch, "|", x + cueW, y + 4 + r * TINY.H)
|
||||
for (r in 0 until ROWS) TINY.draw(batch, "|", x + cueW, y + patOffY + r * TINY.H)
|
||||
*/
|
||||
|
||||
// Pattern index for each voice in current cue
|
||||
@@ -414,11 +573,11 @@ class AudioMenu(parent: VMEmuExecutable, x: Int, y: Int, w: Int, h: Int) : EmuMe
|
||||
val ri = rowFirst + r
|
||||
if (ri > PTN_MAX_ROWS) break
|
||||
val here = ri == rowIdx
|
||||
val ry = y + 4 + r * TINY.H
|
||||
val ry = y + patOffY + r * TINY.H
|
||||
|
||||
if (here) {
|
||||
batch.color = COL_TRACKER_ROW
|
||||
batch.fillRect(patX, ry, 512 - cueW - sepW, TINY.H)
|
||||
batch.fillRect(patX, ry, w - cueW - sepW, TINY.H)
|
||||
}
|
||||
|
||||
var cx = patX
|
||||
|
||||
@@ -90,6 +90,11 @@ class VMEmuExecutable(val windowWidth: Int, val windowHeight: Int, var panelsX:
|
||||
var vmRunners = HashMap<VmId, VMRunner>() // <VM's identifier, VMRunner>
|
||||
var coroutineJobs = HashMap<VmId, Thread>() // <VM's identifier, Job>
|
||||
|
||||
// Per-VM rising-edge latch for the RESET key combo (Ctrl+Shift+R+S). The reboot
|
||||
// only fires when the user releases the keys, otherwise we'd restart-spam every
|
||||
// frame while the combo is held.
|
||||
private val rebootLatched = HashMap<VmId, Boolean>()
|
||||
|
||||
internal val whatToDoOnVmExceptionQueue = ArrayList<() -> Unit>()
|
||||
|
||||
companion object {
|
||||
@@ -122,7 +127,9 @@ class VMEmuExecutable(val windowWidth: Int, val windowHeight: Int, var panelsX:
|
||||
internal fun moveView(oldIndex: Int, newIndex: Int?) {
|
||||
if (oldIndex != newIndex) {
|
||||
if (newIndex != null) {
|
||||
vms[newIndex] = vms[oldIndex]
|
||||
val moved = vms[oldIndex]
|
||||
vms[newIndex] = moved
|
||||
moved?.vm?.let { applyMouseInputMappingForPanel(it, newIndex) }
|
||||
}
|
||||
vms[oldIndex] = null
|
||||
}
|
||||
@@ -130,6 +137,28 @@ class VMEmuExecutable(val windowWidth: Int, val windowHeight: Int, var panelsX:
|
||||
|
||||
internal fun addVMtoView(vm: VM, profileName: String, index: Int) {
|
||||
vms[index] = VMRunnerInfo(vm, profileName)
|
||||
applyMouseInputMappingForPanel(vm, index)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the VM's IOSpace so the mouse pixels it sees are relative to its own
|
||||
* GPU framebuffer rather than the whole TsvmEmulator window. Each tiled VM
|
||||
* lives at panel (pposX, pposY) with a letterbox inside that panel, so the
|
||||
* offset is `panel origin + (panel size − GPU size) / 2`.
|
||||
*/
|
||||
private fun applyMouseInputMappingForPanel(vm: VM, panelIndex: Int) {
|
||||
val gpu = vm.peripheralTable.getOrNull(1)?.peripheral as? GraphicsAdapter ?: return
|
||||
val pposX = panelIndex % panelsX
|
||||
val pposY = panelIndex / panelsX
|
||||
val gpuW = gpu.config.width
|
||||
val gpuH = gpu.config.height
|
||||
val io = vm.getIO()
|
||||
// TsvmEmulator draws at 1:1 pixel scale, so no GDX viewport is needed.
|
||||
io.inputViewport = null
|
||||
io.inputOriginX = pposX * windowWidth + (windowWidth - gpuW) / 2
|
||||
io.inputOriginY = pposY * windowHeight + (windowHeight - gpuH) / 2
|
||||
io.inputAreaW = gpuW
|
||||
io.inputAreaH = gpuH
|
||||
}
|
||||
|
||||
internal fun getCurrentlySelectedVM(): VMRunnerInfo? = if (currentVMselection == null) null else vms[currentVMselection!!]
|
||||
@@ -196,6 +225,7 @@ class VMEmuExecutable(val windowWidth: Int, val windowHeight: Int, var panelsX:
|
||||
val vm1 = getVMbyProfileName("Initial VM")!!
|
||||
initVMenv(vm1, "Initial VM")
|
||||
vms[0] = VMRunnerInfo(vm1, "Initial VM")
|
||||
applyMouseInputMappingForPanel(vm1, 0)
|
||||
|
||||
init()
|
||||
}
|
||||
@@ -288,15 +318,25 @@ class VMEmuExecutable(val windowWidth: Int, val windowHeight: Int, var panelsX:
|
||||
}
|
||||
|
||||
private fun reboot(profileName: String) {
|
||||
val vm = currentlyLoadedProfiles[profileName]!!
|
||||
val vm = currentlyLoadedProfiles[profileName] ?: return
|
||||
|
||||
/*vmRunners[vm.id]!!.close()
|
||||
coroutineJobs[vm.id]!!.interrupt()
|
||||
// Tear down the old session (joins the runner thread, then disposes
|
||||
// peripherals) before spinning up a new one. Without the join, the old
|
||||
// JS thread races the new one on shared VM memory / IO state.
|
||||
killVMenv(vm)
|
||||
initVMenv(vm, profileName)
|
||||
|
||||
vm.init()
|
||||
initVMenv(vm, profileName)*/
|
||||
// The old IOSpace was kept (peripheralTable[0] survives init/kill), so
|
||||
// the InputProcessor reference is still valid; just make sure the
|
||||
// currently focused viewport is still wired to it.
|
||||
if (currentVMselection != null && vms[currentVMselection!!]?.vm?.id == vm.id) {
|
||||
Gdx.input.inputProcessor = vm.getIO()
|
||||
}
|
||||
|
||||
// hypervisor will take over by monitoring MMIO addr 48
|
||||
// peripheralTable[1] (the GPU) was disposed and re-installed; re-apply
|
||||
// the mouse mapping so the rebooted VM keeps targeting its own panel.
|
||||
val panelIndex = vms.indexOfFirst { it?.vm?.id == vm.id }
|
||||
if (panelIndex >= 0) applyMouseInputMappingForPanel(vm, panelIndex)
|
||||
}
|
||||
|
||||
private fun updateGame(delta: Float) {
|
||||
@@ -312,8 +352,27 @@ class VMEmuExecutable(val windowWidth: Int, val windowHeight: Int, var panelsX:
|
||||
}
|
||||
|
||||
vms.forEachIndexed { index, it ->
|
||||
if (it?.vm?.resetDown == true && index == currentVMselection) { reboot(it.profileName) }
|
||||
if (it?.vm?.isRunning == true) it?.vm?.update(delta)
|
||||
if (it == null) return@forEachIndexed
|
||||
val vmId = it.vm.id
|
||||
|
||||
// Trigger reboot on the *release* edge of the RESET key combo, and
|
||||
// only for the focused viewport (resetDown for a hidden VM is
|
||||
// already kept false by IOSpace.update; this guard is belt-and-braces).
|
||||
if (index == currentVMselection) {
|
||||
if (it.vm.resetDown) {
|
||||
rebootLatched[vmId] = true
|
||||
}
|
||||
else if (rebootLatched[vmId] == true) {
|
||||
rebootLatched[vmId] = false
|
||||
reboot(it.profileName)
|
||||
return@forEachIndexed // VM was just rebuilt; skip the update tick
|
||||
}
|
||||
}
|
||||
else {
|
||||
rebootLatched[vmId] = false
|
||||
}
|
||||
|
||||
if (it.vm.isRunning) it.vm.update(delta)
|
||||
}
|
||||
|
||||
updateMenu()
|
||||
@@ -405,6 +464,10 @@ class VMEmuExecutable(val windowWidth: Int, val windowHeight: Int, var panelsX:
|
||||
this.panelsX = panelsX
|
||||
this.panelsY = panelsY
|
||||
resize(windowWidth * panelsX, windowHeight * panelsY)
|
||||
// Panel positions shifted, so every VM needs its mouse origin re-mapped.
|
||||
vms.forEachIndexed { index, info ->
|
||||
info?.vm?.let { applyMouseInputMappingForPanel(it, index) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun resize(width: Int, height: Int) {
|
||||
|
||||
@@ -8,6 +8,8 @@ import com.badlogic.gdx.graphics.g2d.SpriteBatch
|
||||
import com.badlogic.gdx.graphics.g2d.TextureRegion
|
||||
import com.badlogic.gdx.graphics.glutils.FrameBuffer
|
||||
import com.badlogic.gdx.graphics.glutils.ShaderProgram
|
||||
import com.badlogic.gdx.utils.viewport.StretchViewport
|
||||
import com.badlogic.gdx.utils.viewport.Viewport
|
||||
import net.torvald.terrarum.DefaultGL32Shaders
|
||||
import net.torvald.tsvm.peripheral.*
|
||||
import net.torvald.tsvm.peripheral.GraphicsAdapter.Companion.DRAW_SHADER_VERT
|
||||
@@ -48,6 +50,14 @@ class VMGUI(val loaderInfo: EmulInstance, val viewportWidth: Int, val viewportHe
|
||||
lateinit var batch: SpriteBatch
|
||||
lateinit var camera: OrthographicCamera
|
||||
|
||||
/**
|
||||
* Maps window pixels to the VM framebuffer (origin top-left, world size =
|
||||
* viewportWidth × viewportHeight). Stretches to fill the whole window so it
|
||||
* matches the `MAGN`-scaled blit at the end of [renderGame]. Handed to
|
||||
* [IOSpace.inputViewport] so mouse coordinates unproject correctly.
|
||||
*/
|
||||
lateinit var inputViewport: Viewport
|
||||
|
||||
var gpu: GraphicsAdapter? = null
|
||||
lateinit var vmRunner: VMRunner
|
||||
lateinit var coroutineJob: Thread
|
||||
@@ -103,9 +113,20 @@ class VMGUI(val loaderInfo: EmulInstance, val viewportWidth: Int, val viewportHe
|
||||
gpuFBO = FrameBuffer(Pixmap.Format.RGBA8888, viewportWidth, viewportHeight, false)
|
||||
winFBO = FrameBuffer(Pixmap.Format.RGBA8888, viewportWidth, viewportHeight, false)
|
||||
|
||||
val inputCam = OrthographicCamera().also {
|
||||
it.setToOrtho(true, viewportWidth.toFloat(), viewportHeight.toFloat())
|
||||
}
|
||||
inputViewport = StretchViewport(viewportWidth.toFloat(), viewportHeight.toFloat(), inputCam)
|
||||
inputViewport.update(Gdx.graphics.width, Gdx.graphics.height, true)
|
||||
|
||||
init()
|
||||
}
|
||||
|
||||
override fun resize(width: Int, height: Int) {
|
||||
super.resize(width, height)
|
||||
inputViewport.update(width, height, true)
|
||||
}
|
||||
|
||||
private fun init() {
|
||||
vm.init()
|
||||
|
||||
@@ -148,6 +169,11 @@ class VMGUI(val loaderInfo: EmulInstance, val viewportWidth: Int, val viewportHe
|
||||
}
|
||||
|
||||
Gdx.input.inputProcessor = vm.getIO()
|
||||
vm.getIO().inputViewport = inputViewport
|
||||
vm.getIO().inputOriginX = (viewportWidth - loaderInfo.drawWidth) / 2
|
||||
vm.getIO().inputOriginY = (viewportHeight - loaderInfo.drawHeight) / 2
|
||||
vm.getIO().inputAreaW = loaderInfo.drawWidth
|
||||
vm.getIO().inputAreaH = loaderInfo.drawHeight
|
||||
|
||||
if (usememvwr) memvwr = Memvwr(vm)
|
||||
|
||||
@@ -172,8 +198,34 @@ class VMGUI(val loaderInfo: EmulInstance, val viewportWidth: Int, val viewportHe
|
||||
private fun killVMenv() {
|
||||
if (vmKilled.compareAndSet(0, System.currentTimeMillis())) {
|
||||
System.err.println("VMGUI is killing VM environment...")
|
||||
|
||||
// Order is critical: stop ALL execution first, then dispose peripherals.
|
||||
// If we disposed peripherals while the runner thread is still alive, the
|
||||
// thread would touch destroyed UnsafePtrs and SIGSEGV.
|
||||
|
||||
// 1. Stop parallel/child contexts. park() interrupts and joins them.
|
||||
vm.park()
|
||||
vm.poke(-90L, -128)
|
||||
|
||||
// 2. Interrupt the main runner thread and cancel the GraalVM context.
|
||||
if (::coroutineJob.isInitialized) coroutineJob.interrupt()
|
||||
try { if (::vmRunner.isInitialized) vmRunner.close() } catch (_: Throwable) {}
|
||||
|
||||
// 3. Wait for the main runner thread to actually finish.
|
||||
if (::coroutineJob.isInitialized && coroutineJob !== Thread.currentThread()) {
|
||||
try {
|
||||
coroutineJob.join(2000L)
|
||||
if (coroutineJob.isAlive) {
|
||||
System.err.println("[VMGUI] runner ${vm.id} did not exit within 2s; proceeding anyway")
|
||||
coroutineJob.interrupt()
|
||||
}
|
||||
}
|
||||
catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Now it's safe to release native resources held by peripherals.
|
||||
for (i in 1 until vm.peripheralTable.size) {
|
||||
try {
|
||||
vm.peripheralTable[i].peripheral?.dispose()
|
||||
@@ -181,8 +233,7 @@ class VMGUI(val loaderInfo: EmulInstance, val viewportWidth: Int, val viewportHe
|
||||
catch (_: Throwable) {
|
||||
}
|
||||
}
|
||||
coroutineJob.interrupt()
|
||||
vmRunner.close()
|
||||
|
||||
vm.getPrintStream = { TODO() }
|
||||
vm.getErrorStream = { TODO() }
|
||||
vm.getInputStream = { TODO() }
|
||||
@@ -195,12 +246,12 @@ class VMGUI(val loaderInfo: EmulInstance, val viewportWidth: Int, val viewportHe
|
||||
private var rebootRequested = false
|
||||
|
||||
private fun reboot() {
|
||||
/*vmRunner.close()
|
||||
coroutineJob.interrupt()
|
||||
|
||||
init()*/
|
||||
|
||||
// hypervisor will take over by monitoring MMIO addr 48
|
||||
// Tear down the old session (joins the runner thread, then disposes
|
||||
// peripherals) before re-initialising. Without the join, the old JS
|
||||
// thread races the new one on shared VM memory / IO state and can
|
||||
// SIGSEGV on disposed peripherals.
|
||||
killVMenv()
|
||||
init()
|
||||
}
|
||||
|
||||
private var updateAkku = 0.0
|
||||
|
||||
@@ -1,221 +0,0 @@
|
||||
# Created by CuriousTorvald and Claude on 2025-08-17.
|
||||
# Makefile for TSVM Enhanced Video (TEV) encoder and libraries
|
||||
|
||||
CC = gcc
|
||||
CXX = g++
|
||||
CFLAGS = -std=c99 -Wall -Wextra -Ofast -D_GNU_SOURCE -march=native -mavx512f -mavx512dq -mavx512bw -mavx512vl -Iinclude
|
||||
CXXFLAGS = -std=c++11 -Wall -Wextra -Ofast -D_GNU_SOURCE -march=native -mavx512f -mavx512dq -mavx512bw -mavx512vl -Iinclude
|
||||
DBGFLAGS =
|
||||
PREFIX = /usr/local
|
||||
|
||||
# Zstd flags (use pkg-config if available, fallback for cross-platform compatibility)
|
||||
ZSTD_CFLAGS = $(shell pkg-config --cflags libzstd 2>/dev/null || echo "")
|
||||
ZSTD_LIBS = $(shell pkg-config --libs libzstd 2>/dev/null || echo "-lzstd")
|
||||
LIBS = -lm $(ZSTD_LIBS)
|
||||
|
||||
# =============================================================================
|
||||
# Library Object Files
|
||||
# =============================================================================
|
||||
|
||||
# libtavenc - TAV encoder library
|
||||
LIBTAVENC_OBJ = lib/libtavenc/tav_encoder_lib.o \
|
||||
lib/libtavenc/tav_encoder_color.o \
|
||||
lib/libtavenc/tav_encoder_dwt.o \
|
||||
lib/libtavenc/tav_encoder_quantize.o \
|
||||
lib/libtavenc/tav_encoder_ezbc.o \
|
||||
lib/libtavenc/tav_encoder_utils.o \
|
||||
lib/libtavenc/tav_encoder_tile.o
|
||||
|
||||
# libtavdec - TAV decoder library
|
||||
LIBTAVDEC_OBJ = lib/libtavdec/tav_video_decoder.o
|
||||
|
||||
# libtadenc - TAD encoder library
|
||||
LIBTADENC_OBJ = lib/libtadenc/encoder_tad.o
|
||||
|
||||
# libtaddec - TAD decoder library
|
||||
LIBTADDEC_OBJ = lib/libtaddec/decoder_tad.o
|
||||
|
||||
# libfec - Forward Error Correction library (LDPC + Reed-Solomon)
|
||||
LIBFEC_OBJ = lib/libfec/ldpc.o lib/libfec/reed_solomon.o lib/libfec/ldpc_payload.o
|
||||
|
||||
# =============================================================================
|
||||
# Targets
|
||||
# =============================================================================
|
||||
|
||||
# Source files and targets
|
||||
TARGETS = libs encoder_tav_ref decoder_tav_ref tav_inspector tad tav_dt
|
||||
LIBRARIES = lib/libtavenc.a lib/libtavdec.a lib/libtadenc.a lib/libtaddec.a lib/libfec.a
|
||||
TAV_TARGETS = encoder_tav_ref decoder_tav_ref tav_inspector
|
||||
TAD_TARGETS = encoder_tad decoder_tad
|
||||
DT_TARGETS = encoder_tav_dt decoder_tav_dt tavdt_noise_injector
|
||||
|
||||
# Build all encoders (default)
|
||||
all: clean $(TARGETS)
|
||||
|
||||
# Build all libraries
|
||||
libs: $(LIBRARIES)
|
||||
|
||||
# Reference encoder using libtavenc (replaces old monolithic encoder)
|
||||
encoder_tav_ref: src/encoder_tav.c lib/libtavenc.a lib/libtadenc.a
|
||||
rm -f encoder_tav_ref
|
||||
$(CC) $(CFLAGS) $(ZSTD_CFLAGS) -Iinclude -o encoder_tav_ref src/encoder_tav.c lib/libtavenc.a lib/libtadenc.a $(LIBS)
|
||||
@echo ""
|
||||
@echo "Reference encoder built: encoder_tav_ref"
|
||||
@echo "This is the official reference implementation with all features"
|
||||
|
||||
# Reference decoder using libtavdec (replaces old monolithic decoder)
|
||||
decoder_tav_ref: src/decoder_tav.c lib/libtavdec.a lib/libtaddec.a
|
||||
rm -f decoder_tav_ref
|
||||
$(CC) $(CFLAGS) $(ZSTD_CFLAGS) -Iinclude -o decoder_tav_ref src/decoder_tav.c lib/libtavdec.a lib/libtaddec.a $(LIBS)
|
||||
@echo ""
|
||||
@echo "Reference decoder built: decoder_tav_ref"
|
||||
@echo "This is the official reference implementation with all features"
|
||||
|
||||
tav_inspector: tav_inspector.c lib/libfec.a
|
||||
rm -f tav_inspector
|
||||
$(CC) $(CFLAGS) $(ZSTD_CFLAGS) -Ilib/libfec -o tav_inspector $< lib/libfec.a $(LIBS)
|
||||
|
||||
tav: $(TAV_TARGETS)
|
||||
|
||||
# Build TAD (Terrarum Advanced Audio) tools
|
||||
encoder_tad: src/encoder_tad_standalone.c lib/libtadenc/encoder_tad.c include/encoder_tad.h
|
||||
rm -f encoder_tad encoder_tad_standalone.o encoder_tad.o
|
||||
$(CC) $(CFLAGS) $(ZSTD_CFLAGS) -c lib/libtadenc/encoder_tad.c -o encoder_tad.o
|
||||
$(CC) $(CFLAGS) $(ZSTD_CFLAGS) -c src/encoder_tad_standalone.c -o encoder_tad_standalone.o
|
||||
$(CC) $(DBGFLAGS) -o encoder_tad encoder_tad_standalone.o encoder_tad.o $(LIBS)
|
||||
|
||||
decoder_tad: lib/libtaddec/decoder_tad.c
|
||||
rm -f decoder_tad
|
||||
$(CC) $(CFLAGS) $(ZSTD_CFLAGS) -o decoder_tad $< $(LIBS)
|
||||
|
||||
# Build all TAD tools
|
||||
tad: $(TAD_TARGETS)
|
||||
|
||||
# =============================================================================
|
||||
# Library Build Rules
|
||||
# =============================================================================
|
||||
|
||||
# Compile library object files
|
||||
lib/libtavenc/%.o: lib/libtavenc/%.c
|
||||
$(CC) $(CFLAGS) $(ZSTD_CFLAGS) -c $< -o $@
|
||||
|
||||
lib/libtavdec/%.o: lib/libtavdec/%.c
|
||||
$(CC) $(CFLAGS) $(ZSTD_CFLAGS) -c $< -o $@
|
||||
|
||||
lib/libtadenc/%.o: lib/libtadenc/%.c
|
||||
$(CC) $(CFLAGS) $(ZSTD_CFLAGS) -c $< -o $@
|
||||
|
||||
lib/libtaddec/%.o: lib/libtaddec/%.c
|
||||
$(CC) $(CFLAGS) $(ZSTD_CFLAGS) -DTAD_DECODER_LIB -c $< -o $@
|
||||
|
||||
lib/libfec/%.o: lib/libfec/%.c
|
||||
$(CC) $(CFLAGS) -Ilib/libfec -c $< -o $@
|
||||
|
||||
# Build static libraries
|
||||
lib/libtavenc.a: $(LIBTAVENC_OBJ)
|
||||
ar rcs $@ $^
|
||||
|
||||
lib/libtavdec.a: $(LIBTAVDEC_OBJ)
|
||||
ar rcs $@ $^
|
||||
|
||||
lib/libtadenc.a: $(LIBTADENC_OBJ)
|
||||
ar rcs $@ $^
|
||||
|
||||
lib/libtaddec.a: $(LIBTADDEC_OBJ)
|
||||
ar rcs $@ $^
|
||||
|
||||
lib/libfec.a: $(LIBFEC_OBJ)
|
||||
ar rcs $@ $^
|
||||
|
||||
# =============================================================================
|
||||
# TAV-DT (Digital Tape) Encoder/Decoder
|
||||
# =============================================================================
|
||||
|
||||
# TAV-DT encoder with FEC (multithreaded)
|
||||
encoder_tav_dt: src/encoder_tav_dt.c lib/libtavenc.a lib/libtadenc.a lib/libfec.a
|
||||
rm -f encoder_tav_dt
|
||||
$(CC) $(CFLAGS) $(ZSTD_CFLAGS) -Iinclude -Ilib/libfec -o encoder_tav_dt src/encoder_tav_dt.c lib/libtavenc.a lib/libtadenc.a lib/libfec.a $(LIBS) -lpthread
|
||||
@echo ""
|
||||
@echo "TAV-DT encoder built: encoder_tav_dt"
|
||||
@echo "Digital Tape format with LDPC and Reed-Solomon FEC (multithreaded)"
|
||||
|
||||
# TAV-DT decoder with FEC (multithreaded)
|
||||
decoder_tav_dt: src/decoder_tav_dt.c lib/libtavdec.a lib/libtaddec.a lib/libfec.a
|
||||
rm -f decoder_tav_dt
|
||||
$(CC) $(CFLAGS) $(ZSTD_CFLAGS) -Iinclude -Ilib/libfec -o decoder_tav_dt src/decoder_tav_dt.c lib/libtavdec.a lib/libtaddec.a lib/libfec.a $(LIBS) -lpthread
|
||||
@echo ""
|
||||
@echo "TAV-DT decoder built: decoder_tav_dt"
|
||||
@echo "Digital Tape format with LDPC and Reed-Solomon FEC (multithreaded)"
|
||||
|
||||
# TAV-DT noise injector (channel simulator)
|
||||
tavdt_noise_injector: tavdt_noise_injector.c
|
||||
rm -f tavdt_noise_injector
|
||||
$(CC) -std=c99 -Wall -Ofast -D_GNU_SOURCE -o tavdt_noise_injector tavdt_noise_injector.c -lm
|
||||
@echo ""
|
||||
@echo "TAV-DT noise injector built: tavdt_noise_injector"
|
||||
@echo "Simulates QPSK satellite channel noise (AWGN + burst)"
|
||||
|
||||
# Build all TAV-DT tools
|
||||
tav_dt: $(DT_TARGETS)
|
||||
|
||||
# Build with debug symbols
|
||||
debug: CFLAGS += -g -DDEBUG -fsanitize=address -fno-omit-frame-pointer
|
||||
debug: DBGFLAGS += -fsanitize=address -fno-omit-frame-pointer
|
||||
debug: clean $(TARGETS)
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
rm -f $(TARGETS) $(TAD_TARGETS) $(DT_TARGETS) $(LIBRARIES) *.o lib/*/*.o
|
||||
|
||||
# Install (copy to PATH)
|
||||
install: $(TARGETS)
|
||||
cp encoder_tav_ref $(PREFIX)/bin/
|
||||
cp decoder_tav_ref $(PREFIX)/bin/
|
||||
cp encoder_tad $(PREFIX)/bin/
|
||||
cp decoder_tad $(PREFIX)/bin/
|
||||
cp encoder_tav_dt $(PREFIX)/bin/
|
||||
cp decoder_tav_dt $(PREFIX)/bin/
|
||||
cp tav_inspector $(PREFIX)/bin/
|
||||
|
||||
# Check for required dependencies
|
||||
check-deps:
|
||||
@echo "Checking dependencies..."
|
||||
@pkg-config --exists libzstd || (echo "Error: libzstd-dev not found. Install libzstd-dev or equivalent" && exit 1)
|
||||
@echo "All dependencies found."
|
||||
|
||||
# Help
|
||||
help:
|
||||
@echo "TSVM Advanced Video (TAV) and Audio (TAD) Encoders"
|
||||
@echo ""
|
||||
@echo "Targets:"
|
||||
@echo " all - Build video encoders (default)"
|
||||
@echo " libs - Build all codec libraries (.a files)"
|
||||
@echo " tav - Build the TAV advanced video encoder"
|
||||
@echo " tav_dt - Build all TAV-DT (Digital Tape) tools with FEC"
|
||||
@echo " tavdt_noise_injector - Build TAV-DT channel noise simulator"
|
||||
@echo " tad - Build all TAD audio tools (encoder, decoder)"
|
||||
@echo " encoder_tad - Build TAD audio encoder"
|
||||
@echo " decoder_tad - Build TAD audio decoder"
|
||||
@echo " tests - Build test programs"
|
||||
@echo " debug - Build with debug symbols"
|
||||
@echo " clean - Remove build artifacts"
|
||||
@echo " install - Install to /usr/local/bin"
|
||||
@echo " check-deps - Check for required dependencies"
|
||||
@echo " help - Show this help"
|
||||
@echo ""
|
||||
@echo "Libraries:"
|
||||
@echo " lib/libtavenc.a - TAV encoder library"
|
||||
@echo " lib/libtavdec.a - TAV decoder library"
|
||||
@echo " lib/libtadenc.a - TAD encoder library"
|
||||
@echo " lib/libtaddec.a - TAD decoder library"
|
||||
@echo " lib/libfec.a - Forward Error Correction library (LDPC + RS)"
|
||||
@echo ""
|
||||
@echo "Usage:"
|
||||
@echo " make # Build video encoders"
|
||||
@echo " make libs # Build all libraries"
|
||||
@echo " make tav # Build TAV encoder"
|
||||
@echo " make tav_dt # Build TAV-DT encoder/decoder with FEC"
|
||||
@echo " make tad # Build all TAD audio tools"
|
||||
@echo " sudo make install # Install all encoders"
|
||||
|
||||
.PHONY: all libs clean install check-deps help debug tad tav_dt tests
|
||||
@@ -1,350 +0,0 @@
|
||||
# TAD - TSVM Advanced Audio Codec
|
||||
|
||||
A perceptually-optimised wavelet-based audio codec designed for resource-constrained systems, featuring CDF 9/7 wavelets, EZBC sparse coding, and sophisticated perceptual quantisation.
|
||||
|
||||
## Overview
|
||||
|
||||
TAD (TSVM Advanced Audio) is a modern audio codec built on discrete wavelet transform (DWT) using Cohen-Daubechies-Feauveau (CDF) 9/7 biorthogonal wavelets. It combines perceptual quantisation, advanced entropy coding, and careful optimisation for resource-constrained systems.
|
||||
|
||||
### Key Advantages
|
||||
|
||||
- **Perceptual optimisation**: HVS-aware quantisation preserves audio quality where it matters
|
||||
- **Efficient sparse coding**: EZBC encoding exploits coefficient sparsity (86.9% zeros in typical content)
|
||||
- **Variable chunk sizes**: Supports any chunk size ≥1024 samples, including non-power-of-2
|
||||
- **Stereo decorrelation**: Mid/Side encoding exploits stereo correlation for better compression
|
||||
- **Hardware-friendly**: Designed for efficient decoding on resource-constrained platforms
|
||||
|
||||
## Features
|
||||
|
||||
### Compression Technology
|
||||
|
||||
- **CDF 9/7 Biorthogonal Wavelets**
|
||||
- 9-level fixed decomposition for all chunk sizes
|
||||
- Lifting scheme implementation for efficient computation
|
||||
- Optimal frequency discrimination for audio signals
|
||||
|
||||
- **Pre-processing**
|
||||
- First-order IIR pre-emphasis filter (α=0.5) shifts quantisation noise to lower frequencies, where they are less objectionable to listeners
|
||||
- Gamma companding (γ=0.5) for dynamic range compression before quantisation
|
||||
- Mid/Side stereo transformation exploits stereo correlation
|
||||
- Lambda companding (λ=6.0) with Laplacian CDF mapping for full bit utilisation
|
||||
|
||||
- **Perceptual Quantisation**
|
||||
- Channel-specific (Mid/Side) frequency-dependent weights
|
||||
- Subband-aware quantisation preserves perceptually important frequencies
|
||||
|
||||
- **EZBC Encoding**
|
||||
- Binary tree embedded zero block coding
|
||||
- Exploits coefficient sparsity (86.9% Mid, 97.8% Side typical)
|
||||
- Progressive refinement structure
|
||||
- Spatial clustering of non-zero coefficients
|
||||
|
||||
- **Entropy Coding**
|
||||
- Zstandard compression (level 7) on concatenated EZBC bitstreams
|
||||
- Cross-channel compression optimisation
|
||||
- Optional Zstd bypass for debugging
|
||||
|
||||
### Audio Format
|
||||
|
||||
- **Sample Rate**: 32 KHz (TSVM audio hardware native format)
|
||||
- **Channels**: Stereo (L/R input, Mid/Side internal representation)
|
||||
- **Chunk Sizes**: Variable, any size ≥1024 samples (including non-power-of-2)
|
||||
- **Bit Depth**: 32-bit float internal, 8-bit unsigned PCM output with noise-shaped dithering
|
||||
- **Bandwidth**: Full 0-16 KHz frequency range preserved
|
||||
|
||||
### Quality Levels
|
||||
|
||||
Six quality levels (0-5) provide a wide range of compression/quality trade-offs:
|
||||
- **Level 0**: Lowest quality, smallest file size
|
||||
- **Level 3**: Default, balanced quality/compression (2.51:1 vs PCMu8)
|
||||
- **Level 5**: Highest quality, largest file size
|
||||
|
||||
Quality levels are designed to be synchronised with TAV video codec for unified encoding.
|
||||
|
||||
## Building
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- C compiler (GCC/Clang)
|
||||
- Zstandard library (libzstd)
|
||||
- Math library (libm)
|
||||
|
||||
### Compilation
|
||||
|
||||
```bash
|
||||
# Build TAD encoder/decoder
|
||||
make tad
|
||||
|
||||
# Build all tools
|
||||
make all
|
||||
|
||||
# Clean build artifacts
|
||||
make clean
|
||||
```
|
||||
|
||||
### Build Targets
|
||||
|
||||
- `encoder_tad` - Standalone audio encoder with FFmpeg calls
|
||||
- `decoder_tad` - Standalone audio decoder
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Encoding
|
||||
|
||||
Encoding requires FFmpeg executable installed in your system.
|
||||
|
||||
```bash
|
||||
# Default encoding (quality level 3)
|
||||
./encoder_tad -i input.mp3 -o output.tad
|
||||
|
||||
# Specify quality level (0-5)
|
||||
./encoder_tad -i input.m4a -o output.tad -q 0 # Lowest quality
|
||||
./encoder_tad -i input.ogg -o output.tad -q 5 # Highest quality
|
||||
|
||||
# Disable Zstd compression (for debugging)
|
||||
./encoder_tad -i input.opus -o output.tad --no-zstd
|
||||
|
||||
# Verbose output with statistics
|
||||
./encoder_tad -i input.flac -o output.tad -v
|
||||
```
|
||||
|
||||
### Decoding
|
||||
|
||||
```bash
|
||||
# Decode to PCMu8
|
||||
./decoder_tad -i input.tad -o output.pcm --raw-pcm
|
||||
|
||||
# Decode to WAV
|
||||
./decoder_tad -i input.tad -o output.wav
|
||||
```
|
||||
|
||||
### Input Formats
|
||||
|
||||
TAD encoder accepts any audio format supported by FFmpeg:
|
||||
- Audio files: WAV, MP3, FLAC, OGG, AAC, etc.
|
||||
- Video files with audio streams: MP4, MKV, AVI, etc.
|
||||
- Raw PCM formats
|
||||
|
||||
Audio is automatically resampled to 32 KHz stereo if necessary.
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Encoder Pipeline
|
||||
|
||||
1. **Input Processing**
|
||||
- FFmpeg demuxing and audio stream extraction
|
||||
- Resampling to 32 KHz stereo
|
||||
- Conversion to PCM32f
|
||||
|
||||
2. **Pre-emphasis Filter**
|
||||
- First-order IIR filter with α=0.5
|
||||
- Shifts quantisation noise toward lower frequencies
|
||||
- Improves perceptual quality
|
||||
|
||||
3. **Gamma Companding**
|
||||
- Dynamic range compression with γ=0.5
|
||||
- Applied independently to each sample
|
||||
- Reduces quantisation error for low-amplitude signals
|
||||
|
||||
4. **Stereo Decorrelation**
|
||||
- Left/Right to Mid/Side transformation
|
||||
- Mid = (L + R) / 2
|
||||
- Side = (L - R) / 2
|
||||
- Exploits stereo correlation for better compression
|
||||
|
||||
5. **9-Level CDF 9/7 DWT**
|
||||
- Fixed 9 decomposition levels for all chunk sizes
|
||||
- Forward lifting scheme implementation
|
||||
- Correct length tracking for non-power-of-2 sizes
|
||||
|
||||
6. **Perceptual Quantisation**
|
||||
- Channel-specific (Mid/Side) subband weights
|
||||
- Lambda companding with λ=6.0
|
||||
- Laplacian CDF mapping: `sign(x) * floor(λ * log(1 + |x|/λ))`
|
||||
- Quantised to int8 coefficients
|
||||
|
||||
7. **EZBC Encoding**
|
||||
- Binary tree structure per channel
|
||||
- Progressive refinement by bitplanes
|
||||
- Zero block coding exploits sparsity
|
||||
- Independent bitstreams for Mid and Side
|
||||
|
||||
8. **Zstd Compression**
|
||||
- Level 7 compression on concatenated `[Mid_bitstream][Side_bitstream]`
|
||||
- Cross-channel optimisation opportunities
|
||||
- Adaptive compression based on content
|
||||
|
||||
### Decoder Pipeline
|
||||
|
||||
1. **Container Parsing**
|
||||
- TAD packet identification (type 0x24)
|
||||
- Chunk size extraction
|
||||
- Compressed data boundaries
|
||||
|
||||
2. **Zstd Decompression**
|
||||
- Decompress concatenated bitstreams
|
||||
- Split into Mid and Side EZBC streams
|
||||
|
||||
3. **EZBC Decoding**
|
||||
- Binary tree decoder per channel
|
||||
- Reconstruct quantised int8 coefficients
|
||||
- Progressive refinement reconstruction
|
||||
|
||||
4. **Lambda Decompanding**
|
||||
- Inverse Laplacian CDF with channel-specific weights
|
||||
- Reconstruct float32 DWT coefficients
|
||||
- Apply subband-specific perceptual weights
|
||||
|
||||
5. **9-Level Inverse CDF 9/7 DWT**
|
||||
- Inverse lifting scheme implementation
|
||||
- Correct length tracking for non-power-of-2 chunk sizes
|
||||
- Pre-calculated length sequence from forward transform
|
||||
|
||||
6. **Mid/Side to Left/Right**
|
||||
- L = Mid + Side
|
||||
- R = Mid - Side
|
||||
- Reconstruct stereo channels
|
||||
|
||||
7. **Gamma Decompanding**
|
||||
- Inverse gamma with γ⁻¹=2.0
|
||||
- Restore original dynamic range
|
||||
|
||||
8. **De-emphasis Filter**
|
||||
- Reverse pre-emphasis with α=0.5
|
||||
- Remove frequency shaping
|
||||
- Restore flat frequency response
|
||||
|
||||
9. **PCM32f to PCM8u Conversion**
|
||||
- Noise-shaped dithering for 8-bit output
|
||||
- Clamping to valid range
|
||||
- Final output format
|
||||
|
||||
### Wavelet Implementation
|
||||
|
||||
CDF 9/7 wavelet follows a **two-stage lifting scheme**:
|
||||
|
||||
```c
|
||||
// Forward Transform: Predict → Update
|
||||
// Predict step (generate high-pass)
|
||||
temp[half + i] = data[odd] - α * (data[even_left] + data[even_right]);
|
||||
|
||||
// Update step (generate low-pass)
|
||||
temp[i] = data[even] + β * (temp[half + i - 1] + temp[half + i]);
|
||||
|
||||
// Normalization (K factor)
|
||||
temp[i] *= K;
|
||||
temp[half + i] /= K;
|
||||
|
||||
// Inverse Transform: Denormalize → Undo Update → Undo Predict (reversed order)
|
||||
temp[i] /= K;
|
||||
temp[half + i] *= K;
|
||||
|
||||
temp[i] -= β * (temp[half + i - 1] + temp[half + i]);
|
||||
data[odd] = temp[half + i] + α * (temp[i] + temp[i + 1]);
|
||||
data[even] = temp[i];
|
||||
```
|
||||
|
||||
**CDF 9/7 Coefficients**:
|
||||
- α = -1.586134342
|
||||
- β = -0.052980118
|
||||
- γ = +0.882911075
|
||||
- δ = +0.443506852
|
||||
- K = 1.230174105
|
||||
|
||||
### Non-Power-of-2 Chunk Size Handling
|
||||
|
||||
Critical implementation detail for variable chunk sizes:
|
||||
|
||||
```c
|
||||
// Pre-calculate exact length sequence from forward transform
|
||||
int lengths[MAX_LEVELS + 1];
|
||||
lengths[0] = chunk_size;
|
||||
for (int i = 1; i <= levels; i++) {
|
||||
lengths[i] = (lengths[i - 1] + 1) / 2;
|
||||
}
|
||||
|
||||
// Apply inverse DWT using lengths[level] for each level
|
||||
// NEVER use simple doubling (length *= 2) - incorrect for non-power-of-2!
|
||||
```
|
||||
|
||||
Incorrect length tracking causes mirrored subband artefacts in decoded audio.
|
||||
|
||||
### Perceptual Quantisation Weights
|
||||
|
||||
Channel-specific weights for Mid (channel 0) and Side (channel 1):
|
||||
|
||||
```c
|
||||
// Base quantiser weights per subband (9 levels + approximation)
|
||||
float BASE_QUANTISER_WEIGHTS[2][10] = {
|
||||
// Mid channel (0)
|
||||
{4.0f, 2.0f, 1.8f, 1.6f, 1.4f, 1.2f, 1.0f, 1.0f, 1.3f, 2.0f},
|
||||
|
||||
// Side channel (1)
|
||||
{6.0f, 5.0f, 2.6f, 2.4f, 1.8f, 1.3f, 1.0f, 1.0f, 1.6f, 3.2f}
|
||||
};
|
||||
|
||||
// During dequantisation:
|
||||
float weight = BASE_QUANTISER_WEIGHTS[channel][subband] * quantiser_scale;
|
||||
coeffs[i] = normalised_val * TAD32_COEFF_SCALARS[subband] * weight;
|
||||
```
|
||||
|
||||
Different weights for Mid and Side channels reflect perceptual importance of frequency bands in each channel. DC frequency has highest weight (4.0 Mid, 6.0 Side) due to energy concentration.
|
||||
|
||||
## Performance Characteristics
|
||||
|
||||
### Compression Efficiency
|
||||
|
||||
- **Target Compression**: 2:1 against PCMu8 baseline (4:1 against PCM16LE input)
|
||||
- **Achieved Compression**: 2.51:1 against PCMu8 at quality level 3
|
||||
- **Audio Quality**: Preserves full 0-16 KHz bandwidth
|
||||
- **Coefficient Sparsity**: 86.9% zeros in Mid channel, 97.8% in Side channel (typical)
|
||||
- **EZBC Benefits**: Exploits sparsity, progressive refinement, spatial clustering
|
||||
|
||||
### Computational Complexity
|
||||
|
||||
- **Encoding**: O(n log n) per chunk for DWT, O(n) for EZBC encoding
|
||||
- **Decoding**: O(n log n) per chunk for inverse DWT, O(n) for EZBC decoding
|
||||
- **Memory**: O(n) working memory for chunk processing
|
||||
|
||||
### Quality Characteristics
|
||||
|
||||
- **Frequency Response**: Flat 0-16 KHz within perceptual limits
|
||||
- **Dynamic Range**: Preserved through gamma companding
|
||||
- **Stereo Imaging**: Maintained through Mid/Side decorrelation
|
||||
- **Perceptual Quality**: Optimised for human auditory system characteristics
|
||||
|
||||
## Integration with TAV
|
||||
|
||||
TAD is designed as an includable API for TAV video encoder integration:
|
||||
|
||||
- **Variable Chunk Sizes**: Audio chunks can match video GOP boundaries (e.g., 32016 samples for 1-second TAV GOP)
|
||||
- **Unified Quality Levels**: TAD quality 0-5 synchronised with TAV quality 0-5
|
||||
- **Embedded Packets**: TAV embeds TAD-compressed audio using packet type 0x24
|
||||
- **Shared Container**: Single .tav file contains both video and audio streams
|
||||
|
||||
### TAV Integration Example
|
||||
|
||||
```c
|
||||
// TAD handles non-power-of-2 chunk size correctly
|
||||
tad_encode_chunk(audio_buffer, audio_samples_per_gop, output_buffer, &output_size);
|
||||
|
||||
// TAV embeds TAD packet
|
||||
tav_write_packet(TAV_PACKET_AUDIO, output_buffer, output_size);
|
||||
```
|
||||
|
||||
## Format Specification
|
||||
|
||||
For complete packet structure and bitstream format details, refer to `format documentation.txt`.
|
||||
|
||||
### Key Packet Types
|
||||
|
||||
- `0x24`: TAD audio packet (used in standalone .tad files and embedded in .tav files)
|
||||
|
||||
## Related Projects
|
||||
|
||||
- **TAV** (TSVM Advanced Video): Wavelet-based video codec with integrated TAD audio
|
||||
- **TSVM**: Target virtual machine platform for TAD playback
|
||||
|
||||
## Licence
|
||||
|
||||
MIT.
|
||||
@@ -1,261 +0,0 @@
|
||||
# TAV - TSVM Advanced Video Codec
|
||||
|
||||
A perceptually-optimised wavelet-based video codec designed for resource-constrained systems, featuring multiple wavelet types, temporal 3D DWT, and sophisticated compression techniques.
|
||||
|
||||
## Overview
|
||||
|
||||
TAV (TSVM Advanced Video) is a modern video codec built on discrete wavelet transformation (DWT). It combines cutting-edge compression techniques with careful optimisation for resource-constrained systems.
|
||||
|
||||
### Key Advantages
|
||||
|
||||
- **No blocking artefacts**: Large-tile DWT encoding with padding eliminates DCT block boundaries
|
||||
- **No colour banding**: Wavelets spreads gradients across scales, preventing banding in the first place
|
||||
- **Perceptual optimisation**: HVS-aware quantisation preserves visual quality where it matters
|
||||
- **Temporal coherence**: 3D DWT with GOP encoding exploits inter-frame similarity
|
||||
- **Efficient sparse coding**: EZBC encoding exploits coefficient sparsity for 16-18% additional compression
|
||||
- **Hardware-friendly**: Designed for efficient decoding on resource-constrained platforms
|
||||
|
||||
## Features
|
||||
|
||||
### Compression Technology
|
||||
|
||||
- **Wavelet Types**
|
||||
- **5/3 Reversible** (JPEG 2000 standard): Lossless-capable, good for archival
|
||||
- **9/7 Irreversible** (default): Best overall compression, CDF 9/7 variant
|
||||
|
||||
- **Spatial Encoding**
|
||||
- Large-tile encoding with padding, with optional single-tile mode (no blocking artefacts)
|
||||
- 6-level DWT decomposition for deep frequency analysis
|
||||
- Perceptual quantisation with HVS-optimised coefficient scaling
|
||||
- YCoCg-R colour space with anisotropic chroma quantisation
|
||||
|
||||
- **Temporal Encoding** (3D DWT Mode)
|
||||
- Group-of-pictures (GOP) encoding with adaptive size (typically 20 frames)
|
||||
- Unified EZBC encoding across temporal dimension
|
||||
- Adaptive GOP boundaries with scene change detection
|
||||
|
||||
- **EZBC Encoding**
|
||||
- Binary tree embedded zero block coding exploits coefficient sparsity
|
||||
- Progressive refinement structure with bitplane encoding
|
||||
- Concatenated channel layout for cross-channel compression optimisation
|
||||
- Typical sparsity: 86.9% (Y), 97.8% (Co), 99.5% (Cg)
|
||||
- 16-18% compression improvement over naive coefficient encoding
|
||||
|
||||
### Audio Integration
|
||||
|
||||
TAV seamlessly integrates with the TAD (TSVM Advanced Audio) codec for synchronised audio/video encoding:
|
||||
- Variable chunk sizes match video GOP boundaries
|
||||
- Embedded TAD packets (type 0x24) with Zstd compression
|
||||
- Unified container format
|
||||
|
||||
## Building
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- C compiler (GCC/Clang)
|
||||
- Zstandard library
|
||||
- OpenCV 4 library (only used by experimental motion estimation feature)
|
||||
|
||||
### Compilation
|
||||
|
||||
```bash
|
||||
# Build TAV encoder/decoder
|
||||
make tav
|
||||
|
||||
# Build all tools including TAD audio codec
|
||||
make all
|
||||
|
||||
# Clean build artefacts
|
||||
make clean
|
||||
```
|
||||
|
||||
### Build Targets
|
||||
|
||||
- `encoder_tav` - Main video encoder
|
||||
- `decoder_tav` - Standalone video decoder
|
||||
- `tav_inspector` - Packet analysis and debugging tool
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic Encoding
|
||||
|
||||
Encoding requires FFmpeg executable installed in your system.
|
||||
|
||||
```bash
|
||||
# Default encoding (CDF 9/7 wavelet, quality level 3)
|
||||
./encoder_tav -i input.mp4 -o output.tav
|
||||
|
||||
# Quality levels (0-5)
|
||||
./encoder_tav -i input.avi -q 0 -o output.tav # Lowest quality, smallest file
|
||||
./encoder_tav -i input.mkv -q 5 -o output.tav # Highest quality, largest file
|
||||
```
|
||||
|
||||
### Intra-only Encoding
|
||||
|
||||
```bash
|
||||
# Enable Intra-only encoding
|
||||
./encoder_tav -i input.mp4 --intra-only -o output.tav
|
||||
```
|
||||
|
||||
### Decoding and Inspection
|
||||
|
||||
```bash
|
||||
# Decode TAV to raw video
|
||||
./decoder_tav -i input.tav -o output.mkv
|
||||
|
||||
# Inspect packet structure (debugging)
|
||||
./tav_inspector input.tav -v
|
||||
```
|
||||
|
||||
### Frame Limiting
|
||||
|
||||
```bash
|
||||
# Encode only first N frames (useful for testing)
|
||||
./encoder_tav -i input.mp4 -o output.tav --encode-limit 100
|
||||
```
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Encoder Pipeline
|
||||
|
||||
1. **Input Processing**
|
||||
- FFmpeg demuxing and frame extraction
|
||||
- RGB to YCoCg-R colour space conversion
|
||||
- Resolution validation and padding
|
||||
|
||||
2. **DWT Transform**
|
||||
- Spatial: 6-level decomposition per frame
|
||||
- Temporal: 1D DWT across GOP frames (3D DWT mode)
|
||||
- Lifting scheme implementation for all wavelets
|
||||
|
||||
3. **Perceptual Quantisation**
|
||||
- HVS-based subband weights
|
||||
- Anisotropic chroma quantisation (YCoCg-R specific)
|
||||
- Quality-dependent quantisation matrices
|
||||
|
||||
4. **EZBC Encoding**
|
||||
- Binary tree embedded zero block coding per channel
|
||||
- Progressive refinement by bitplanes
|
||||
- Concatenated bitstream layout: `[Y_bitstream][Co_bitstream][Cg_bitstream]`
|
||||
- Cross-channel compression optimisation
|
||||
|
||||
5. **Entropy Coding**
|
||||
- Zstandard compression (level 7) on concatenated EZBC bitstreams
|
||||
- Cross-channel compression opportunities
|
||||
- Adaptive compression based on GOP structure
|
||||
|
||||
### Decoder Pipeline
|
||||
|
||||
1. **Container Parsing**
|
||||
- Packet type identification (0x00-0xFF)
|
||||
- Timecode synchronisation
|
||||
- GOP boundary detection
|
||||
|
||||
2. **Entropy Decoding**
|
||||
- Zstd decompression of concatenated bitstreams
|
||||
- EZBC binary tree decoding per channel
|
||||
- Progressive coefficient reconstruction
|
||||
|
||||
3. **Inverse Quantisation**
|
||||
- Perceptual weight application
|
||||
- Subband-specific scaling
|
||||
- Coefficient reconstruction from sparse representation
|
||||
|
||||
4. **Inverse DWT**
|
||||
- Temporal: 1D inverse DWT across frames (3D DWT mode)
|
||||
- Spatial: 6-level inverse wavelet reconstruction
|
||||
|
||||
5. **Output Conversion**
|
||||
- YCoCg-R to RGB colour space
|
||||
- Clamping and dithering
|
||||
- Frame buffering for display
|
||||
|
||||
### Wavelet Implementation
|
||||
|
||||
All wavelets follow a **lifting scheme** pattern with symmetric boundary extension:
|
||||
|
||||
```c
|
||||
// Forward Transform: Predict → Update
|
||||
temp[half + i] = data[odd] - predict(data[even]); // High-pass
|
||||
temp[i] = data[even] + update(temp[half]); // Low-pass
|
||||
|
||||
// Inverse Transform: Undo Update → Undo Predict (reversed order)
|
||||
data[even] = temp[i] - update(temp[half]); // Undo low-pass
|
||||
data[odd] = temp[half + i] + predict(data[even]); // Undo high-pass
|
||||
```
|
||||
|
||||
**Critical**: Forward and inverse transforms must use identical coefficient indexing and exactly reverse operations to avoid grid artefacts.
|
||||
|
||||
### Coefficient Layout
|
||||
|
||||
TAV uses **2D Spatial Layout** in memory for each decomposition level:
|
||||
|
||||
```
|
||||
[LL] [LH] [HL] [HH] [LH] [HL] [HH] ...
|
||||
└── Level 0 ──┘ └─── Level 1 ───┘
|
||||
```
|
||||
|
||||
- `LL`: Low-pass (approximation) - progressively smaller with each level
|
||||
- `LH`, `HL`, `HH`: High-pass subbands (horizontal, vertical, diagonal detail)
|
||||
|
||||
## Performance Characteristics
|
||||
|
||||
### Compression Efficiency
|
||||
|
||||
- **Sparsity Exploitation**: Typical quantised coefficient sparsity
|
||||
- Y channel: 86.9% zeros
|
||||
- Co channel: 97.8% zeros
|
||||
- Cg channel: 99.5% zeros
|
||||
|
||||
- **EZBC Benefits**: 16-18% compression improvement over naive coefficient encoding through sparsity exploitation
|
||||
|
||||
- **Temporal Coherence**: Additional 15-25% improvement with 3D DWT (content-dependent)
|
||||
|
||||
### Computational Complexity
|
||||
|
||||
- **Encoding**: O(n log n) per frame for spatial DWT
|
||||
- **Decoding**: O(n log n) per frame, optimised lifting scheme implementation
|
||||
- **Memory**: Single-tile encoding requires O(w × h) working memory
|
||||
|
||||
### Quality Characteristics
|
||||
|
||||
- **No blocking artefacts**: Wavelet-based encoding is inherently smooth
|
||||
- **Perceptual optimisation**: Better subjective quality than bitrate-equivalent DCT codecs
|
||||
- **Scalability**: 6 quality levels (0-5) provide wide range of bitrate/quality trade-offs
|
||||
- **Temporal stability**: 3D DWT mode reduces flickering and temporal artefacts
|
||||
|
||||
## Format Specification
|
||||
|
||||
For complete packet structure and bitstream format details, refer to `format documentation.txt`.
|
||||
|
||||
### Key Packet Types
|
||||
|
||||
- `0x00`: Metadata and initialisation
|
||||
- `0x01`: I-frame (intra-coded frame)
|
||||
- `0x12`: GOP unified packet (3D DWT mode)
|
||||
- `0x24`: Embedded TAD audio
|
||||
- `0xFC`: GOP synchronisation
|
||||
- `0xFD`: Timecode
|
||||
|
||||
## Debugging Tools
|
||||
|
||||
### TAV Inspector
|
||||
|
||||
Analyse TAV packet structure and decode individual frames:
|
||||
|
||||
```bash
|
||||
# Verbose packet analysis
|
||||
./tav_inspector input.tav -v
|
||||
|
||||
# Extract specific frame ranges
|
||||
./tav_inspector input.tav --frame-range 100-200
|
||||
```
|
||||
|
||||
## Related Projects
|
||||
|
||||
- **TAD** (TSVM Advanced Audio): Perceptual audio codec using CDF 9/7 wavelets
|
||||
- **TSVM**: Target virtual machine platform for TAV playback
|
||||
|
||||
## Licence
|
||||
|
||||
MIT.
|
||||
@@ -1,424 +0,0 @@
|
||||
/**
|
||||
* TAV+UCF Payload Writer for TAV Files
|
||||
* Creates a TAV header-only (32 bytes) + UCF cue file (4KB) for concatenated TAV files
|
||||
* Total output size: 4096 bytes (32 + 4064)
|
||||
* Usage: ./create_ucf_payload input.tav output.ucf [track_names.txt]
|
||||
*/
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#define TAV_HEADER_SIZE 32
|
||||
#define UCF_SIZE 4064
|
||||
#define TAV_OFFSET_BIAS (TAV_HEADER_SIZE + UCF_SIZE)
|
||||
#define TAV_MAGIC "\x1FTSVMTA" // Matches both TAV and TAP
|
||||
|
||||
typedef struct {
|
||||
uint8_t magic[8];
|
||||
uint8_t version;
|
||||
uint16_t width;
|
||||
uint16_t height;
|
||||
uint8_t fps;
|
||||
uint32_t total_frames;
|
||||
// ... rest of header fields
|
||||
} __attribute__((packed)) TAVHeader;
|
||||
|
||||
// Write TAV header-only payload (File Role = 1)
|
||||
static void write_tav_header_only(FILE *out) {
|
||||
uint8_t header[TAV_HEADER_SIZE] = {0};
|
||||
|
||||
// Magic: "\x1FTSVMTAV"
|
||||
header[0] = 0x1F;
|
||||
header[1] = 'T';
|
||||
header[2] = 'S';
|
||||
header[3] = 'V';
|
||||
header[4] = 'M';
|
||||
header[5] = 'T';
|
||||
header[6] = 'A';
|
||||
header[7] = 'V';
|
||||
|
||||
// Version: 5 (YCoCg-R perceptual)
|
||||
header[8] = 5;
|
||||
|
||||
// Width: 560 (little-endian)
|
||||
header[9] = 0x30;
|
||||
header[10] = 0x02;
|
||||
|
||||
// Height: 448 (little-endian)
|
||||
header[11] = 0xC0;
|
||||
header[12] = 0x01;
|
||||
|
||||
// FPS: 30
|
||||
header[13] = 30;
|
||||
|
||||
// Total Frames: 0xFFFFFFFF (still image marker / not applicable)
|
||||
header[14] = 0xFF;
|
||||
header[15] = 0xFF;
|
||||
header[16] = 0xFF;
|
||||
header[17] = 0xFF;
|
||||
|
||||
// Wavelet Filter Type: 1 (9/7 irreversible, default)
|
||||
header[18] = 1;
|
||||
|
||||
// Decomposition Levels: 6
|
||||
header[19] = 6;
|
||||
|
||||
// Quantiser Indices (Y, Co, Cg): 255 (not applicable for header-only)
|
||||
header[20] = 0xFF;
|
||||
header[21] = 0xFF;
|
||||
header[22] = 0xFF;
|
||||
|
||||
// Extra Feature Flags: 0x80 (bit 7 = has no actual packets)
|
||||
header[23] = 0x80;
|
||||
|
||||
// Video Flags: 0
|
||||
header[24] = 0;
|
||||
|
||||
// Encoder quality level: 0
|
||||
header[25] = 0;
|
||||
|
||||
// Channel layout: 0 (Y-Co-Cg)
|
||||
header[26] = 0;
|
||||
|
||||
// Reserved[4]: zeros (27-30 already initialised to 0)
|
||||
|
||||
// File Role: 1 (header-only, UCF payload follows)
|
||||
header[31] = 1;
|
||||
|
||||
fwrite(header, 1, TAV_HEADER_SIZE, out);
|
||||
}
|
||||
|
||||
// Write UCF header
|
||||
static void write_ucf_header(FILE *out, uint16_t num_cues) {
|
||||
uint8_t magic[8] = {0x1F, 'T', 'S', 'V', 'M', 'U', 'C', 'F'};
|
||||
uint8_t version = 1;
|
||||
uint32_t cue_file_size = TAV_OFFSET_BIAS;
|
||||
uint8_t reserved = 0;
|
||||
|
||||
fwrite(magic, 1, 8, out);
|
||||
fwrite(&version, 1, 1, out);
|
||||
fwrite(&num_cues, 2, 1, out);
|
||||
fwrite(&cue_file_size, 4, 1, out);
|
||||
fwrite(&reserved, 1, 1, out);
|
||||
}
|
||||
|
||||
// Write UCF cue element (internal addressing, human+machine interactable)
|
||||
static void write_cue_element(FILE *out, uint64_t offset, const char *name) {
|
||||
uint8_t addressing_mode = 0x22; // 0x20 (human) | 0x01 (machine) | 0x02 (internal)
|
||||
uint16_t name_len = strlen(name);
|
||||
|
||||
// Offset with 4KB bias
|
||||
uint64_t biased_offset = offset + TAV_OFFSET_BIAS;
|
||||
|
||||
fwrite(&addressing_mode, 1, 1, out);
|
||||
fwrite(&name_len, 2, 1, out);
|
||||
fwrite(name, 1, name_len, out);
|
||||
|
||||
// Write 48-bit (6-byte) offset
|
||||
fwrite(&biased_offset, 6, 1, out);
|
||||
}
|
||||
|
||||
// Read track names from file (newline-delimited)
|
||||
static char **read_track_names(const char *filename, int *count_out) {
|
||||
FILE *f = fopen(filename, "r");
|
||||
if (!f) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
char **names = NULL;
|
||||
int count = 0;
|
||||
int capacity = 16;
|
||||
char line[256];
|
||||
|
||||
names = malloc(capacity * sizeof(char *));
|
||||
if (!names) {
|
||||
fclose(f);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
while (fgets(line, sizeof(line), f)) {
|
||||
// Remove trailing newline
|
||||
size_t len = strlen(line);
|
||||
if (len > 0 && line[len - 1] == '\n') {
|
||||
line[len - 1] = '\0';
|
||||
len--;
|
||||
}
|
||||
if (len > 0 && line[len - 1] == '\r') {
|
||||
line[len - 1] = '\0';
|
||||
len--;
|
||||
}
|
||||
|
||||
// Skip empty lines
|
||||
if (len == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Expand capacity if needed
|
||||
if (count >= capacity) {
|
||||
capacity *= 2;
|
||||
char **new_names = realloc(names, capacity * sizeof(char *));
|
||||
if (!new_names) {
|
||||
// Cleanup on failure
|
||||
for (int i = 0; i < count; i++) {
|
||||
free(names[i]);
|
||||
}
|
||||
free(names);
|
||||
fclose(f);
|
||||
return NULL;
|
||||
}
|
||||
names = new_names;
|
||||
}
|
||||
|
||||
// Allocate and copy name
|
||||
names[count] = strdup(line);
|
||||
if (!names[count]) {
|
||||
// Cleanup on failure
|
||||
for (int i = 0; i < count; i++) {
|
||||
free(names[i]);
|
||||
}
|
||||
free(names);
|
||||
fclose(f);
|
||||
return NULL;
|
||||
}
|
||||
count++;
|
||||
}
|
||||
|
||||
fclose(f);
|
||||
*count_out = count;
|
||||
return names;
|
||||
}
|
||||
|
||||
// Find all TAV headers in the file (with smart packet-wise skipping)
|
||||
static int find_tav_headers(FILE *in, uint64_t **offsets_out) {
|
||||
uint64_t *offsets = NULL;
|
||||
int count = 0;
|
||||
int capacity = 16;
|
||||
|
||||
offsets = malloc(capacity * sizeof(uint64_t));
|
||||
if (!offsets) {
|
||||
fprintf(stderr, "Error: Memory allocation failed\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Seek to beginning
|
||||
fseek(in, 0, SEEK_SET);
|
||||
|
||||
uint8_t magic[8];
|
||||
|
||||
while (1) {
|
||||
// Remember current position before reading
|
||||
uint64_t pos = ftell(in);
|
||||
|
||||
// Try to read magic
|
||||
if (fread(magic, 1, 8, in) != 8) {
|
||||
// End of file
|
||||
break;
|
||||
}
|
||||
|
||||
// Check for TAV magic signature
|
||||
if (memcmp(magic, TAV_MAGIC, 7) == 0 && (magic[7] == 'V' || magic[7] == 'P')) {
|
||||
// Found TAV header
|
||||
if (count >= capacity) {
|
||||
capacity *= 2;
|
||||
uint64_t *new_offsets = realloc(offsets, capacity * sizeof(uint64_t));
|
||||
if (!new_offsets) {
|
||||
fprintf(stderr, "Error: Memory reallocation failed\n");
|
||||
free(offsets);
|
||||
return -1;
|
||||
}
|
||||
offsets = new_offsets;
|
||||
}
|
||||
|
||||
offsets[count++] = pos;
|
||||
printf("Found TAV header at offset: 0x%lX (%lu)\n", pos, pos);
|
||||
|
||||
// Skip past this header (32 bytes total)
|
||||
uint64_t packet_pos = pos + 32;
|
||||
fseek(in, packet_pos, SEEK_SET);
|
||||
|
||||
// Smart packet-wise skipping
|
||||
while (1) {
|
||||
uint8_t packet_type;
|
||||
if (fread(&packet_type, 1, 1, in) != 1) {
|
||||
// End of file
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if this is the start of next TAV file (0x1F is prohibited as packet type)
|
||||
if (packet_type == 0x1F) {
|
||||
// Rewind 1 byte to re-read as magic at the top of outer loop
|
||||
fseek(in, packet_pos, SEEK_SET);
|
||||
break;
|
||||
}
|
||||
|
||||
// printf("TAV Packet 0x%02X at 0x%lX\n", packet_type, packet_pos);
|
||||
|
||||
// Sync packets (0xFE, 0xFF) have no payload size - they're single-byte packets
|
||||
if (packet_type == 0xFE || packet_type == 0xFF) {
|
||||
packet_pos += 1;
|
||||
fseek(in, packet_pos, SEEK_SET);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Read payload size (uint32, little-endian)
|
||||
uint32_t payload_size = 0;
|
||||
if (fread(&payload_size, 4, 1, in) != 1) {
|
||||
// End of file
|
||||
break;
|
||||
}
|
||||
|
||||
// Skip packet: 1 byte (type) + 4 bytes (size) + payload_size
|
||||
packet_pos += 1 + 4 + payload_size;
|
||||
fseek(in, packet_pos, SEEK_SET);
|
||||
}
|
||||
} else {
|
||||
// Move forward by 1 byte for next search
|
||||
fseek(in, pos + 1, SEEK_SET);
|
||||
}
|
||||
}
|
||||
|
||||
*offsets_out = offsets;
|
||||
return count;
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
if (argc < 3 || argc > 4) {
|
||||
fprintf(stderr, "Usage: %s <input.tav> <output.ucf> [track_names.txt]\n", argv[0]);
|
||||
fprintf(stderr, "Creates a 4KB UCF payload for concatenated TAV file\n");
|
||||
fprintf(stderr, " track_names.txt: Optional file with track names (one per line)\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
const char *input_path = argv[1];
|
||||
const char *output_path = argv[2];
|
||||
const char *names_path = (argc == 4) ? argv[3] : NULL;
|
||||
|
||||
// Read track names if provided
|
||||
char **track_names = NULL;
|
||||
int num_names = 0;
|
||||
if (names_path) {
|
||||
track_names = read_track_names(names_path, &num_names);
|
||||
if (track_names) {
|
||||
printf("Loaded %d track name(s) from '%s'\n", num_names, names_path);
|
||||
} else {
|
||||
fprintf(stderr, "Warning: Could not read track names from '%s', using defaults\n", names_path);
|
||||
}
|
||||
}
|
||||
|
||||
// Open input file
|
||||
FILE *in = fopen(input_path, "rb");
|
||||
if (!in) {
|
||||
fprintf(stderr, "Error: Cannot open input file '%s'\n", input_path);
|
||||
if (track_names) {
|
||||
for (int i = 0; i < num_names; i++) {
|
||||
free(track_names[i]);
|
||||
}
|
||||
free(track_names);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Find all TAV headers
|
||||
uint64_t *offsets = NULL;
|
||||
int num_tracks = find_tav_headers(in, &offsets);
|
||||
fclose(in);
|
||||
|
||||
if (num_tracks < 0) {
|
||||
fprintf(stderr, "Error: Failed to scan input file\n");
|
||||
if (track_names) {
|
||||
for (int i = 0; i < num_names; i++) {
|
||||
free(track_names[i]);
|
||||
}
|
||||
free(track_names);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (num_tracks == 0) {
|
||||
fprintf(stderr, "Error: No TAV headers found in input file\n");
|
||||
free(offsets);
|
||||
if (track_names) {
|
||||
for (int i = 0; i < num_names; i++) {
|
||||
free(track_names[i]);
|
||||
}
|
||||
free(track_names);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
printf("\nFound %d TAV header(s)\n", num_tracks);
|
||||
|
||||
// Create output UCF file
|
||||
FILE *out = fopen(output_path, "wb");
|
||||
if (!out) {
|
||||
fprintf(stderr, "Error: Cannot create output file '%s'\n", output_path);
|
||||
free(offsets);
|
||||
if (track_names) {
|
||||
for (int i = 0; i < num_names; i++) {
|
||||
free(track_names[i]);
|
||||
}
|
||||
free(track_names);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Write TAV header-only payload (File Role = 1)
|
||||
write_tav_header_only(out);
|
||||
printf("Written TAV header-only payload (%d bytes)\n", TAV_HEADER_SIZE);
|
||||
|
||||
// Write UCF header
|
||||
write_ucf_header(out, num_tracks);
|
||||
|
||||
// Write cue elements
|
||||
for (int i = 0; i < num_tracks; i++) {
|
||||
char default_name[32];
|
||||
const char *name;
|
||||
|
||||
// Use custom name if available, otherwise generate default
|
||||
if (track_names && i < num_names) {
|
||||
name = track_names[i];
|
||||
} else {
|
||||
snprintf(default_name, sizeof(default_name), "Track %d", i + 1);
|
||||
name = default_name;
|
||||
}
|
||||
|
||||
write_cue_element(out, offsets[i], name);
|
||||
printf("Written cue element: '%s' at offset 0x%lX (biased: 0x%lX)\n",
|
||||
name, offsets[i], offsets[i] + TAV_OFFSET_BIAS);
|
||||
}
|
||||
|
||||
// Get current file position
|
||||
long current_pos = ftell(out);
|
||||
|
||||
// Fill remaining space with zeros to reach TAV header + 4KB UCF
|
||||
size_t target_size = TAV_HEADER_SIZE + UCF_SIZE;
|
||||
if (current_pos < target_size) {
|
||||
size_t remaining = target_size - current_pos;
|
||||
uint8_t *zeros = calloc(remaining, 1);
|
||||
if (zeros) {
|
||||
fwrite(zeros, 1, remaining, out);
|
||||
free(zeros);
|
||||
}
|
||||
}
|
||||
|
||||
fclose(out);
|
||||
free(offsets);
|
||||
|
||||
// Clean up track names
|
||||
if (track_names) {
|
||||
for (int i = 0; i < num_names; i++) {
|
||||
free(track_names[i]);
|
||||
}
|
||||
free(track_names);
|
||||
}
|
||||
|
||||
printf("\nTAV+UCF payload created successfully: %s\n", output_path);
|
||||
printf("File size: %zu bytes (TAV header: %d + UCF: %d)\n",
|
||||
(size_t)(TAV_HEADER_SIZE + UCF_SIZE), TAV_HEADER_SIZE, UCF_SIZE);
|
||||
printf("\nTo create seekable TAV file, prepend this payload to your concatenated TAV file:\n");
|
||||
printf(" cat %s input.tav > output_seekable.tav\n", output_path);
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -1,935 +0,0 @@
|
||||
#define _GNU_SOURCE
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <math.h>
|
||||
#include <zlib.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/wait.h>
|
||||
#include <getopt.h>
|
||||
#include <sys/time.h>
|
||||
|
||||
// TVDOS Movie format constants
|
||||
#define TVDOS_MAGIC "\x1F\x54\x53\x56\x4D\x4D\x4F\x56" // "\x1FTSVM MOV"
|
||||
#define IPF_BLOCK_SIZE 12
|
||||
|
||||
// iPF1-delta opcodes
|
||||
#define SKIP_OP 0x00
|
||||
#define PATCH_OP 0x01
|
||||
#define REPEAT_OP 0x02
|
||||
#define END_OP 0xFF
|
||||
|
||||
// Video packet types
|
||||
#define IPF1_PACKET_TYPE 0x04, 0x00 // iPF Type 1 (4 + 0)
|
||||
#define IPF1_DELTA_PACKET_TYPE 0x04, 0x02 // iPF Type 1 delta
|
||||
#define SYNC_PACKET_TYPE 0xFF, 0xFF // Sync packet
|
||||
|
||||
// Audio constants
|
||||
#define MP2_SAMPLE_RATE 32000
|
||||
#define MP2_DEFAULT_PACKET_SIZE 0x240
|
||||
#define MP2_PACKET_TYPE_BASE 0x11
|
||||
|
||||
// Default values
|
||||
#define DEFAULT_WIDTH 560
|
||||
#define DEFAULT_HEIGHT 448
|
||||
#define TEMP_AUDIO_FILE "/tmp/tvdos_temp_audio.mp2"
|
||||
|
||||
typedef struct {
|
||||
char *input_file;
|
||||
char *output_file;
|
||||
int width;
|
||||
int height;
|
||||
int fps;
|
||||
int total_frames;
|
||||
double duration;
|
||||
int has_audio;
|
||||
int output_to_stdout;
|
||||
|
||||
// Internal buffers
|
||||
uint8_t *previous_ipf_frame;
|
||||
uint8_t *current_ipf_frame;
|
||||
uint8_t *delta_buffer;
|
||||
uint8_t *rgb_buffer;
|
||||
uint8_t *compressed_buffer;
|
||||
uint8_t *mp2_buffer;
|
||||
size_t frame_buffer_size;
|
||||
|
||||
// Audio handling
|
||||
FILE *mp2_file;
|
||||
int mp2_packet_size;
|
||||
int mp2_rate_index;
|
||||
size_t audio_remaining;
|
||||
int audio_frames_in_buffer;
|
||||
int target_audio_buffer_size;
|
||||
|
||||
// FFmpeg processes
|
||||
FILE *ffmpeg_video_pipe;
|
||||
FILE *ffmpeg_audio_pipe;
|
||||
|
||||
// Progress tracking
|
||||
struct timeval start_time;
|
||||
struct timeval last_progress_time;
|
||||
size_t total_output_bytes;
|
||||
|
||||
// Dithering mode
|
||||
int dither_mode;
|
||||
} encoder_config_t;
|
||||
|
||||
// CORRECTED YCoCg conversion matching Kotlin implementation
|
||||
typedef struct {
|
||||
float y, co, cg;
|
||||
} ycocg_t;
|
||||
|
||||
static ycocg_t rgb_to_ycocg_correct(uint8_t r, uint8_t g, uint8_t b, float ditherThreshold) {
|
||||
ycocg_t result;
|
||||
float rf = floor((ditherThreshold / 15.0 + r / 255.0) * 15.0) / 15.0;
|
||||
float gf = floor((ditherThreshold / 15.0 + g / 255.0) * 15.0) / 15.0;
|
||||
float bf = floor((ditherThreshold / 15.0 + b / 255.0) * 15.0) / 15.0;
|
||||
|
||||
// CORRECTED: Match Kotlin implementation exactly
|
||||
float co = rf - bf; // co = r - b [-1..1]
|
||||
float tmp = bf + co / 2.0f; // tmp = b + co/2
|
||||
float cg = gf - tmp; // cg = g - tmp [-1..1]
|
||||
float y = tmp + cg / 2.0f; // y = tmp + cg/2 [0..1]
|
||||
|
||||
result.y = y;
|
||||
result.co = co;
|
||||
result.cg = cg;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static int quantise_4bit_y(float value) {
|
||||
// Y quantisation: round(y * 15)
|
||||
return (int)round(fmaxf(0.0f, fminf(15.0f, value * 15.0f)));
|
||||
}
|
||||
|
||||
static int chroma_to_four_bits(float f) {
|
||||
// CORRECTED: Match Kotlin chromaToFourBits function exactly
|
||||
// return (round(f * 8) + 7).coerceIn(0..15)
|
||||
int result = (int)round(f * 8.0f) + 7;
|
||||
return fmaxf(0, fminf(15, result));
|
||||
}
|
||||
|
||||
// Parse resolution string like "1024x768"
|
||||
static int parse_resolution(const char *res_str, int *width, int *height) {
|
||||
if (!res_str) return 0;
|
||||
return sscanf(res_str, "%dx%d", width, height) == 2;
|
||||
}
|
||||
|
||||
// Execute command and capture output
|
||||
static char *execute_command(const char *command) {
|
||||
FILE *pipe = popen(command, "r");
|
||||
if (!pipe) return NULL;
|
||||
|
||||
char *result = malloc(4096);
|
||||
size_t len = fread(result, 1, 4095, pipe);
|
||||
result[len] = '\0';
|
||||
|
||||
pclose(pipe);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get video metadata using ffprobe
|
||||
static int get_video_metadata(encoder_config_t *config) {
|
||||
char command[1024];
|
||||
char *output;
|
||||
|
||||
// Get frame count
|
||||
snprintf(command, sizeof(command),
|
||||
"ffprobe -v quiet -select_streams v:0 -count_frames -show_entries stream=nb_read_frames -of csv=p=0 \"%s\"",
|
||||
config->input_file);
|
||||
output = execute_command(command);
|
||||
if (!output) {
|
||||
fprintf(stderr, "Failed to get frame count\n");
|
||||
return 0;
|
||||
}
|
||||
config->total_frames = atoi(output);
|
||||
free(output);
|
||||
|
||||
// Get frame rate
|
||||
snprintf(command, sizeof(command),
|
||||
"ffprobe -v quiet -select_streams v:0 -show_entries stream=r_frame_rate -of csv=p=0 \"%s\"",
|
||||
config->input_file);
|
||||
output = execute_command(command);
|
||||
if (!output) {
|
||||
fprintf(stderr, "Failed to get frame rate\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Parse framerate (could be "30/1" or "29.97")
|
||||
int num, den;
|
||||
if (sscanf(output, "%d/%d", &num, &den) == 2) {
|
||||
config->fps = (den > 0) ? (num / den) : 30;
|
||||
} else {
|
||||
config->fps = (int)round(atof(output));
|
||||
}
|
||||
free(output);
|
||||
|
||||
// Get duration
|
||||
snprintf(command, sizeof(command),
|
||||
"ffprobe -v quiet -show_entries format=duration -of csv=p=0 \"%s\"",
|
||||
config->input_file);
|
||||
output = execute_command(command);
|
||||
if (output) {
|
||||
config->duration = atof(output);
|
||||
free(output);
|
||||
}
|
||||
|
||||
// Check if has audio
|
||||
snprintf(command, sizeof(command),
|
||||
"ffprobe -v quiet -select_streams a:0 -show_entries stream=index -of csv=p=0 \"%s\"",
|
||||
config->input_file);
|
||||
output = execute_command(command);
|
||||
config->has_audio = (output && strlen(output) > 0 && atoi(output) >= 0);
|
||||
if (output) free(output);
|
||||
|
||||
// Validate frame count using duration if needed
|
||||
if (config->total_frames <= 0 && config->duration > 0) {
|
||||
config->total_frames = (int)(config->duration * config->fps);
|
||||
}
|
||||
|
||||
fprintf(stderr, "Video metadata:\n");
|
||||
fprintf(stderr, " Frames: %d\n", config->total_frames);
|
||||
fprintf(stderr, " FPS: %d\n", config->fps);
|
||||
fprintf(stderr, " Duration: %.2fs\n", config->duration);
|
||||
fprintf(stderr, " Audio: %s\n", config->has_audio ? "Yes" : "No");
|
||||
fprintf(stderr, " Resolution: %dx%d\n", config->width, config->height);
|
||||
|
||||
return (config->total_frames > 0 && config->fps > 0);
|
||||
}
|
||||
|
||||
// Start FFmpeg process for video conversion
|
||||
static int start_video_conversion(encoder_config_t *config) {
|
||||
char command[2048];
|
||||
snprintf(command, sizeof(command),
|
||||
"ffmpeg -i \"%s\" -f rawvideo -pix_fmt rgb24 -vf scale=%d:%d:force_original_aspect_ratio=increase,crop=%d:%d -y - 2>/dev/null",
|
||||
config->input_file, config->width, config->height, config->width, config->height);
|
||||
|
||||
config->ffmpeg_video_pipe = popen(command, "r");
|
||||
return (config->ffmpeg_video_pipe != NULL);
|
||||
}
|
||||
|
||||
// Start FFmpeg process for audio conversion
|
||||
static int start_audio_conversion(encoder_config_t *config) {
|
||||
if (!config->has_audio) return 1;
|
||||
|
||||
char command[2048];
|
||||
snprintf(command, sizeof(command),
|
||||
"ffmpeg -i \"%s\" -acodec libtwolame -psymodel 4 -b:a 192k -ar %d -ac 2 -y \"%s\" 2>/dev/null",
|
||||
config->input_file, MP2_SAMPLE_RATE, TEMP_AUDIO_FILE);
|
||||
|
||||
int result = system(command);
|
||||
if (result == 0) {
|
||||
config->mp2_file = fopen(TEMP_AUDIO_FILE, "rb");
|
||||
if (config->mp2_file) {
|
||||
fseek(config->mp2_file, 0, SEEK_END);
|
||||
config->audio_remaining = ftell(config->mp2_file);
|
||||
fseek(config->mp2_file, 0, SEEK_SET);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
fprintf(stderr, "Warning: Failed to convert audio, proceeding without audio\n");
|
||||
config->has_audio = 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Write variable-length integer
|
||||
static void write_varint(uint8_t **ptr, uint32_t value) {
|
||||
while (value >= 0x80) {
|
||||
**ptr = (uint8_t)((value & 0x7F) | 0x80);
|
||||
(*ptr)++;
|
||||
value >>= 7;
|
||||
}
|
||||
**ptr = (uint8_t)(value & 0x7F);
|
||||
(*ptr)++;
|
||||
}
|
||||
|
||||
// Get MP2 packet size and rate index
|
||||
static int get_mp2_packet_size(uint8_t *header) {
|
||||
int bitrate_index = (header[2] >> 4) & 0xF;
|
||||
int padding_bit = (header[2] >> 1) & 0x1;
|
||||
|
||||
int bitrates[] = {0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, -1};
|
||||
int bitrate = bitrates[bitrate_index];
|
||||
|
||||
if (bitrate <= 0) return MP2_DEFAULT_PACKET_SIZE;
|
||||
|
||||
int frame_size = (144 * bitrate * 1000) / MP2_SAMPLE_RATE + padding_bit;
|
||||
return frame_size;
|
||||
}
|
||||
|
||||
static int mp2_packet_size_to_rate_index(int packet_size, int is_mono) {
|
||||
int rate_index;
|
||||
switch (packet_size) {
|
||||
case 144: rate_index = 0; break;
|
||||
case 216: rate_index = 2; break;
|
||||
case 252: rate_index = 4; break;
|
||||
case 288: rate_index = 6; break;
|
||||
case 360: rate_index = 8; break;
|
||||
case 432: rate_index = 10; break;
|
||||
case 504: rate_index = 12; break;
|
||||
case 576: rate_index = 14; break;
|
||||
case 720: rate_index = 16; break;
|
||||
case 864: rate_index = 18; break;
|
||||
case 1008: rate_index = 20; break;
|
||||
case 1152: rate_index = 22; break;
|
||||
case 1440: rate_index = 24; break;
|
||||
case 1728: rate_index = 26; break;
|
||||
default: rate_index = 14; break;
|
||||
}
|
||||
return rate_index + (is_mono ? 1 : 0);
|
||||
}
|
||||
|
||||
// Gzip compress function (instead of zlib)
|
||||
static size_t gzip_compress(uint8_t *src, size_t src_len, uint8_t *dst, size_t dst_max) {
|
||||
z_stream stream = {0};
|
||||
stream.next_in = src;
|
||||
stream.avail_in = src_len;
|
||||
stream.next_out = dst;
|
||||
stream.avail_out = dst_max;
|
||||
|
||||
// Use deflateInit2 with gzip format
|
||||
if (deflateInit2(&stream, Z_DEFAULT_COMPRESSION, Z_DEFLATED, 15 + 16, 8, Z_DEFAULT_STRATEGY) != Z_OK) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (deflate(&stream, Z_FINISH) != Z_STREAM_END) {
|
||||
deflateEnd(&stream);
|
||||
return 0;
|
||||
}
|
||||
|
||||
size_t compressed_size = stream.total_out;
|
||||
deflateEnd(&stream);
|
||||
return compressed_size;
|
||||
}
|
||||
|
||||
// Bayer dithering kernels (4 patterns, each 4x4)
|
||||
static const float bayerKernels[4][16] = {
|
||||
{ // Pattern 0
|
||||
(0.0f + 0.5f) / 16.0f, (8.0f + 0.5f) / 16.0f, (2.0f + 0.5f) / 16.0f, (10.0f + 0.5f) / 16.0f,
|
||||
(12.0f + 0.5f) / 16.0f, (4.0f + 0.5f) / 16.0f, (14.0f + 0.5f) / 16.0f, (6.0f + 0.5f) / 16.0f,
|
||||
(3.0f + 0.5f) / 16.0f, (11.0f + 0.5f) / 16.0f, (1.0f + 0.5f) / 16.0f, (9.0f + 0.5f) / 16.0f,
|
||||
(15.0f + 0.5f) / 16.0f, (7.0f + 0.5f) / 16.0f, (13.0f + 0.5f) / 16.0f, (5.0f + 0.5f) / 16.0f
|
||||
},
|
||||
{ // Pattern 1
|
||||
(8.0f + 0.5f) / 16.0f, (2.0f + 0.5f) / 16.0f, (10.0f + 0.5f) / 16.0f, (0.0f + 0.5f) / 16.0f,
|
||||
(4.0f + 0.5f) / 16.0f, (14.0f + 0.5f) / 16.0f, (6.0f + 0.5f) / 16.0f, (12.0f + 0.5f) / 16.0f,
|
||||
(11.0f + 0.5f) / 16.0f, (1.0f + 0.5f) / 16.0f, (9.0f + 0.5f) / 16.0f, (3.0f + 0.5f) / 16.0f,
|
||||
(7.0f + 0.5f) / 16.0f, (13.0f + 0.5f) / 16.0f, (5.0f + 0.5f) / 16.0f, (15.0f + 0.5f) / 16.0f
|
||||
},
|
||||
{ // Pattern 2
|
||||
(7.0f + 0.5f) / 16.0f, (13.0f + 0.5f) / 16.0f, (5.0f + 0.5f) / 16.0f, (15.0f + 0.5f) / 16.0f,
|
||||
(8.0f + 0.5f) / 16.0f, (2.0f + 0.5f) / 16.0f, (10.0f + 0.5f) / 16.0f, (0.0f + 0.5f) / 16.0f,
|
||||
(4.0f + 0.5f) / 16.0f, (14.0f + 0.5f) / 16.0f, (6.0f + 0.5f) / 16.0f, (12.0f + 0.5f) / 16.0f,
|
||||
(11.0f + 0.5f) / 16.0f, (1.0f + 0.5f) / 16.0f, (9.0f + 0.5f) / 16.0f, (3.0f + 0.5f) / 16.0f
|
||||
},
|
||||
{ // Pattern 3
|
||||
(15.0f + 0.5f) / 16.0f, (7.0f + 0.5f) / 16.0f, (13.0f + 0.5f) / 16.0f, (5.0f + 0.5f) / 16.0f,
|
||||
(0.0f + 0.5f) / 16.0f, (8.0f + 0.5f) / 16.0f, (2.0f + 0.5f) / 16.0f, (10.0f + 0.5f) / 16.0f,
|
||||
(12.0f + 0.5f) / 16.0f, (4.0f + 0.5f) / 16.0f, (14.0f + 0.5f) / 16.0f, (6.0f + 0.5f) / 16.0f,
|
||||
(3.0f + 0.5f) / 16.0f, (11.0f + 0.5f) / 16.0f, (1.0f + 0.5f) / 16.0f, (9.0f + 0.5f) / 16.0f
|
||||
}
|
||||
};
|
||||
|
||||
// CORRECTED: Encode a 4x4 block to iPF1 format matching Kotlin implementation
|
||||
static void encode_ipf1_block_correct(uint8_t *rgb_data, int width, int height, int block_x, int block_y,
|
||||
int channels, int pattern, uint8_t *output) {
|
||||
ycocg_t pixels[16];
|
||||
int y_values[16];
|
||||
float co_values[16]; // Keep full precision for subsampling
|
||||
float cg_values[16]; // Keep full precision for subsampling
|
||||
|
||||
// Convert 4x4 block to YCoCg using corrected transform
|
||||
for (int py = 0; py < 4; py++) {
|
||||
for (int px = 0; px < 4; px++) {
|
||||
int src_x = block_x * 4 + px;
|
||||
int src_y = block_y * 4 + py;
|
||||
float t = (pattern < 0) ? 0.0f : bayerKernels[pattern % 4][4 * (py % 4) + (px % 4)];
|
||||
int idx = py * 4 + px;
|
||||
|
||||
if (src_x < width && src_y < height) {
|
||||
int pixel_offset = (src_y * width + src_x) * channels;
|
||||
uint8_t r = rgb_data[pixel_offset];
|
||||
uint8_t g = rgb_data[pixel_offset + 1];
|
||||
uint8_t b = rgb_data[pixel_offset + 2];
|
||||
pixels[idx] = rgb_to_ycocg_correct(r, g, b, t);
|
||||
} else {
|
||||
pixels[idx] = (ycocg_t){0.0f, 0.0f, 0.0f};
|
||||
}
|
||||
|
||||
y_values[idx] = quantise_4bit_y(pixels[idx].y);
|
||||
co_values[idx] = pixels[idx].co;
|
||||
cg_values[idx] = pixels[idx].cg;
|
||||
}
|
||||
}
|
||||
|
||||
// CORRECTED: Chroma subsampling (4:2:0 for iPF1) with correct averaging
|
||||
int cos1 = chroma_to_four_bits((co_values[0] + co_values[1] + co_values[4] + co_values[5]) / 4.0f);
|
||||
int cos2 = chroma_to_four_bits((co_values[2] + co_values[3] + co_values[6] + co_values[7]) / 4.0f);
|
||||
int cos3 = chroma_to_four_bits((co_values[8] + co_values[9] + co_values[12] + co_values[13]) / 4.0f);
|
||||
int cos4 = chroma_to_four_bits((co_values[10] + co_values[11] + co_values[14] + co_values[15]) / 4.0f);
|
||||
|
||||
int cgs1 = chroma_to_four_bits((cg_values[0] + cg_values[1] + cg_values[4] + cg_values[5]) / 4.0f);
|
||||
int cgs2 = chroma_to_four_bits((cg_values[2] + cg_values[3] + cg_values[6] + cg_values[7]) / 4.0f);
|
||||
int cgs3 = chroma_to_four_bits((cg_values[8] + cg_values[9] + cg_values[12] + cg_values[13]) / 4.0f);
|
||||
int cgs4 = chroma_to_four_bits((cg_values[10] + cg_values[11] + cg_values[14] + cg_values[15]) / 4.0f);
|
||||
|
||||
// CORRECTED: Pack into iPF1 format matching Kotlin exactly
|
||||
// Co values (2 bytes): cos2|cos1, cos4|cos3
|
||||
output[0] = ((cos2 << 4) | cos1);
|
||||
output[1] = ((cos4 << 4) | cos3);
|
||||
|
||||
// Cg values (2 bytes): cgs2|cgs1, cgs4|cgs3
|
||||
output[2] = ((cgs2 << 4) | cgs1);
|
||||
output[3] = ((cgs4 << 4) | cgs3);
|
||||
|
||||
// CORRECTED: Y values (8 bytes) with correct ordering from Kotlin
|
||||
output[4] = ((y_values[1] << 4) | y_values[0]); // Y1|Y0
|
||||
output[5] = ((y_values[5] << 4) | y_values[4]); // Y5|Y4
|
||||
output[6] = ((y_values[3] << 4) | y_values[2]); // Y3|Y2
|
||||
output[7] = ((y_values[7] << 4) | y_values[6]); // Y7|Y6
|
||||
output[8] = ((y_values[9] << 4) | y_values[8]); // Y9|Y8
|
||||
output[9] = ((y_values[13] << 4) | y_values[12]); // Y13|Y12
|
||||
output[10] = ((y_values[11] << 4) | y_values[10]); // Y11|Y10
|
||||
output[11] = ((y_values[15] << 4) | y_values[14]); // Y15|Y14
|
||||
}
|
||||
|
||||
// Helper function for contrast weighting
|
||||
static double contrast_weight(int v1, int v2, int delta, int weight) {
|
||||
double avg = (v1 + v2) / 2.0;
|
||||
double contrast = (avg < 4 || avg > 11) ? 1.5 : 1.0;
|
||||
return delta * weight * contrast;
|
||||
}
|
||||
|
||||
// Check if two iPF1 blocks are significantly different
|
||||
static int is_significantly_different(uint8_t *block_a, uint8_t *block_b) {
|
||||
double score = 0.0;
|
||||
|
||||
// Co values (bytes 0-1)
|
||||
uint16_t co_a = block_a[0] | (block_a[1] << 8);
|
||||
uint16_t co_b = block_b[0] | (block_b[1] << 8);
|
||||
for (int i = 0; i < 4; i++) {
|
||||
int va = (co_a >> (i * 4)) & 0xF;
|
||||
int vb = (co_b >> (i * 4)) & 0xF;
|
||||
int delta = abs(va - vb);
|
||||
score += contrast_weight(va, vb, delta, 3);
|
||||
}
|
||||
|
||||
// Cg values (bytes 2-3)
|
||||
uint16_t cg_a = block_a[2] | (block_a[3] << 8);
|
||||
uint16_t cg_b = block_b[2] | (block_b[3] << 8);
|
||||
for (int i = 0; i < 4; i++) {
|
||||
int va = (cg_a >> (i * 4)) & 0xF;
|
||||
int vb = (cg_b >> (i * 4)) & 0xF;
|
||||
int delta = abs(va - vb);
|
||||
score += contrast_weight(va, vb, delta, 3);
|
||||
}
|
||||
|
||||
// Y values (bytes 4-11)
|
||||
for (int i = 4; i < 12; i++) {
|
||||
int byte_a = block_a[i] & 0xFF;
|
||||
int byte_b = block_b[i] & 0xFF;
|
||||
|
||||
int y_a_high = (byte_a >> 4) & 0xF;
|
||||
int y_a_low = byte_a & 0xF;
|
||||
int y_b_high = (byte_b >> 4) & 0xF;
|
||||
int y_b_low = byte_b & 0xF;
|
||||
|
||||
int delta_high = abs(y_a_high - y_b_high);
|
||||
int delta_low = abs(y_a_low - y_b_low);
|
||||
|
||||
score += contrast_weight(y_a_high, y_b_high, delta_high, 2);
|
||||
score += contrast_weight(y_a_low, y_b_low, delta_low, 2);
|
||||
}
|
||||
|
||||
return score > 4.0;
|
||||
}
|
||||
|
||||
// Encode iPF1 frame to buffer
|
||||
static void encode_ipf1_frame(uint8_t *rgb_data, int width, int height, int channels, int pattern,
|
||||
uint8_t *ipf_buffer) {
|
||||
int blocks_per_row = (width + 3) / 4;
|
||||
int blocks_per_col = (height + 3) / 4;
|
||||
|
||||
for (int block_y = 0; block_y < blocks_per_col; block_y++) {
|
||||
for (int block_x = 0; block_x < blocks_per_row; block_x++) {
|
||||
int block_index = block_y * blocks_per_row + block_x;
|
||||
uint8_t *output_block = ipf_buffer + block_index * IPF_BLOCK_SIZE;
|
||||
encode_ipf1_block_correct(rgb_data, width, height, block_x, block_y, channels, pattern, output_block);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create iPF1-delta encoded frame
|
||||
static size_t encode_ipf1_delta(uint8_t *previous_frame, uint8_t *current_frame,
|
||||
int width, int height, uint8_t *delta_buffer) {
|
||||
int blocks_per_row = (width + 3) / 4;
|
||||
int blocks_per_col = (height + 3) / 4;
|
||||
int total_blocks = blocks_per_row * blocks_per_col;
|
||||
|
||||
uint8_t *output_ptr = delta_buffer;
|
||||
int skip_count = 0;
|
||||
uint8_t *patch_blocks = malloc(total_blocks * IPF_BLOCK_SIZE);
|
||||
int patch_count = 0;
|
||||
|
||||
for (int block_index = 0; block_index < total_blocks; block_index++) {
|
||||
uint8_t *prev_block = previous_frame + block_index * IPF_BLOCK_SIZE;
|
||||
uint8_t *curr_block = current_frame + block_index * IPF_BLOCK_SIZE;
|
||||
|
||||
if (is_significantly_different(prev_block, curr_block)) {
|
||||
if (skip_count > 0) {
|
||||
*output_ptr++ = SKIP_OP;
|
||||
write_varint(&output_ptr, skip_count);
|
||||
skip_count = 0;
|
||||
}
|
||||
|
||||
memcpy(patch_blocks + patch_count * IPF_BLOCK_SIZE, curr_block, IPF_BLOCK_SIZE);
|
||||
patch_count++;
|
||||
} else {
|
||||
if (patch_count > 0) {
|
||||
*output_ptr++ = PATCH_OP;
|
||||
write_varint(&output_ptr, patch_count);
|
||||
memcpy(output_ptr, patch_blocks, patch_count * IPF_BLOCK_SIZE);
|
||||
output_ptr += patch_count * IPF_BLOCK_SIZE;
|
||||
patch_count = 0;
|
||||
}
|
||||
skip_count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (patch_count > 0) {
|
||||
*output_ptr++ = PATCH_OP;
|
||||
write_varint(&output_ptr, patch_count);
|
||||
memcpy(output_ptr, patch_blocks, patch_count * IPF_BLOCK_SIZE);
|
||||
output_ptr += patch_count * IPF_BLOCK_SIZE;
|
||||
}
|
||||
|
||||
*output_ptr++ = END_OP;
|
||||
|
||||
free(patch_blocks);
|
||||
return output_ptr - delta_buffer;
|
||||
}
|
||||
|
||||
// Get current time in seconds
|
||||
static double get_current_time_sec(struct timeval *tv) {
|
||||
gettimeofday(tv, NULL);
|
||||
return tv->tv_sec + tv->tv_usec / 1000000.0;
|
||||
}
|
||||
|
||||
// Display progress information similar to FFmpeg
|
||||
static void display_progress(encoder_config_t *config, int frame_num) {
|
||||
struct timeval current_time;
|
||||
double current_sec = get_current_time_sec(¤t_time);
|
||||
|
||||
// Only update progress once per second
|
||||
double last_progress_sec = config->last_progress_time.tv_sec + config->last_progress_time.tv_usec / 1000000.0;
|
||||
if (current_sec - last_progress_sec < 1.0) {
|
||||
return;
|
||||
}
|
||||
|
||||
config->last_progress_time = current_time;
|
||||
|
||||
// Calculate timing
|
||||
double start_sec = config->start_time.tv_sec + config->start_time.tv_usec / 1000000.0;
|
||||
double elapsed_sec = current_sec - start_sec;
|
||||
double current_video_time = (double)frame_num / config->fps;
|
||||
double fps = frame_num / elapsed_sec;
|
||||
double speed = (elapsed_sec > 0) ? current_video_time / elapsed_sec : 0.0;
|
||||
double bitrate = (elapsed_sec > 0) ? (config->total_output_bytes * 8.0 / 1024.0) / elapsed_sec : 0.0;
|
||||
|
||||
// Format output size in human readable format
|
||||
char size_str[32];
|
||||
if (config->total_output_bytes >= 1024 * 1024) {
|
||||
snprintf(size_str, sizeof(size_str), "%.1fMB", config->total_output_bytes / (1024.0 * 1024.0));
|
||||
} else if (config->total_output_bytes >= 1024) {
|
||||
snprintf(size_str, sizeof(size_str), "%.1fkB", config->total_output_bytes / 1024.0);
|
||||
} else {
|
||||
snprintf(size_str, sizeof(size_str), "%zuB", config->total_output_bytes);
|
||||
}
|
||||
|
||||
// Format current time as HH:MM:SS.xx
|
||||
int hours = (int)(current_video_time / 3600);
|
||||
int minutes = (int)((current_video_time - hours * 3600) / 60);
|
||||
double seconds = current_video_time - hours * 3600 - minutes * 60;
|
||||
|
||||
// Print progress line (overwrite previous line)
|
||||
fprintf(stderr, "\rframe=%d fps=%.1f size=%s time=%02d:%02d:%05.2f bitrate=%.1fkbits/s speed=%4.2fx",
|
||||
frame_num, fps, size_str, hours, minutes, seconds, bitrate, speed);
|
||||
fflush(stderr);
|
||||
}
|
||||
|
||||
// Process audio for current frame
|
||||
static int process_audio(encoder_config_t *config, int frame_num, FILE *output) {
|
||||
if (!config->has_audio || !config->mp2_file || config->audio_remaining <= 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Initialise packet size on first frame
|
||||
if (config->mp2_packet_size == 0) {
|
||||
uint8_t header[4];
|
||||
if (fread(header, 1, 4, config->mp2_file) != 4) return 1;
|
||||
fseek(config->mp2_file, 0, SEEK_SET);
|
||||
|
||||
config->mp2_packet_size = get_mp2_packet_size(header);
|
||||
int is_mono = (header[3] >> 6) == 3;
|
||||
config->mp2_rate_index = mp2_packet_size_to_rate_index(config->mp2_packet_size, is_mono);
|
||||
}
|
||||
|
||||
// Calculate how much audio time each frame represents (in seconds)
|
||||
double frame_audio_time = 1.0 / config->fps;
|
||||
|
||||
// Calculate how much audio time each MP2 packet represents
|
||||
// MP2 frame contains 1152 samples at 32kHz = 0.036 seconds
|
||||
double packet_audio_time = 1152.0 / MP2_SAMPLE_RATE;
|
||||
|
||||
// Estimate how many packets we consume per video frame
|
||||
double packets_per_frame = frame_audio_time / packet_audio_time;
|
||||
|
||||
// Only insert audio when buffer would go below 2 frames
|
||||
// Initialise with 2 packets on first frame to prime the buffer
|
||||
int packets_to_insert = 0;
|
||||
if (frame_num == 1) {
|
||||
packets_to_insert = 2;
|
||||
config->audio_frames_in_buffer = 2;
|
||||
} else {
|
||||
// Simulate buffer consumption (packets consumed per frame)
|
||||
config->audio_frames_in_buffer -= (int)ceil(packets_per_frame);
|
||||
|
||||
// Only insert packets when buffer gets low (≤ 2 frames)
|
||||
if (config->audio_frames_in_buffer <= 2) {
|
||||
packets_to_insert = config->target_audio_buffer_size - config->audio_frames_in_buffer;
|
||||
packets_to_insert = (packets_to_insert > 0) ? packets_to_insert : 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Insert the calculated number of audio packets
|
||||
for (int q = 0; q < packets_to_insert; q++) {
|
||||
size_t bytes_to_read = config->mp2_packet_size;
|
||||
if (bytes_to_read > config->audio_remaining) {
|
||||
bytes_to_read = config->audio_remaining;
|
||||
}
|
||||
|
||||
size_t bytes_read = fread(config->mp2_buffer, 1, bytes_to_read, config->mp2_file);
|
||||
if (bytes_read == 0) break;
|
||||
|
||||
uint8_t audio_packet_type[2] = {config->mp2_rate_index, MP2_PACKET_TYPE_BASE};
|
||||
fwrite(audio_packet_type, 1, 2, output);
|
||||
fwrite(config->mp2_buffer, 1, bytes_read, output);
|
||||
|
||||
// Track audio bytes written
|
||||
config->total_output_bytes += 2 + bytes_read;
|
||||
config->audio_remaining -= bytes_read;
|
||||
config->audio_frames_in_buffer++;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Write TVDOS header
|
||||
static void write_tvdos_header(encoder_config_t *config, FILE *output) {
|
||||
fwrite(TVDOS_MAGIC, 1, 8, output);
|
||||
fwrite(&config->width, 2, 1, output);
|
||||
fwrite(&config->height, 2, 1, output);
|
||||
fwrite(&config->fps, 2, 1, output);
|
||||
fwrite(&config->total_frames, 4, 1, output);
|
||||
|
||||
uint16_t unused = 0x00FF;
|
||||
fwrite(&unused, 2, 1, output);
|
||||
|
||||
int audio_sample_size = 2 * (((MP2_SAMPLE_RATE / config->fps) + 1));
|
||||
int audio_queue_size = config->has_audio ?
|
||||
(int)ceil(audio_sample_size / 2304.0) + 1 : 0;
|
||||
|
||||
uint16_t audio_queue_info = config->has_audio ?
|
||||
(MP2_DEFAULT_PACKET_SIZE >> 2) | (audio_queue_size << 12) : 0x0000;
|
||||
fwrite(&audio_queue_info, 2, 1, output);
|
||||
|
||||
// Store target buffer size for audio timing
|
||||
config->target_audio_buffer_size = audio_queue_size;
|
||||
|
||||
uint8_t reserved[10] = {0};
|
||||
fwrite(reserved, 1, 10, output);
|
||||
}
|
||||
|
||||
// Initialise encoder configuration
|
||||
static encoder_config_t *init_encoder_config() {
|
||||
encoder_config_t *config = calloc(1, sizeof(encoder_config_t));
|
||||
if (!config) return NULL;
|
||||
|
||||
config->width = DEFAULT_WIDTH;
|
||||
config->height = DEFAULT_HEIGHT;
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
// Allocate encoder buffers
|
||||
static int allocate_buffers(encoder_config_t *config) {
|
||||
config->frame_buffer_size = ((config->width + 3) / 4) * ((config->height + 3) / 4) * IPF_BLOCK_SIZE;
|
||||
|
||||
config->rgb_buffer = malloc(config->width * config->height * 3);
|
||||
config->previous_ipf_frame = malloc(config->frame_buffer_size);
|
||||
config->current_ipf_frame = malloc(config->frame_buffer_size);
|
||||
config->delta_buffer = malloc(config->frame_buffer_size * 2);
|
||||
config->compressed_buffer = malloc(config->frame_buffer_size * 2);
|
||||
config->mp2_buffer = malloc(2048);
|
||||
|
||||
return (config->rgb_buffer && config->previous_ipf_frame &&
|
||||
config->current_ipf_frame && config->delta_buffer &&
|
||||
config->compressed_buffer && config->mp2_buffer);
|
||||
}
|
||||
|
||||
// Process one frame - CORRECTED ORDER: Audio -> Video -> Sync
|
||||
static int process_frame(encoder_config_t *config, int frame_num, int is_keyframe, FILE *output) {
|
||||
// Read RGB data from FFmpeg pipe first
|
||||
size_t rgb_size = config->width * config->height * 3;
|
||||
if (fread(config->rgb_buffer, 1, rgb_size, config->ffmpeg_video_pipe) != rgb_size) {
|
||||
if (feof(config->ffmpeg_video_pipe)) return 0;
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Step 1: Process audio FIRST (matches working file pattern)
|
||||
if (!process_audio(config, frame_num, output)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Step 2: Encode and write video
|
||||
int pattern;
|
||||
switch (config->dither_mode) {
|
||||
case 0: pattern = -1; break; // No dithering
|
||||
case 1: pattern = 0; break; // Static pattern
|
||||
case 2: pattern = frame_num % 4; break; // Dynamic pattern
|
||||
default: pattern = 0; break; // Fallback to static
|
||||
}
|
||||
encode_ipf1_frame(config->rgb_buffer, config->width, config->height, 3, pattern,
|
||||
config->current_ipf_frame);
|
||||
|
||||
// Determine if we should use delta encoding
|
||||
int use_delta = 0;
|
||||
size_t data_size = config->frame_buffer_size;
|
||||
uint8_t *frame_data = config->current_ipf_frame;
|
||||
|
||||
if (frame_num > 1 && !is_keyframe) {
|
||||
size_t delta_size = encode_ipf1_delta(config->previous_ipf_frame,
|
||||
config->current_ipf_frame,
|
||||
config->width, config->height,
|
||||
config->delta_buffer);
|
||||
|
||||
if (delta_size < config->frame_buffer_size * 0.576) {
|
||||
use_delta = 1;
|
||||
data_size = delta_size;
|
||||
frame_data = config->delta_buffer;
|
||||
}
|
||||
}
|
||||
|
||||
// Compress the frame data using gzip
|
||||
size_t compressed_size = gzip_compress(frame_data, data_size,
|
||||
config->compressed_buffer,
|
||||
config->frame_buffer_size * 2);
|
||||
if (compressed_size == 0) {
|
||||
fprintf(stderr, "Gzip compression failed\n");
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Write video packet
|
||||
if (use_delta) {
|
||||
uint8_t packet_type[2] = {IPF1_DELTA_PACKET_TYPE};
|
||||
fwrite(packet_type, 1, 2, output);
|
||||
} else {
|
||||
uint8_t packet_type[2] = {IPF1_PACKET_TYPE};
|
||||
fwrite(packet_type, 1, 2, output);
|
||||
}
|
||||
|
||||
uint32_t size_le = compressed_size;
|
||||
fwrite(&size_le, 4, 1, output);
|
||||
fwrite(config->compressed_buffer, 1, compressed_size, output);
|
||||
|
||||
// Step 3: Write sync packet AFTER video (matches working file pattern)
|
||||
uint8_t sync[2] = {SYNC_PACKET_TYPE};
|
||||
fwrite(sync, 1, 2, output);
|
||||
|
||||
// Track video bytes written (packet type + size + compressed data + sync)
|
||||
config->total_output_bytes += 2 + 4 + compressed_size + 2;
|
||||
|
||||
// Swap frame buffers
|
||||
uint8_t *temp = config->previous_ipf_frame;
|
||||
config->previous_ipf_frame = config->current_ipf_frame;
|
||||
config->current_ipf_frame = temp;
|
||||
|
||||
// Display progress
|
||||
display_progress(config, frame_num);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
static void cleanup_config(encoder_config_t *config) {
|
||||
if (!config) return;
|
||||
|
||||
if (config->ffmpeg_video_pipe) pclose(config->ffmpeg_video_pipe);
|
||||
if (config->mp2_file) fclose(config->mp2_file);
|
||||
|
||||
free(config->input_file);
|
||||
free(config->output_file);
|
||||
free(config->rgb_buffer);
|
||||
free(config->previous_ipf_frame);
|
||||
free(config->current_ipf_frame);
|
||||
free(config->delta_buffer);
|
||||
free(config->compressed_buffer);
|
||||
free(config->mp2_buffer);
|
||||
|
||||
// Remove temporary audio file
|
||||
unlink(TEMP_AUDIO_FILE);
|
||||
|
||||
free(config);
|
||||
}
|
||||
|
||||
// Print usage information
|
||||
static void print_usage(const char *program_name) {
|
||||
printf("TVDOS Movie Encoder\n\n");
|
||||
printf("Usage: %s [options] input_video\n\n", program_name);
|
||||
printf("Options:\n");
|
||||
printf(" -o, --output FILE Output TVDOS movie file (default: stdout)\n");
|
||||
printf(" -s, --size WxH Video resolution (default: 560x448)\n");
|
||||
printf(" -d, --dither MODE Dithering mode (default: 1)\n");
|
||||
printf(" 0: No dithering\n");
|
||||
printf(" 1: Static pattern\n");
|
||||
printf(" 2: Dynamic pattern (better quality, larger files)\n");
|
||||
printf(" -h, --help Show this help message\n\n");
|
||||
printf("Examples:\n");
|
||||
printf(" %s input.mp4 -o output.mov\n", program_name);
|
||||
printf(" %s input.avi -s 1024x768 -o output.mov\n", program_name);
|
||||
printf(" yt-dlp -o - \"https://youtube.com/watch?v=VIDEO_ID\" | ffmpeg -i pipe:0 -c copy temp.mp4 && %s temp.mp4 -o youtube_video.mov && rm temp.mp4\n", program_name);
|
||||
}
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
encoder_config_t *config = init_encoder_config();
|
||||
if (!config) {
|
||||
fprintf(stderr, "Failed to initialise encoder\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
config->output_to_stdout = 1; // Default to stdout
|
||||
config->dither_mode = 1; // Default to static dithering
|
||||
|
||||
// Parse command line arguments
|
||||
static struct option long_options[] = {
|
||||
{"output", required_argument, 0, 'o'},
|
||||
{"size", required_argument, 0, 's'},
|
||||
{"dither", required_argument, 0, 'd'},
|
||||
{"help", no_argument, 0, 'h'},
|
||||
{0, 0, 0, 0}
|
||||
};
|
||||
|
||||
int c;
|
||||
while ((c = getopt_long(argc, argv, "o:s:d:h", long_options, NULL)) != -1) {
|
||||
switch (c) {
|
||||
case 'o':
|
||||
config->output_file = strdup(optarg);
|
||||
config->output_to_stdout = 0;
|
||||
break;
|
||||
case 's':
|
||||
if (!parse_resolution(optarg, &config->width, &config->height)) {
|
||||
fprintf(stderr, "Invalid resolution format: %s\n", optarg);
|
||||
cleanup_config(config);
|
||||
return 1;
|
||||
}
|
||||
break;
|
||||
case 'd':
|
||||
config->dither_mode = atoi(optarg);
|
||||
if (config->dither_mode < 0 || config->dither_mode > 2) {
|
||||
fprintf(stderr, "Invalid dither mode: %s (must be 0, 1, or 2)\n", optarg);
|
||||
cleanup_config(config);
|
||||
return 1;
|
||||
}
|
||||
break;
|
||||
case 'h':
|
||||
print_usage(argv[0]);
|
||||
cleanup_config(config);
|
||||
return 0;
|
||||
default:
|
||||
print_usage(argv[0]);
|
||||
cleanup_config(config);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (optind >= argc) {
|
||||
fprintf(stderr, "Error: Input video file required\n\n");
|
||||
print_usage(argv[0]);
|
||||
cleanup_config(config);
|
||||
return 1;
|
||||
}
|
||||
|
||||
config->input_file = strdup(argv[optind]);
|
||||
|
||||
// Get video metadata
|
||||
if (!get_video_metadata(config)) {
|
||||
fprintf(stderr, "Failed to analyze video metadata\n");
|
||||
cleanup_config(config);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Allocate buffers
|
||||
if (!allocate_buffers(config)) {
|
||||
fprintf(stderr, "Failed to allocate memory buffers\n");
|
||||
cleanup_config(config);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Start video conversion
|
||||
if (!start_video_conversion(config)) {
|
||||
fprintf(stderr, "Failed to start video conversion\n");
|
||||
cleanup_config(config);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Start audio conversion
|
||||
if (!start_audio_conversion(config)) {
|
||||
fprintf(stderr, "Failed to start audio conversion\n");
|
||||
cleanup_config(config);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Open output
|
||||
FILE *output = config->output_to_stdout ? stdout : fopen(config->output_file, "wb");
|
||||
if (!output) {
|
||||
fprintf(stderr, "Failed to open output file\n");
|
||||
cleanup_config(config);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Write TVDOS header
|
||||
write_tvdos_header(config, output);
|
||||
|
||||
// Initialise progress tracking
|
||||
gettimeofday(&config->start_time, NULL);
|
||||
config->last_progress_time = config->start_time;
|
||||
config->total_output_bytes = 8 + 2 + 2 + 2 + 4 + 2 + 2 + 10; // TVDOS header size
|
||||
|
||||
// Process frames with correct order: Audio -> Video -> Sync
|
||||
for (int frame = 1; frame <= config->total_frames; frame++) {
|
||||
int is_keyframe = (frame == 1) || (frame % 30 == 0);
|
||||
|
||||
int result = process_frame(config, frame, is_keyframe, output);
|
||||
if (result <= 0) {
|
||||
if (result == 0) {
|
||||
fprintf(stderr, "End of video at frame %d\n", frame);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Final progress update and newline
|
||||
fprintf(stderr, "\n");
|
||||
|
||||
if (!config->output_to_stdout) {
|
||||
fclose(output);
|
||||
fprintf(stderr, "Encoding complete: %s\n", config->output_file);
|
||||
}
|
||||
|
||||
cleanup_config(config);
|
||||
return 0;
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
// Created by CuriousTorvald and Claude on 2025-10-17
|
||||
// MPEG-style bidirectional block motion compensation for TAV encoder
|
||||
// Simplified: Single-level diamond search, variable blocks, overlaps, sub-pixel refinement
|
||||
|
||||
#include <opencv2/opencv.hpp>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <cmath>
|
||||
|
||||
extern "C" {
|
||||
|
||||
// Dense optical flow estimation using Farneback algorithm
|
||||
// Computes flow at every pixel, then samples at block centers for motion vectors
|
||||
// Much more spatially coherent than independent block matching
|
||||
void estimate_optical_flow_motion(
|
||||
const float *current_y, // Current frame Y channel (width×height)
|
||||
const float *reference_y, // Reference frame Y channel
|
||||
int width, int height,
|
||||
int block_size, // Block size (e.g., 16)
|
||||
int16_t *mvs_x, // Output: motion vectors X (in 1/4-pixel units)
|
||||
int16_t *mvs_y // Output: motion vectors Y (in 1/4-pixel units)
|
||||
) {
|
||||
// Convert float Y channels to 8-bit grayscale for OpenCV
|
||||
cv::Mat cur_gray(height, width, CV_8UC1);
|
||||
cv::Mat ref_gray(height, width, CV_8UC1);
|
||||
|
||||
// Detect if Y is in [0,1] range and scale to [0,255] if needed
|
||||
float y_min = current_y[0], y_max = current_y[0];
|
||||
for (int i = 1; i < width * height; i++) {
|
||||
if (current_y[i] < y_min) y_min = current_y[i];
|
||||
if (current_y[i] > y_max) y_max = current_y[i];
|
||||
}
|
||||
float scale = (y_max <= 1.1f) ? 255.0f : 1.0f;
|
||||
|
||||
for (int y = 0; y < height; y++) {
|
||||
for (int x = 0; x < width; x++) {
|
||||
int idx = y * width + x;
|
||||
cur_gray.at<uint8_t>(y, x) = (uint8_t)std::round(std::max(0.0f, std::min(255.0f, current_y[idx] * scale)));
|
||||
ref_gray.at<uint8_t>(y, x) = (uint8_t)std::round(std::max(0.0f, std::min(255.0f, reference_y[idx] * scale)));
|
||||
}
|
||||
}
|
||||
|
||||
// Compute dense optical flow using Farneback algorithm
|
||||
// IMPORTANT: We need BACKWARD flow (current → reference) for motion compensation
|
||||
// This tells us where to PULL pixels FROM in the reference frame
|
||||
cv::Mat flow;
|
||||
cv::calcOpticalFlowFarneback(
|
||||
cur_gray, // Current frame (source)
|
||||
ref_gray, // Reference frame (destination)
|
||||
flow, // Output flow (2-channel float: dx, dy per pixel)
|
||||
0.5, // pyr_scale: pyramid scale (0.5 = each layer is half size)
|
||||
3, // levels: number of pyramid levels
|
||||
20, // winsize: averaging window size
|
||||
3, // iterations: number of iterations at each pyramid level
|
||||
5, // poly_n: size of pixel neighborhood (5 or 7)
|
||||
1.2, // poly_sigma: standard deviation of Gaussian for polynomial expansion
|
||||
0 // flags: 0 = normal, OPTFLOW_USE_INITIAL_FLOW = use input flow as initial estimate
|
||||
);
|
||||
|
||||
// Sample flow at block centers to get motion vectors
|
||||
int num_blocks_x = (width + block_size - 1) / block_size;
|
||||
int num_blocks_y = (height + block_size - 1) / block_size;
|
||||
|
||||
for (int by = 0; by < num_blocks_y; by++) {
|
||||
for (int bx = 0; bx < num_blocks_x; bx++) {
|
||||
int block_idx = by * num_blocks_x + bx;
|
||||
|
||||
// Block center position
|
||||
int center_x = bx * block_size + block_size / 2;
|
||||
int center_y = by * block_size + block_size / 2;
|
||||
|
||||
// Clamp to frame boundaries
|
||||
if (center_x >= width) center_x = width - 1;
|
||||
if (center_y >= height) center_y = height - 1;
|
||||
|
||||
// Get flow at block center
|
||||
cv::Point2f flow_vec = flow.at<cv::Point2f>(center_y, center_x);
|
||||
|
||||
// Convert to 1/4-pixel units and store
|
||||
// Flow is in pixels, positive = motion to the right/down
|
||||
mvs_x[block_idx] = (int16_t)std::round(flow_vec.x * 4.0f);
|
||||
mvs_y[block_idx] = (int16_t)std::round(flow_vec.y * 4.0f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Block-based motion compensation with bilinear interpolation (sub-pixel precision)
|
||||
// MVs are in 1/4-pixel units
|
||||
// This implements the warp() function from MC-EZBC pseudocode
|
||||
void warp_block_motion(
|
||||
const float *src, // Source frame
|
||||
int width, int height,
|
||||
const int16_t *mvs_x, // Motion vectors X (1/4-pixel units)
|
||||
const int16_t *mvs_y, // Motion vectors Y (1/4-pixel units)
|
||||
int block_size, // Block size (e.g., 16)
|
||||
float *dst // Output warped frame
|
||||
) {
|
||||
int num_blocks_x = (width + block_size - 1) / block_size;
|
||||
int num_blocks_y = (height + block_size - 1) / block_size;
|
||||
|
||||
// Process each block
|
||||
for (int by = 0; by < num_blocks_y; by++) {
|
||||
for (int bx = 0; bx < num_blocks_x; bx++) {
|
||||
int block_idx = by * num_blocks_x + bx;
|
||||
|
||||
// Get motion vector for this block (in 1/4-pixel units)
|
||||
float mv_x = mvs_x[block_idx] / 4.0f; // Convert to pixels
|
||||
float mv_y = mvs_y[block_idx] / 4.0f;
|
||||
|
||||
// Block boundaries in destination frame
|
||||
int block_x_start = bx * block_size;
|
||||
int block_y_start = by * block_size;
|
||||
int block_x_end = std::min(block_x_start + block_size, width);
|
||||
int block_y_end = std::min(block_y_start + block_size, height);
|
||||
|
||||
// Warp each pixel in the block
|
||||
for (int y = block_y_start; y < block_y_end; y++) {
|
||||
for (int x = block_x_start; x < block_x_end; x++) {
|
||||
// Source position (backward warping)
|
||||
float src_x = x - mv_x;
|
||||
float src_y = y - mv_y;
|
||||
|
||||
// Clamp to valid range
|
||||
src_x = std::max(0.0f, std::min((float)(width - 1), src_x));
|
||||
src_y = std::max(0.0f, std::min((float)(height - 1), src_y));
|
||||
|
||||
// Bilinear interpolation
|
||||
int x0 = (int)src_x;
|
||||
int y0 = (int)src_y;
|
||||
int x1 = std::min(x0 + 1, width - 1);
|
||||
int y1 = std::min(y0 + 1, height - 1);
|
||||
|
||||
float fx = src_x - x0;
|
||||
float fy = src_y - y0;
|
||||
|
||||
float val00 = src[y0 * width + x0];
|
||||
float val10 = src[y0 * width + x1];
|
||||
float val01 = src[y1 * width + x0];
|
||||
float val11 = src[y1 * width + x1];
|
||||
|
||||
float val_top = (1.0f - fx) * val00 + fx * val10;
|
||||
float val_bot = (1.0f - fx) * val01 + fx * val11;
|
||||
float val = (1.0f - fy) * val_top + fy * val_bot;
|
||||
|
||||
dst[y * width + x] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bidirectional motion compensation for MC-EZBC predict step
|
||||
// Implements: prediction = 0.5 * (warp(f0, MV_fwd) + warp(f1, MV_bwd))
|
||||
void warp_bidirectional(
|
||||
const float *f0, const float *f1,
|
||||
int width, int height,
|
||||
const int16_t *mvs_fwd_x, const int16_t *mvs_fwd_y, // F0 → F1
|
||||
const int16_t *mvs_bwd_x, const int16_t *mvs_bwd_y, // F1 → F0
|
||||
int block_size,
|
||||
float *prediction // Output: 0.5 * (warped_f0 + warped_f1)
|
||||
) {
|
||||
int num_pixels = width * height;
|
||||
|
||||
// Allocate temporary buffers
|
||||
float *warped_f0 = new float[num_pixels];
|
||||
float *warped_f1 = new float[num_pixels];
|
||||
|
||||
// Warp f0 forward using forward MVs
|
||||
warp_block_motion(f0, width, height, mvs_fwd_x, mvs_fwd_y, block_size, warped_f0);
|
||||
|
||||
// Warp f1 backward using backward MVs
|
||||
warp_block_motion(f1, width, height, mvs_bwd_x, mvs_bwd_y, block_size, warped_f1);
|
||||
|
||||
// Average the two warped frames
|
||||
for (int i = 0; i < num_pixels; i++) {
|
||||
prediction[i] = 0.5f * (warped_f0[i] + warped_f1[i]);
|
||||
}
|
||||
|
||||
delete[] warped_f0;
|
||||
delete[] warped_f1;
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
@@ -1,795 +0,0 @@
|
||||
/*
|
||||
encoder_tav_text.c
|
||||
Text-based video encoder for TSVM using custom font ROMs
|
||||
|
||||
Outputs Videotex files with custom header and packet type 0x3F (text mode)
|
||||
|
||||
File structure:
|
||||
- Videotex header (32 bytes): magic "\x1FTSVM-VT", version, grid dims, fps, total_frames
|
||||
- Extended header packet (0xEF): BGNT, ENDT, CDAT, VNDR, FMPG
|
||||
- Font ROM packets (0x30): lowrom and highrom (1920 bytes each)
|
||||
- Per-frame sequence: [audio 0x20], [timecode 0xFD], [videotex 0x3F], [sync 0xFF]
|
||||
|
||||
Videotex packet structure (0x3F): Zstd([rows][cols][fg-array][bg-array][char-array])
|
||||
- rows: uint8 (32)
|
||||
- cols: uint8 (80)
|
||||
- fg-array: rows*cols bytes (foreground colors, 0xF0=black, 0xFE=white)
|
||||
- bg-array: rows*cols bytes (background colors, 0xF0=black, 0xFE=white)
|
||||
- char-array: rows*cols bytes (glyph indices 0-255)
|
||||
|
||||
Total uncompressed size: 2 + (80*32*3) = 7682 bytes
|
||||
Separated arrays compress much better (fg/bg are just 0xF0/0xFE runs)
|
||||
Video size: 80×32 characters (560×448 pixels with 7×14 font)
|
||||
Audio: MP2 encoding at 96 kbps, 32 KHz stereo (packet 0x20)
|
||||
Each text frame is treated as an I-frame with sync packet
|
||||
|
||||
Usage:
|
||||
gcc -Ofast -std=c11 -Wall encoder_tav_text.c -o encoder_tav_text -lm -lzstd
|
||||
./encoder_tav_text -i video.mp4 -f font.chr -o output.mv3
|
||||
*/
|
||||
|
||||
#define _POSIX_C_SOURCE 200809L
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <math.h>
|
||||
#include <zstd.h>
|
||||
#include <unistd.h>
|
||||
#include <time.h>
|
||||
#include <sys/time.h>
|
||||
|
||||
#define ENCODER_VENDOR_STRING "Encoder-TAV-Text 20251121 (videotex)"
|
||||
|
||||
#define CHAR_W 7
|
||||
#define CHAR_H 14
|
||||
#define GRID_W 80
|
||||
#define GRID_H 32
|
||||
#define PIXEL_W (GRID_W * CHAR_W) // 560
|
||||
#define PIXEL_H (GRID_H * CHAR_H) // 448
|
||||
#define PATCH_SZ (CHAR_W * CHAR_H)
|
||||
#define SAMPLE_RATE 32000
|
||||
#define MP2_DEFAULT_PACKET_SIZE 1152
|
||||
|
||||
// TAV packet types
|
||||
#define PACKET_TIMECODE 0xFD
|
||||
#define PACKET_SYNC 0xFF
|
||||
#define PACKET_AUDIO_MP2 0x20
|
||||
#define PACKET_SSF 0x30
|
||||
#define PACKET_TEXT 0x3F
|
||||
#define PACKET_EXTENDED_HDR 0xEF
|
||||
|
||||
// SSF opcodes for font ROM
|
||||
#define SSF_OPCODE_LOWROM 0x80
|
||||
#define SSF_OPCODE_HIGHROM 0x81
|
||||
|
||||
// Font ROM size constants
|
||||
#define FONTROM_PADDED_SIZE 1920
|
||||
#define GLYPHS_PER_ROM 128
|
||||
|
||||
// Color mapping (4-bit RGB to TSVM palette)
|
||||
#define COLOR_BLACK 0xF0
|
||||
#define COLOR_WHITE 0xFE
|
||||
|
||||
// Generate random filename for temporary audio file
|
||||
static void generate_random_filename(char *filename) {
|
||||
srand(time(NULL));
|
||||
|
||||
const char charset[] = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
const int charset_size = sizeof(charset) - 1;
|
||||
|
||||
// Start with the prefix
|
||||
strcpy(filename, "/tmp/");
|
||||
|
||||
// Generate 32 random characters
|
||||
for (int i = 0; i < 32; i++) {
|
||||
filename[5 + i] = charset[rand() % charset_size];
|
||||
}
|
||||
|
||||
// Add the .mp2 extension
|
||||
strcpy(filename + 37, ".mp2");
|
||||
filename[41] = '\0'; // Null terminate
|
||||
}
|
||||
|
||||
char TEMP_AUDIO_FILE[42];
|
||||
|
||||
// Global flag to disable inverted character matching
|
||||
int g_no_invert_char = 0;
|
||||
|
||||
typedef struct {
|
||||
uint8_t *data; // Binary glyph data (PATCH_SZ bytes per glyph)
|
||||
int count; // Number of glyphs
|
||||
} FontROM;
|
||||
|
||||
// Get FFmpeg version string
|
||||
char *get_ffmpeg_version(void) {
|
||||
FILE *pipe = popen("ffmpeg -version 2>&1 | head -1", "r");
|
||||
if (!pipe) return NULL;
|
||||
|
||||
char *version = malloc(256);
|
||||
if (!version) {
|
||||
pclose(pipe);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
if (fgets(version, 256, pipe)) {
|
||||
// Remove trailing newline
|
||||
size_t len = strlen(version);
|
||||
if (len > 0 && version[len - 1] == '\n') {
|
||||
version[len - 1] = '\0';
|
||||
}
|
||||
pclose(pipe);
|
||||
return version;
|
||||
}
|
||||
|
||||
free(version);
|
||||
pclose(pipe);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Detect video FPS using ffprobe
|
||||
float detect_fps(const char *video_path) {
|
||||
char cmd[1024];
|
||||
snprintf(cmd, sizeof(cmd),
|
||||
"ffprobe -v error -select_streams v:0 -show_entries stream=r_frame_rate "
|
||||
"-of default=noprint_wrappers=1:nokey=1 \"%s\" 2>/dev/null",
|
||||
video_path);
|
||||
|
||||
FILE *pipe = popen(cmd, "r");
|
||||
if (!pipe) return 30.0f; // fallback
|
||||
|
||||
char fps_str[64] = {0};
|
||||
if (fgets(fps_str, sizeof(fps_str), pipe)) {
|
||||
// Parse fraction like "30/1" or "24000/1001"
|
||||
int num = 0, den = 1;
|
||||
if (sscanf(fps_str, "%d/%d", &num, &den) == 2 && den > 0) {
|
||||
pclose(pipe);
|
||||
return (float)num / (float)den;
|
||||
}
|
||||
}
|
||||
pclose(pipe);
|
||||
return 30.0f; // fallback
|
||||
}
|
||||
|
||||
// Load font ROM (14 bytes per glyph, no header)
|
||||
FontROM *load_font_rom(const char *path) {
|
||||
FILE *f = fopen(path, "rb");
|
||||
if (!f) return NULL;
|
||||
|
||||
fseek(f, 0, SEEK_END);
|
||||
long size = ftell(f);
|
||||
fseek(f, 0, SEEK_SET);
|
||||
|
||||
if (size % 14 != 0) {
|
||||
fprintf(stderr, "Warning: ROM size not divisible by 14 (got %ld bytes)\n", size);
|
||||
}
|
||||
|
||||
int glyph_count = size / 14;
|
||||
FontROM *rom = malloc(sizeof(FontROM));
|
||||
rom->count = glyph_count;
|
||||
rom->data = malloc(glyph_count * PATCH_SZ);
|
||||
|
||||
// Read and unpack glyphs
|
||||
for (int g = 0; g < glyph_count; g++) {
|
||||
uint8_t row_bytes[14];
|
||||
if (fread(row_bytes, 14, 1, f) != 1) {
|
||||
free(rom->data);
|
||||
free(rom);
|
||||
fclose(f);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Unpack bits to binary pixels
|
||||
for (int row = 0; row < CHAR_H; row++) {
|
||||
for (int col = 0; col < CHAR_W; col++) {
|
||||
// Bit 6 = leftmost, bit 0 = rightmost
|
||||
int bit = (row_bytes[row] >> (6 - col)) & 1;
|
||||
rom->data[g * PATCH_SZ + row * CHAR_W + col] = bit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fclose(f);
|
||||
fprintf(stderr, "Loaded font ROM: %d glyphs\n", glyph_count);
|
||||
return rom;
|
||||
}
|
||||
|
||||
// Find best matching glyph for a grayscale patch
|
||||
int find_best_glyph(const uint8_t *patch, const FontROM *rom, uint8_t *out_bg, uint8_t *out_fg) {
|
||||
// Try both normal and inverted matching (unless --no-invert-char is set)
|
||||
int best_glyph = 0;
|
||||
float best_error = INFINITY;
|
||||
uint8_t best_bg = COLOR_BLACK, best_fg = COLOR_WHITE;
|
||||
|
||||
for (int g = 0; g < rom->count; g++) {
|
||||
const uint8_t *glyph = &rom->data[g * PATCH_SZ];
|
||||
|
||||
// Try normal: glyph 1 = fg, glyph 0 = bg
|
||||
float err_normal = 0;
|
||||
for (int i = 0; i < PATCH_SZ; i++) {
|
||||
int expected = glyph[i] ? 255 : 0;
|
||||
int diff = patch[i] - expected;
|
||||
err_normal += diff * diff;
|
||||
}
|
||||
|
||||
if (err_normal < best_error) {
|
||||
best_error = err_normal;
|
||||
best_glyph = g;
|
||||
best_bg = COLOR_BLACK;
|
||||
best_fg = COLOR_WHITE;
|
||||
}
|
||||
|
||||
// Try inverted: glyph 0 = fg, glyph 1 = bg (skip if --no-invert-char)
|
||||
if (!g_no_invert_char) {
|
||||
float err_inverted = 0;
|
||||
for (int i = 0; i < PATCH_SZ; i++) {
|
||||
int expected = glyph[i] ? 0 : 255;
|
||||
int diff = patch[i] - expected;
|
||||
err_inverted += diff * diff;
|
||||
}
|
||||
|
||||
if (err_inverted < best_error) {
|
||||
best_error = err_inverted;
|
||||
best_glyph = g;
|
||||
best_bg = COLOR_WHITE;
|
||||
best_fg = COLOR_BLACK;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
*out_bg = best_bg;
|
||||
*out_fg = best_fg;
|
||||
return best_glyph;
|
||||
}
|
||||
|
||||
// Convert frame to text mode
|
||||
void frame_to_text(const uint8_t *pixels, const FontROM *rom,
|
||||
uint8_t *bg_col, uint8_t *fg_col, uint8_t *chars) {
|
||||
uint8_t patch[PATCH_SZ];
|
||||
|
||||
for (int gr = 0; gr < GRID_H; gr++) {
|
||||
for (int gc = 0; gc < GRID_W; gc++) {
|
||||
int idx = gr * GRID_W + gc;
|
||||
|
||||
// Extract patch
|
||||
for (int y = 0; y < CHAR_H; y++) {
|
||||
for (int x = 0; x < CHAR_W; x++) {
|
||||
int px = gc * CHAR_W + x;
|
||||
int py = gr * CHAR_H + y;
|
||||
patch[y * CHAR_W + x] = pixels[py * PIXEL_W + px];
|
||||
}
|
||||
}
|
||||
|
||||
// Find best match
|
||||
chars[idx] = find_best_glyph(patch, rom, &bg_col[idx], &fg_col[idx]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get current time in nanoseconds since UNIX epoch
|
||||
uint64_t get_current_time_ns(void) {
|
||||
struct timeval tv;
|
||||
gettimeofday(&tv, NULL);
|
||||
return (uint64_t)tv.tv_sec * 1000000000ULL + (uint64_t)tv.tv_usec * 1000ULL;
|
||||
}
|
||||
|
||||
// Parse MP2 packet header to get accurate packet size
|
||||
int get_mp2_packet_size(uint8_t *header) {
|
||||
int bitrate_index = (header[2] >> 4) & 0x0F;
|
||||
int bitrates[] = {0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384};
|
||||
if (bitrate_index >= 15) return MP2_DEFAULT_PACKET_SIZE;
|
||||
|
||||
int bitrate = bitrates[bitrate_index];
|
||||
if (bitrate == 0) return MP2_DEFAULT_PACKET_SIZE;
|
||||
|
||||
int sampling_freq_index = (header[2] >> 2) & 0x03;
|
||||
int sampling_freqs[] = {44100, 48000, 32000, 0};
|
||||
int sampling_freq = sampling_freqs[sampling_freq_index];
|
||||
if (sampling_freq == 0) return MP2_DEFAULT_PACKET_SIZE;
|
||||
|
||||
int padding = (header[2] >> 1) & 0x01;
|
||||
return (144 * bitrate * 1000) / sampling_freq + padding;
|
||||
}
|
||||
|
||||
// Write Videotex header (32 bytes, similar to TAV but simpler)
|
||||
void write_videotex_header(FILE *f, uint8_t fps, uint32_t total_frames) {
|
||||
fwrite("\x1FTSVMTAV", 8, 1, f);
|
||||
|
||||
// Version: 1 (uint8)
|
||||
fputc(1, f);
|
||||
|
||||
// Grid dimensions (uint8 each)
|
||||
uint16_t width = GRID_W;
|
||||
uint16_t height = GRID_H;
|
||||
fwrite(&width, sizeof(uint16_t), 1, f); // cols = 80
|
||||
fwrite(&height, sizeof(uint16_t), 1, f); // rows = 32
|
||||
|
||||
// FPS (uint8)
|
||||
fputc(fps, f);
|
||||
|
||||
// Total frames (uint32, little-endian)
|
||||
fwrite(&total_frames, sizeof(uint32_t), 1, f);
|
||||
|
||||
fputc(0, f); // wavelet filter type
|
||||
fputc(0, f); // decomposition levels
|
||||
fputc(0, f); // quantiser Y
|
||||
fputc(0, f); // quantiser Co
|
||||
fputc(0, f); // quantiser Cg
|
||||
|
||||
// Feature Flags
|
||||
fputc(0x03, f); // bit 0 = has audio; bit 1 = has subtitle (Videotex is classified as subtitles)
|
||||
|
||||
// Video Flags
|
||||
fputc(0x80, f); // bit 7 = has no video (Videotex is classified as subtitles)
|
||||
|
||||
|
||||
fputc(0, f); // encoder quality level
|
||||
fputc(0x02, f); // channel layout: Y only
|
||||
fputc(0, f); // entropy coder
|
||||
|
||||
fputc(0, f); // reserved
|
||||
fputc(0, f); // reserved
|
||||
|
||||
fputc(0, f); // device orientation: no rotation
|
||||
fputc(0, f); // file role: generic
|
||||
}
|
||||
|
||||
// Write extended header packet with metadata
|
||||
// Returns the file offset where ENDT value is written (for later update)
|
||||
long write_extended_header(FILE *f, uint64_t creation_time_ns, const char *ffmpeg_version) {
|
||||
fputc(PACKET_EXTENDED_HDR, f);
|
||||
|
||||
// Helper macros for key-value pairs
|
||||
#define WRITE_KV_UINT64(key_str, value) do { \
|
||||
fwrite(key_str, 1, 4, f); \
|
||||
uint8_t value_type = 0x04; /* Uint64 */ \
|
||||
fwrite(&value_type, 1, 1, f); \
|
||||
uint64_t val = (value); \
|
||||
fwrite(&val, sizeof(uint64_t), 1, f); \
|
||||
} while(0)
|
||||
|
||||
#define WRITE_KV_BYTES(key_str, data, len) do { \
|
||||
fwrite(key_str, 1, 4, f); \
|
||||
uint8_t value_type = 0x10; /* Bytes */ \
|
||||
fwrite(&value_type, 1, 1, f); \
|
||||
uint16_t length = (len); \
|
||||
fwrite(&length, sizeof(uint16_t), 1, f); \
|
||||
fwrite((data), 1, (len), f); \
|
||||
} while(0)
|
||||
|
||||
// Count key-value pairs (BGNT, ENDT, CDAT, VNDR, FMPG)
|
||||
uint16_t num_pairs = ffmpeg_version ? 5 : 4; // FMPG is optional
|
||||
fwrite(&num_pairs, sizeof(uint16_t), 1, f);
|
||||
|
||||
// BGNT: Video begin time (0 for frame 0)
|
||||
WRITE_KV_UINT64("BGNT", 0ULL);
|
||||
|
||||
// ENDT: Video end time (placeholder, will be updated at end)
|
||||
long endt_offset = ftell(f);
|
||||
WRITE_KV_UINT64("ENDT", 0ULL);
|
||||
|
||||
// CDAT: Creation time in nanoseconds since UNIX epoch
|
||||
WRITE_KV_UINT64("CDAT", creation_time_ns);
|
||||
|
||||
// VNDR: Encoder name and version
|
||||
const char *vendor_str = ENCODER_VENDOR_STRING;
|
||||
WRITE_KV_BYTES("VNDR", vendor_str, strlen(vendor_str));
|
||||
|
||||
// FMPG: FFmpeg version (if available)
|
||||
if (ffmpeg_version) {
|
||||
WRITE_KV_BYTES("FMPG", ffmpeg_version, strlen(ffmpeg_version));
|
||||
}
|
||||
|
||||
#undef WRITE_KV_UINT64
|
||||
#undef WRITE_KV_BYTES
|
||||
|
||||
// Return offset of ENDT value (skip key, type byte)
|
||||
return endt_offset + 4 + 1; // 4 bytes for "ENDT", 1 byte for type
|
||||
}
|
||||
|
||||
// Write font ROM packet (SSF packet type 0x30)
|
||||
void write_fontrom_packet(FILE *f, const uint8_t *rom_data, size_t data_size, uint8_t opcode) {
|
||||
// Prepare padded ROM data (pad to FONTROM_PADDED_SIZE with zeros)
|
||||
uint8_t *padded_data = calloc(1, FONTROM_PADDED_SIZE);
|
||||
memcpy(padded_data, rom_data, data_size);
|
||||
|
||||
// Packet structure:
|
||||
// [type:0x30][size:uint32][index:uint24][opcode:uint8][length:uint16][data][terminator:0x00]
|
||||
uint32_t packet_size = 3 + 1 + 2 + FONTROM_PADDED_SIZE + 1;
|
||||
|
||||
// Write packet type and size
|
||||
fputc(PACKET_SSF, f);
|
||||
fwrite(&packet_size, sizeof(uint32_t), 1, f);
|
||||
|
||||
// Write SSF payload
|
||||
// Index (3 bytes, always 0 for font ROM)
|
||||
fputc(0, f);
|
||||
fputc(0, f);
|
||||
fputc(0, f);
|
||||
|
||||
// Opcode (0x80=lowrom, 0x81=highrom)
|
||||
fputc(opcode, f);
|
||||
|
||||
// Payload length (uint16, little-endian)
|
||||
uint16_t payload_len = FONTROM_PADDED_SIZE;
|
||||
fwrite(&payload_len, sizeof(uint16_t), 1, f);
|
||||
|
||||
// Font data (padded to 1920 bytes)
|
||||
fwrite(padded_data, 1, FONTROM_PADDED_SIZE, f);
|
||||
|
||||
// Terminator
|
||||
fputc(0x00, f);
|
||||
|
||||
free(padded_data);
|
||||
|
||||
fprintf(stderr, "Font ROM uploaded: %zu bytes (padded to %d), opcode 0x%02X\n",
|
||||
data_size, FONTROM_PADDED_SIZE, opcode);
|
||||
}
|
||||
|
||||
// Write timecode packet (nanoseconds)
|
||||
void write_timecode(FILE *f, uint64_t timecode_ns) {
|
||||
fputc(PACKET_TIMECODE, f);
|
||||
fwrite(&timecode_ns, sizeof(uint64_t), 1, f);
|
||||
}
|
||||
|
||||
// Write sync packet
|
||||
void write_sync(FILE *f) {
|
||||
fputc(PACKET_SYNC, f);
|
||||
}
|
||||
|
||||
// Write MP2 audio packet
|
||||
void write_audio_mp2(FILE *f, const uint8_t *data, uint32_t size) {
|
||||
fputc(PACKET_AUDIO_MP2, f);
|
||||
fwrite(&size, sizeof(uint32_t), 1, f);
|
||||
fwrite(data, 1, size, f);
|
||||
}
|
||||
|
||||
// Write text packet with separated arrays (better compression)
|
||||
void write_text_packet(FILE *f, const uint8_t *bg_col, const uint8_t *fg_col,
|
||||
const uint8_t *chars, int rows, int cols) {
|
||||
int grid_size = rows * cols;
|
||||
|
||||
// Prepare uncompressed data: [rows][cols][fg-array][bg-array][char-array]
|
||||
// Separated arrays compress much better (fg/bg are just 0xF0/0xFE runs)
|
||||
size_t uncompressed_size = 2 + grid_size * 3;
|
||||
uint8_t *uncompressed = malloc(uncompressed_size);
|
||||
|
||||
uncompressed[0] = rows;
|
||||
uncompressed[1] = cols;
|
||||
|
||||
// Copy arrays in order: foreground, background, characters
|
||||
memcpy(&uncompressed[2], fg_col, grid_size); // Foreground first
|
||||
memcpy(&uncompressed[2 + grid_size], bg_col, grid_size); // Background second
|
||||
memcpy(&uncompressed[2 + grid_size * 2], chars, grid_size); // Characters third
|
||||
|
||||
// Compress with Zstd
|
||||
size_t max_compressed = ZSTD_compressBound(uncompressed_size);
|
||||
uint8_t *compressed = malloc(max_compressed);
|
||||
size_t compressed_size = ZSTD_compress(compressed, max_compressed,
|
||||
uncompressed, uncompressed_size, 3);
|
||||
|
||||
if (ZSTD_isError(compressed_size)) {
|
||||
fprintf(stderr, "Zstd compression error\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Write packet: [type][size][data]
|
||||
fputc(PACKET_TEXT, f);
|
||||
uint32_t size32 = compressed_size;
|
||||
fwrite(&size32, 4, 1, f);
|
||||
fwrite(compressed, compressed_size, 1, f);
|
||||
|
||||
free(compressed);
|
||||
free(uncompressed);
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
if (argc < 7) {
|
||||
fprintf(stderr, "Usage: %s -i <video> -f <font.chr> -o <output.tav> [--no-invert-char]\n", argv[0]);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const char *input_video = NULL;
|
||||
const char *font_path = NULL;
|
||||
const char *output_path = NULL;
|
||||
|
||||
for (int i = 1; i < argc; i++) {
|
||||
if (strcmp(argv[i], "-i") == 0 && i+1 < argc) input_video = argv[++i];
|
||||
else if (strcmp(argv[i], "-f") == 0 && i+1 < argc) font_path = argv[++i];
|
||||
else if (strcmp(argv[i], "-o") == 0 && i+1 < argc) output_path = argv[++i];
|
||||
else if (strcmp(argv[i], "--no-invert-char") == 0) g_no_invert_char = 1;
|
||||
}
|
||||
|
||||
if (!input_video || !font_path || !output_path) {
|
||||
fprintf(stderr, "Missing required arguments\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (g_no_invert_char) {
|
||||
fprintf(stderr, "Inverted character matching disabled\n");
|
||||
}
|
||||
|
||||
// Generate random temp filename for audio
|
||||
generate_random_filename(TEMP_AUDIO_FILE);
|
||||
|
||||
// Capture creation time and FFmpeg version for extended header
|
||||
uint64_t creation_time_ns = get_current_time_ns();
|
||||
char *ffmpeg_version = get_ffmpeg_version();
|
||||
|
||||
// Detect video FPS
|
||||
float fps_float = detect_fps(input_video);
|
||||
uint8_t fps = (uint8_t)(fps_float + 0.5f); // Round to nearest integer
|
||||
fprintf(stderr, "Detected FPS: %.2f (using %d in TAV header)\n", fps_float, fps);
|
||||
|
||||
// Load font ROM
|
||||
FontROM *rom = load_font_rom(font_path);
|
||||
if (!rom) {
|
||||
fprintf(stderr, "Failed to load font ROM: %s\n", font_path);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Open FFmpeg pipe for grayscale frames at 560×448
|
||||
char ffmpeg_cmd[1024];
|
||||
snprintf(ffmpeg_cmd, sizeof(ffmpeg_cmd),
|
||||
"ffmpeg -i \"%s\" -vf \"scale=%d:%d:force_original_aspect_ratio=increase,crop=%d:%d\" "
|
||||
"-f rawvideo -pix_fmt gray - 2>/dev/null",
|
||||
input_video, PIXEL_W, PIXEL_H, PIXEL_W, PIXEL_H);
|
||||
|
||||
fprintf(stderr, "Opening video stream...\n");
|
||||
FILE *video_pipe = popen(ffmpeg_cmd, "r");
|
||||
if (!video_pipe) {
|
||||
fprintf(stderr, "Failed to open FFmpeg pipe\n");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Extract MP2 audio to temporary file using libtwolame
|
||||
fprintf(stderr, "Extracting MP2 audio...\n");
|
||||
char audio_cmd[1024];
|
||||
snprintf(audio_cmd, sizeof(audio_cmd),
|
||||
"ffmpeg -v quiet -i \"%s\" -acodec libtwolame -psymodel 4 -b:a 224k -ar %d -ac 2 -y \"%s\" 2>/dev/null",
|
||||
input_video, SAMPLE_RATE, TEMP_AUDIO_FILE);
|
||||
|
||||
int audio_result = system(audio_cmd);
|
||||
if (audio_result != 0) {
|
||||
fprintf(stderr, "Warning: Audio extraction failed, continuing without audio\n");
|
||||
}
|
||||
|
||||
// Open MP2 file for reading
|
||||
FILE *mp2_file = NULL;
|
||||
long audio_remaining = 0;
|
||||
if (audio_result == 0) {
|
||||
mp2_file = fopen(TEMP_AUDIO_FILE, "rb");
|
||||
if (mp2_file) {
|
||||
fseek(mp2_file, 0, SEEK_END);
|
||||
audio_remaining = ftell(mp2_file);
|
||||
fseek(mp2_file, 0, SEEK_SET);
|
||||
fprintf(stderr, "Audio ready: %ld bytes\n", audio_remaining);
|
||||
}
|
||||
}
|
||||
|
||||
// Open output file
|
||||
FILE *out = fopen(output_path, "wb");
|
||||
if (!out) {
|
||||
fprintf(stderr, "Failed to open output file\n");
|
||||
pclose(video_pipe);
|
||||
if (mp2_file) fclose(mp2_file);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Write Videotex header with placeholder total_frames (will update at end)
|
||||
long header_offset = ftell(out);
|
||||
write_videotex_header(out, fps, 0);
|
||||
|
||||
// Write extended header packet (before first timecode)
|
||||
long endt_offset = write_extended_header(out, creation_time_ns, ffmpeg_version);
|
||||
|
||||
// Upload font ROM to TSVM (split into lowrom and highrom)
|
||||
fprintf(stderr, "Uploading font ROM to TSVM...\n");
|
||||
FILE *rom_file = fopen(font_path, "rb");
|
||||
if (rom_file) {
|
||||
fseek(rom_file, 0, SEEK_END);
|
||||
long rom_size = ftell(rom_file);
|
||||
fseek(rom_file, 0, SEEK_SET);
|
||||
|
||||
uint8_t *raw_rom = malloc(rom_size);
|
||||
if (raw_rom && fread(raw_rom, 1, rom_size, rom_file) == rom_size) {
|
||||
// Split into lowrom and highrom
|
||||
size_t bytes_per_half = (GLYPHS_PER_ROM * 14); // 128 glyphs × 14 bytes = 1792
|
||||
|
||||
// Write lowrom (first 128 glyphs)
|
||||
if (rom_size >= bytes_per_half) {
|
||||
write_fontrom_packet(out, raw_rom, bytes_per_half, SSF_OPCODE_LOWROM);
|
||||
}
|
||||
|
||||
// Write highrom (second 128 glyphs)
|
||||
if (rom_size >= bytes_per_half * 2) {
|
||||
write_fontrom_packet(out, raw_rom + bytes_per_half, bytes_per_half, SSF_OPCODE_HIGHROM);
|
||||
} else if (rom_size > bytes_per_half) {
|
||||
// Partial highrom
|
||||
write_fontrom_packet(out, raw_rom + bytes_per_half, rom_size - bytes_per_half, SSF_OPCODE_HIGHROM);
|
||||
}
|
||||
|
||||
free(raw_rom);
|
||||
}
|
||||
fclose(rom_file);
|
||||
}
|
||||
|
||||
// Allocate buffers
|
||||
size_t frame_size = PIXEL_W * PIXEL_H;
|
||||
uint8_t *gray_pixels = malloc(frame_size);
|
||||
uint8_t *bg_col = malloc(GRID_W * GRID_H);
|
||||
uint8_t *fg_col = malloc(GRID_W * GRID_H);
|
||||
uint8_t *chars = malloc(GRID_W * GRID_H);
|
||||
|
||||
// Audio buffer for MP2 packets
|
||||
#define MP2_BUFFER_SIZE 2048
|
||||
uint8_t *audio_buffer = malloc(MP2_BUFFER_SIZE);
|
||||
|
||||
uint32_t frame_num = 0;
|
||||
uint64_t total_audio_bytes = 0;
|
||||
|
||||
// Audio timing calculation
|
||||
double frame_audio_time = 1.0 / fps_float; // Time per video frame
|
||||
double packet_audio_time = (double)MP2_DEFAULT_PACKET_SIZE / SAMPLE_RATE; // Time per audio packet
|
||||
double packets_per_frame = frame_audio_time / packet_audio_time;
|
||||
double audio_frames_in_buffer = 0.0; // Simulated audio buffer level
|
||||
|
||||
fprintf(stderr, "Encoding text-mode video (%dx%d chars, %dx%d pixels)...\n",
|
||||
GRID_W, GRID_H, PIXEL_W, PIXEL_H);
|
||||
|
||||
// Track encoding start time
|
||||
struct timeval start_time, now;
|
||||
gettimeofday(&start_time, NULL);
|
||||
|
||||
// Read and process frames
|
||||
while (fread(gray_pixels, 1, frame_size, video_pipe) == frame_size) {
|
||||
// Calculate timecode in nanoseconds
|
||||
uint64_t timecode_ns = (uint64_t)(frame_num * 1000000000.0 / fps_float);
|
||||
|
||||
// Write audio packets for this frame (based on timing)
|
||||
if (mp2_file && audio_remaining > 0) {
|
||||
// Simulate buffer consumption
|
||||
audio_frames_in_buffer -= packets_per_frame;
|
||||
|
||||
// Calculate how many packets we need to maintain buffer
|
||||
double target_level = fmax(packets_per_frame, 2.0);
|
||||
int packets_to_insert = 0;
|
||||
|
||||
if (audio_frames_in_buffer < target_level) {
|
||||
double deficit = target_level - audio_frames_in_buffer;
|
||||
packets_to_insert = (int)ceil(deficit);
|
||||
}
|
||||
|
||||
// Insert the calculated number of audio packets
|
||||
for (int q = 0; q < packets_to_insert; q++) {
|
||||
// Peek at header to get actual packet size
|
||||
long pos = ftell(mp2_file);
|
||||
uint8_t header[4];
|
||||
if (fread(header, 1, 4, mp2_file) != 4) break;
|
||||
fseek(mp2_file, pos, SEEK_SET); // Rewind to re-read with full packet
|
||||
|
||||
int actual_packet_size = get_mp2_packet_size(header);
|
||||
size_t bytes_to_read = actual_packet_size;
|
||||
|
||||
// Clamp to remaining audio
|
||||
if (bytes_to_read > audio_remaining) {
|
||||
bytes_to_read = audio_remaining;
|
||||
}
|
||||
|
||||
// Sanity check
|
||||
if (bytes_to_read > MP2_BUFFER_SIZE) {
|
||||
fprintf(stderr, "ERROR: MP2 packet size %zu exceeds buffer\n", bytes_to_read);
|
||||
break;
|
||||
}
|
||||
|
||||
// Read full packet
|
||||
size_t bytes_read = fread(audio_buffer, 1, bytes_to_read, mp2_file);
|
||||
if (bytes_read == 0) break;
|
||||
|
||||
// Write MP2 audio packet
|
||||
write_audio_mp2(out, audio_buffer, bytes_read);
|
||||
|
||||
// Track audio
|
||||
audio_remaining -= bytes_read;
|
||||
audio_frames_in_buffer++;
|
||||
total_audio_bytes += bytes_read;
|
||||
}
|
||||
}
|
||||
|
||||
// Write timecode
|
||||
write_timecode(out, timecode_ns);
|
||||
|
||||
// Convert to text mode
|
||||
frame_to_text(gray_pixels, rom, bg_col, fg_col, chars);
|
||||
|
||||
// Write text packet (treated as I-frame)
|
||||
write_text_packet(out, bg_col, fg_col, chars, GRID_H, GRID_W);
|
||||
|
||||
// Write sync packet after each frame
|
||||
write_sync(out);
|
||||
|
||||
frame_num++;
|
||||
if (frame_num % 30 == 0) {
|
||||
// Calculate encoding speed
|
||||
gettimeofday(&now, NULL);
|
||||
double elapsed = (now.tv_sec - start_time.tv_sec) +
|
||||
(now.tv_usec - start_time.tv_usec) / 1000000.0;
|
||||
double encoding_fps = frame_num / elapsed;
|
||||
|
||||
fprintf(stderr, "\rEncoded %u frames (%.1f fps)", frame_num, encoding_fps);
|
||||
fflush(stderr);
|
||||
}
|
||||
}
|
||||
|
||||
// Write any remaining audio
|
||||
if (mp2_file && audio_remaining > 0) {
|
||||
while (audio_remaining > 0) {
|
||||
// Peek at header to get actual packet size
|
||||
long pos = ftell(mp2_file);
|
||||
uint8_t header[4];
|
||||
if (fread(header, 1, 4, mp2_file) != 4) break;
|
||||
fseek(mp2_file, pos, SEEK_SET);
|
||||
|
||||
int actual_packet_size = get_mp2_packet_size(header);
|
||||
size_t bytes_to_read = (actual_packet_size < audio_remaining) ? actual_packet_size : audio_remaining;
|
||||
|
||||
if (bytes_to_read > MP2_BUFFER_SIZE) break;
|
||||
|
||||
size_t bytes_read = fread(audio_buffer, 1, bytes_to_read, mp2_file);
|
||||
if (bytes_read == 0) break;
|
||||
|
||||
write_audio_mp2(out, audio_buffer, bytes_read);
|
||||
audio_remaining -= bytes_read;
|
||||
total_audio_bytes += bytes_read;
|
||||
}
|
||||
}
|
||||
|
||||
// Final timing
|
||||
gettimeofday(&now, NULL);
|
||||
double total_time = (now.tv_sec - start_time.tv_sec) +
|
||||
(now.tv_usec - start_time.tv_usec) / 1000000.0;
|
||||
double final_fps = frame_num / total_time;
|
||||
|
||||
fprintf(stderr, "\nDone! Encoded %u frames in %.2fs (%.1f fps)\n",
|
||||
frame_num, total_time, final_fps);
|
||||
fprintf(stderr, "Audio: %llu bytes (%.2f MB)\n",
|
||||
(unsigned long long)total_audio_bytes,
|
||||
total_audio_bytes / 1024.0 / 1024.0);
|
||||
|
||||
// Update total_frames in header
|
||||
if (frame_num > 0) {
|
||||
fseek(out, header_offset + 14, SEEK_SET); // Offset to total_frames field
|
||||
fwrite(&frame_num, sizeof(uint32_t), 1, out);
|
||||
fprintf(stderr, "Updated total_frames in header: %u\n", frame_num);
|
||||
}
|
||||
|
||||
// Update ENDT in extended header (calculate end time for last frame)
|
||||
if (frame_num > 0) {
|
||||
// Calculate duration: (frame_num - 1) frames * (1/fps) seconds in nanoseconds
|
||||
uint64_t duration_ns = (uint64_t)((frame_num - 1) * 1000000000.0 / fps_float);
|
||||
uint64_t endt_ns = duration_ns;
|
||||
|
||||
fseek(out, endt_offset, SEEK_SET);
|
||||
fwrite(&endt_ns, sizeof(uint64_t), 1, out);
|
||||
fprintf(stderr, "Updated ENDT in extended header: %llu ns (%.3f seconds)\n",
|
||||
(unsigned long long)endt_ns, endt_ns / 1000000000.0);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
pclose(video_pipe);
|
||||
if (mp2_file) {
|
||||
fclose(mp2_file);
|
||||
unlink(TEMP_AUDIO_FILE); // Remove temporary audio file
|
||||
}
|
||||
fclose(out);
|
||||
free(gray_pixels);
|
||||
free(bg_col);
|
||||
free(fg_col);
|
||||
free(chars);
|
||||
free(audio_buffer);
|
||||
free(rom->data);
|
||||
free(rom);
|
||||
if (ffmpeg_version) free(ffmpeg_version);
|
||||
|
||||
return 0;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user