mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-08 22:34:03 +09:00
Compare commits
22 Commits
dad345c027
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6068080bcb | ||
|
|
ffc1d420cd | ||
|
|
e32f7565ba | ||
|
|
c17f4828b0 | ||
|
|
95ac8c53dd | ||
|
|
c8fc363445 | ||
|
|
ce45929c4e | ||
|
|
0f5ede5276 | ||
|
|
aa45c2194f | ||
|
|
3444bdf63b | ||
|
|
df16b99ba5 | ||
|
|
6a0241a249 | ||
|
|
5c7ff9e906 | ||
|
|
c6e087e74c | ||
|
|
7dea413454 | ||
|
|
ee202efe09 | ||
|
|
6be98b5207 | ||
|
|
729e5246c9 | ||
|
|
e27a01dca6 | ||
|
|
35263eeaa4 | ||
|
|
d223adda25 | ||
|
|
a9d095e3cb |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -74,3 +74,6 @@ assets/disk0/movtestimg/*.jpg
|
|||||||
assets/disk0/*.mov
|
assets/disk0/*.mov
|
||||||
assets/diskMediabin/*
|
assets/diskMediabin/*
|
||||||
assets/disk0/hopper/*
|
assets/disk0/hopper/*
|
||||||
|
|
||||||
|
# TVDOS runtime caches (regenerated on the VM; never commit)
|
||||||
|
assets/disk0/tvdos/cache/
|
||||||
|
|||||||
109
CLAUDE.md
109
CLAUDE.md
@@ -436,3 +436,112 @@ The different weights for Mid and Side channels reflect the perceptual importanc
|
|||||||
- DC frequency underamplification (using 1.0 instead of 4.0/6.0)
|
- DC frequency underamplification (using 1.0 instead of 4.0/6.0)
|
||||||
- Incorrect stereo imaging and extreme side channel distortion
|
- Incorrect stereo imaging and extreme side channel distortion
|
||||||
- Severe frequency response errors that manifest as "clipping-like" distortion
|
- Severe frequency response errors that manifest as "clipping-like" distortion
|
||||||
|
|
||||||
|
## Virtual Consoles (vtmgr)
|
||||||
|
|
||||||
|
Linux-style virtual consoles for TVDOS: up to 6 independent shell sessions,
|
||||||
|
switched with **Alt-1..Alt-6** or the **`chvt N`** builtin, **Alt-0** to exit.
|
||||||
|
Implemented entirely in JS — **no tsvm_core changes**.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
- **Dispatcher**: `assets/disk0/tvdos/VTMGR.SYS`. Launched directly by the
|
||||||
|
`TVDOS.SYS` boot block (only when `!_TVDOS_IS_VT_PANE`); when it exits (Alt-0)
|
||||||
|
the boot block runs `AUTOEXEC.BAT` as the bare fallback shell. Owns the
|
||||||
|
physical keyboard and screen. Each VT runs in its own GraalVM context/thread
|
||||||
|
via the existing `parallel.spawnNewContext` / `attachProgram` / `launch` API
|
||||||
|
(see `VMJSR223Delegate.kt` `class Parallel`). VT 1 spawns at boot; VT 2-6 are
|
||||||
|
lazy-spawned on first switch and re-spawned if their shell exits.
|
||||||
|
- **Concurrency model**: truly concurrent — switching works mid-command, not
|
||||||
|
just at the prompt. Background panes keep running (no `Thread.suspend`; it is
|
||||||
|
unusable on JDK 21). A cooperative gate inside the shimmed `con.getch` parks
|
||||||
|
panes blocked on input; CPU-bound background panes are allowed to run.
|
||||||
|
- **Shared memory**: one `sys.malloc` region holds a control block (active VT,
|
||||||
|
switch request, debounce, spawned-bits) plus, per VT, an input ring buffer and
|
||||||
|
a 7682-byte text-plane buffer mirroring the GPU text-area layout
|
||||||
|
(cursor 2 + fore 2560 + back 2560 + char 2560).
|
||||||
|
- **Compositor** (30 Hz): blits the active VT's text plane to the physical GPU
|
||||||
|
text area via `sys.memcpy`, and pushes that VT's cursor-visibility into the GPU
|
||||||
|
blink bit (MMIO attribute byte 6, addressed at `-1 - (131072*gpuSlot + 6)`).
|
||||||
|
- **Boot config split (`commandrc` + `AUTOEXEC.BAT`)**: environment setup and
|
||||||
|
app-launch are split into two files so panes can replay one without the other.
|
||||||
|
`\commandrc` holds the `set` commands (PATH/INCLPATH/HELPPATH/KEYBOARD) and is
|
||||||
|
run by the `TVDOS.SYS` boot block in **every** context (boot and pane) — it has
|
||||||
|
no `.BAT` extension, so the boot block runs it line-by-line (`set` mutates the
|
||||||
|
shared `_TVDOS.variables`, so the effect persists). `\AUTOEXEC.BAT` is the
|
||||||
|
**per-console launch** script (Korean IME `tvdos/i18n/korean`, then
|
||||||
|
`command -fancy`); it is run once per console — by each pane's bootstrap, and
|
||||||
|
by the boot block as the post-vtmgr fallback. No env snapshot/replay anymore;
|
||||||
|
each pane gets PATH/KEYBOARD/etc. natively from `commandrc`, and Korean IME
|
||||||
|
(a per-context `unicode.uniprint` handler) now registers in every pane.
|
||||||
|
- **Per-pane bootstrap**: each pane re-evals `TVDOS.SYS` (with `_TVDOS_IS_VT_PANE`
|
||||||
|
set — which makes the boot block run `commandrc` but skip the vtmgr/AUTOEXEC
|
||||||
|
launch — and a `_BIOS` stub captured live from the main context) then runs
|
||||||
|
`command -c \AUTOEXEC.BAT`, all in ONE direct `eval` so the launcher shares
|
||||||
|
scope with `_TVDOS`/`files`/`execApp`.
|
||||||
|
|
||||||
|
### Output/input shimming (in the pane bootstrap)
|
||||||
|
|
||||||
|
`con` and the global `print`/`println` family are plain JS, so the bootstrap
|
||||||
|
overrides them to read/write the per-VT shared-memory buffers instead of the
|
||||||
|
physical GPU. **`sys` and `graphics` are host objects and CANNOT be overridden
|
||||||
|
from JS** — this is the key constraint that shapes everything below.
|
||||||
|
|
||||||
|
- The shimmed `print` is a faithful JS port of the GPU's TTY interpreter
|
||||||
|
(`GlassTty.acceptChar` + `GraphicsAdapter` handlers): control bytes, the
|
||||||
|
`\x84<decimal>u` "emit char by code" escape (used by `con.prnch`), CSI cursor
|
||||||
|
moves / erase / SGR colours, and the `?25` cursor-visibility private sequence.
|
||||||
|
A swallow-only parser is NOT enough — TVDOS apps drive the screen through
|
||||||
|
these `print` escapes.
|
||||||
|
- `con.move`/`con.getyx` are **1-based** (mirroring `graphics.setCursorYX`'s
|
||||||
|
`cx-1` and `getCursorYX`'s `cx+1`); `con.addch` does NOT advance the cursor
|
||||||
|
(matches `graphics.putSymbol`), while `con.prnch` DOES.
|
||||||
|
- `command.js`'s `shell.execute` reassigns the global print family to
|
||||||
|
`shell.stdio.out.*`, which call `sys.print` (→ physical GPU). `shell.stdio.out`
|
||||||
|
was made to delegate to a `globalThis.__VT_OUT` hook when present (set by the
|
||||||
|
bootstrap); outside a VT the hook is absent and the path is byte-identical.
|
||||||
|
|
||||||
|
### Direct-VRAM apps need a VT-aware base (the `vaddr` pattern)
|
||||||
|
|
||||||
|
Apps that write the text area directly via `graphics.getGpuMemBase()` (rather
|
||||||
|
than `con.*`/`print`) bypass the shims and paint the physical screen, invading
|
||||||
|
whatever VT is visible. They must resolve text-area byte `m` through a
|
||||||
|
VT-aware base:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// physical: backward (byte m at gpuBase - m) — getDev inverts to forward-native
|
||||||
|
// VT pane: forward (byte m at VT_TEXT_PLANE + m, the pane buffer the compositor blits)
|
||||||
|
const VT = (typeof globalThis.VT_TEXT_PLANE !== 'undefined')
|
||||||
|
const VRAM_BASE = VT ? globalThis.VT_TEXT_PLANE : (graphics.getGpuMemBase() - 253950)
|
||||||
|
const VRAM_SGN = VT ? 1 : -1
|
||||||
|
function vaddr(m) { return VRAM_BASE + VRAM_SGN * m }
|
||||||
|
```
|
||||||
|
|
||||||
|
`sys.memcpy`/`sys.pokeBytes` copy forward in the resolved native memory, so this
|
||||||
|
works for both directions. The physical branch is identical to the original
|
||||||
|
arithmetic (no regression outside vtmgr). Applied so far in
|
||||||
|
`assets/disk0/tvdos/bin/taut.js` and `assets/disk0/hopper/include/aa.mjs`
|
||||||
|
(used by `bb.js`). Any future direct-VRAM app needs the same one-line `vaddr`.
|
||||||
|
|
||||||
|
### Files
|
||||||
|
|
||||||
|
- New: `assets/disk0/tvdos/VTMGR.SYS` (dispatcher + per-pane bootstrap)
|
||||||
|
- `assets/disk0/tvdos/bin/command.js`: `chvt` builtin, `[N]` prompt prefix for
|
||||||
|
VT 2-6, `shell.stdio.out` → `__VT_OUT` delegation
|
||||||
|
- `assets/disk0/tvdos/TVDOS.SYS`: boot block runs `\commandrc` (env) in every
|
||||||
|
context, then — only when `!_TVDOS_IS_VT_PANE` — launches `tvdos/sbin/vtmgr`
|
||||||
|
and, on its exit, `\AUTOEXEC.BAT` as the fallback shell
|
||||||
|
- `assets/disk0/commandrc`: env-only `set` commands (PATH/INCLPATH/HELPPATH/KEYBOARD)
|
||||||
|
- `assets/disk0/AUTOEXEC.BAT`: per-console launch (Korean IME + `command -fancy`)
|
||||||
|
- `assets/disk0/tvdos/bin/taut.js`, `assets/disk0/hopper/include/aa.mjs`:
|
||||||
|
`vaddr` VT-aware direct-VRAM addressing
|
||||||
|
|
||||||
|
### Gotcha: injectIntChk vs. embedded source
|
||||||
|
|
||||||
|
`execApp`/`require` run a program's source through `injectIntChk` (TVDOS.SYS),
|
||||||
|
which sed-rewrites the **first** `while`/`for`/`do` of each kind to call a
|
||||||
|
per-exec `tvdosSIGTERM_<hash>()` SIGTERM check. When vtmgr embeds the pane
|
||||||
|
bootstrap as a string literal, one of those rewrites can land inside the literal
|
||||||
|
— and the pane context has no such symbol. vtmgr strips them from the bootstrap
|
||||||
|
string with `raw.replace(/tvdosSIGTERM_[A-Za-z0-9_]+\(\);?/g, '')`. Any future
|
||||||
|
code that builds executable source as a string literal must do the same.
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
con.reset_graphics();con.curs_set(0);con.clear();
|
con.reset_graphics();con.curs_set(0);con.clear();
|
||||||
graphics.resetPalette();graphics.setPalette(0, 0, 0, 0, 15);graphics.setBackground(0,0,0);
|
graphics.resetPalette();graphics.setBackground(0,0,0);
|
||||||
|
|
||||||
let logo = gzip.decomp(base64.atob("H4sICJoBTGECA3Rzdm1sb2dvLnJhdwDtneu2nCoQhPf7v6xLEMUL5lxyVk6yhxm7mmZGpfqnK7uC+gkN1TA/fhTFF+Ni8eOjwedPXsgLeSEvDPLCIC8M8sIgL+SFvJAX8kJeGOSFQV4Y5IVBXsgLeSEv5IW8MMgLow1e1i4XfH/kJR8deSEvcl48eSEvAC+RvJAXgJedvJAXOS9DR17Ii5yXSF7IC8DLTl7Ii5yX0JEX8iLnZSUv5EXOy7Nsl7yQF6h7IS/kBcheyAt5eYx+Jy/kRc7L0pEX8iLmZezIC3kR8zJ05IW8iHnxO3khL2JeDnAhL+Tlj8HoABfyQl6kqS55IS9/rrssHXkhL1Jewt6RF/Ii5GVYO4vYctouxGVLe2cXXvHg3TeN3eeu6rR9lRafl5ewGr3I6RHEOXXmMSse/PeSwTV7Vac9V2nxSXkZotmnv/ffvulYAZZ//h8HP/f+e0tC9qpK2+01WnxSXtZq372bu1oxwc/9u+mesld12lOVFp+Ul65SXtHHrl5s8HNfs+9vNdHeqrT4/rz8/kxC6mrGUJiR/hwfvIn2UKXFDfAyIhlgWSyFGenyopWo9lKlxffn5f9s122VcUHzx4casCF7VaXt9hotboCX+OsJpq56ROipj9mRczTRjlVa3AAvTmhym0QqykjHl3kqpp2qtPj+vKxY/1waoSAj/TlyDibaoUqLG+AlvG8w+h1PTUY6H+SpiPZapcX35yX18sWIN5tIDz2eP+oH5dq+Sosb4GV6z0RaY8lM2Q99MtGeq7S4AV4cOJqbm1XyjDQc5qli7X6v0uL787J8PfHv6sVobh3h2mOVFjfAi4fWIt5qIq3ZhZDVRHur0uL787J95auPTmAiPSwHOckikUx7qNLiBngZ35zsApZMzP5VNNFeqrT4/rz8zOTe3L3ILBnIOgK14aVJ3ES6Jy/z+7OX3+bwmHXUy/JUifZUpcUN8OIhJ+WtJhJmHWHaqUqL78/Lqkr+3mIi+ezI6U20Q5UWN8BL+ES2K7Nk5uzIOZtor1VafH9e/rOO0vt56RyakXp5nnqoXaXFDfAyfWLx5fe1N3lGugF5agQn6jYtboCXt1tHj664NCMdgZ7wQFvpfaS+dV6Wr8/MpgWWzJB9WYOJ9lilxQ3wMujWOt9hIi3ZwWAx0d6qtPj+vGyFz89k6UeY7TpsVdYbFUrJVS+wfxrBp2DxalIUf0gwXMytI5n2Ujp+t87LbrsQLk0TXlkye3adSG76vNAuqGqHTKT78vL6L3stL4cvZpIXSvXoPG4ytI503w55QeNoLTaJh7IJzrOSoXWkM5E4HqFxmFgO5tbRsXaZVzaQl2r57rFNswo7pkXhcq2G1pHKRLovL2Xz6T1tSwxOZQM7WaGUhwv6n2qXeh+OvNis16V5wBfeo6xQSrUqGw2tI42JdF9erPyAFB2onLdkZIVSq0b7kOBN1eK2eDH0G2eH9f5BkJHm99jvXqN9eKuDRrUxXkzrGWKPDHWr2jqKKu2jTmlRqTbGi229VArI7NVrC6W8Rlsww1eoNseLcT3mDKA4H2ZT69OruLZkBRFXbY4X63rvzYlX3x93ssv22AeNdi9xKPAWN8eLeQFvcmoTSWYd/XsV1j5EwZXZXs3wYl5ht3vpELAdZKTTi6uo9iYaalDVBnmxr/j+Zf2DJpLPLqjmr6LawlRWbXu1w0uFHUi/hiSsbEpWKLWotBdhx1FS6NUILxW2lGzS6mr3KiMdnl9FtQ/vcdSotslLjT0CMzApwayjDZrwwFO13iTjvTcvNc4jC7iJJLOORo1BBZifOturKV5qbFr777ECRo/QOurlC7ZBfoNeo9osLzU23Ue0bEp2PPOsKslCire0hV4t8VJjG5LDvmyxdfSF9xpQnwH0Re3yUuE8+BkzkWTHM6/Q0vSsKj43MJFuz0uN35tw0MxEbh3Bsx5wzmNgIt2flwq/ZxNlII7ZbDe/x/7b5ESoDW6eE6o2zov9kJSQlVXZ8cwRrD7eVGu20rXgtnmx/z2+QebcDLn1V/f19CriCg3SfwSrkpdatVOSzxuzjuTzukXVXRSbSI3wYvx7wklmyfydPz6svw7ZVdnhcPtJThtPRwSq5OXnVMLUS3LS6cmYJW18Oe2VaiumO8UmUjO8/J0zGA5KQbj80cv22E+KITT1muWUY1Xy8j8x0WpUisLl1Sk7wfWvp71C7cMO02tUA3n5Y4YwmyCzCC2ZlP3kZ9G66pH20dCymp4W0Cgv//QyIS5bKlvE25T+t3++897cWw86VUde8OgnoS+TFJhNwlWysp4wKVUjedHEa2B2XQXfUaGUZXVgVKq+znjJy7MeRvY/O/wHWQfpmkeRU/r0FMMyE+navPQf5wU6ZubZHvtnUXKEzaJWXa/MS61T6KzGI2jXrc9aR77Kjt5Br+ovzEu1U+iM8l2kgO/5Hnv74sCtQHW+MC8fOtUdeB3yk29D1joK6k5O2/OWlE2dnZflnLwsgCXzZ58UhNNeTBvyDUtMpLPzEs/JS1TUSrzaY29dhzEXqW7X5SWck5eAWDKwdQRrQylr0d77s/PizsmLw3Os/PHMS5X8bStUXS7Ly0d+tRNca5edoft6j/2z0P1q2lio+rzXOz0v8xl5mfGs9GCPvWnGe1gld6gaL8vLcEZeBjwpx6yjsoQ/Fqumy/JyxgEp4UkWaB2VJXCuXDVclpcTzqgjWoQk2WP/LPCfHlkNVNfL8nLCGZLDZ/2odVSyohAMVHd/VV7Ol/E+9gqHpdcpuxAvOoUdPvNIdO5Pr9x7fwFe3Om7F6ElA1lHehNpMlF9klpdgJezZTBRw/SIWkf678XZqI6X5aU/1RQp391LtqauAvDKPdfFSHW7LC/nMpGC1pIBrSOtieStVIfL8nKmlHdWWzJR2RFgJtJmprpcl5fzlE1takvGJ8n3W2wijWaq2f7vIry4k6QwyaktmUXdESAm0t7bqU7X5aXGKXQaI8/ZjZnyjgDRng1V04V5qXAKnQIXb1fatCOV6nJtb6kaLszLCYak5AyNHqQjkGuvpqrrlXmxP4UOTXWd5azfQ/cu1Q6mqpnh90K8fHhafdghQMuKG3bnQu3U26rGa/NifAodNBYJvlzE6Angncu0J2PVxyTrWrwYn0IHeEaSDxcwenZ0X6ZM21mrjhfnxfYUOvFQJHwPcqMnwvct0V7MVbfL82J5Cp1sJIrir1Zca7w7+K4l2oO9qr8+L19mp9AJYJmhdyCdwa2Kez7W3iqozrfg5cvmFLpXPUDalhjQbkBq9ATFDR9rjxVUv/eEl+WF8ZEgLwzywiAvDPLC509eyAt5IS8M8sIgLwzywiAv5IW8kBfyQl4Y5IVBXhjkhUFeyAt5IS/khbwwyAuDvDDIC+OWvPwFgd7gz8BmAQA="));
|
|
||||||
|
|
||||||
|
let logo = gzip.decomp(base64.atob("KLUv/aTAZgEABUAAZjZzEeDpUsq9pdxbyp1kAwAAQIEBbABsAG4AM2iX1JTWdkQh0DgC2AAAYCcpIWMQM9tMW2aimiH1Z1+Gs/X33dfS13naMQYOYyi7vqBstcwUJO0jYKEmjCffvSl9rXfaK8QbcmjFEiYGDL4+8GqOs6dJec2D7FALXA4eSzbiIrY91x6wSZkSBYCpzrgjdC+wdrQkQvrTu3MIV6jD9xL9diN1ncSElF0ug1EVqTjCFiS8J3/3tmHEjjFySAAb+AfOmcwxclRwoAq+IVUKpHd5/u1bCUEkaLYBYHapqgJhCxI+/H79Me4Gll3rLfuZl75gh2ClQ3DuhC2NQSuEmUgnJgkFVTViyRg3hsJ3vyfSu9tToYJuIMmiDgP3FYeCDB/uo1lVGhpVm5F136/KzzjVz5c03IIR9v0o6m3uHEJwnHEAGanNBbNS4k3w6/kcd1cccPt7FAnWd1K66ggTT5cSRAzfEDATFVR96zTH3BE/E35auqOhFWaqNkc6iTjzNQPP/BAyeNWPAUC7C0Yx4H4E7bjqvyGUgswZ6TycAmaTY8wRUqXwh7uZ+ZFUSRHBmtlPCJlBJHNn/0d1dG6qjjYsIX5DqAqjOiNzZoycv4BZBoMbqALqfbUcgkKNgBCQEGkIaxsSQCAQBBAIBEEQEIQgBAlGoBAgBAjCIAyCwAzEsSTmjfKsYQypHIaqhY782TFH0zZk9v65rXj1wihIZhwaM9EJCM6oGrKY+HR9fateD4VUZCQ6YM7lMzz6/BCOyT0+DyY6xduMZwQ+IvB0W/J8nr/LrEB02XOAZ2GXwdk7vrVEXeHSoGu6a2GzcnxtqibNPJsDaw9b9ZbsCUobzYVqZo7PAtcoijH1PsdJMg8eoI1UiYn8GK6Ef/tYKXRIO7jy1b/N2HHZp4qkM/V5+GwwvuGslANy8mHLtBWe3WYoWKY5nrzlh3LL5OcCr8P2FWUG/ETfR7mkZeomXhtLqXzfiVabPVkhsdgoTEYATB4fhUqGpL/QUZuxmNSuhPQjkY9aG8vkbhib7siueLJ5dvadM30INl7WNtrc4egmSg9CPkobFRrsW/niGcNHsMn6B4xuTXx8hNhmKO1ML6mv7gMBIxG2GYI1U0/KV7zsCSBCYhPMdiGp3Vv6HtFt7Nkko/IRxsvERNY2IyNlWMMx0cRmgwcjTGpuMzhQpoVfH6X4q2wDP7G4zQjEDIwZH3VYKKGHmxhvBiGzOwlF+Sg1ElRXggZuxiFkfKdcNdR09uuxTEE4L/3jWoGD+ywc6ZjhDTJ9ut1PGtubE8TZipUigaXXMfDkVcNmS8DsR5YyTHKPI6OPmasYaDGW0kRk82/5/cqMVmaJzThEa/rWDjVZxGYKdoV6dsbdMobx9ZAepdj3N3LgoaQzDk0KZlguE0pCudLRd8dH8n6WOdqeTlwjMGM7WVltyPZHhWU/QIgVZ3+ucE2AGdvKytTxJ6wDz/5CCQT+3ETT/4wts2eQ5y3LOTsuYccfCqEoG4YSEcww+sxowh+9+aEyfHTghdBlypFghsuZ7YX7wK7CR0UJTfVTyiQwQ8vMZEh8sakD4+Sp+l9DvBICMybKaqr+k3OcolmAdRXeDKKAGZPJSlTxWbr7nP31GfHeeP8zhMrMUPT10A0VO1mOj/6fYUhmWhgVwUtb0YwqSErvnP1nhJ6BYmDmQDQfHVPdj07oi8mmFdBXA07oDpROeTqe/wwhMrth/l1aMx+1s0MSvL7+GUNkt6tDqjz7qMgj6dmL/WfclPEO1/YjeuqkNJj8/M/8M3KVkaA9n9r4DmvMD8E/45yMf4iPgxP2pFO+vGL/DI1kzAn5tiNYEEmjvtB/RtAMzNoPKUUz4q5hNpBxlv8MF5mUwBrwdbTXi0JSFw/in5EjwzbiQ/2z78Bmr73nDdk/4ySDbuIDFYpmdBMy5N76nxFoGV+7PrdGqmDbMqf/GSPM8o34XBYBvz1Vmav6ZwQ6g2/Eh7bPvhM/QPry9c/waCaLjHxcOkupiCLUIRwAanUsvp7Ax+a7SQTzcWY7lKYhfBS34FcKfjSqBcxYnkOPkE+xOPubV00IeJmXP2W09UvGfZQnQsuaFIqMxxYlIlYGAmclmlGk6eUZspS50IqMBwEER6zPgifis2GZtwyp50ApFStb1EcH3125BLCohNFHj5LnsG9sAMimTCL5dGBNBGULG04Z+64Pk+WWFudEIbUPUUyKullbxuYxtw8vY0+VStQSBIb+0O867s577g8PK1+DvBTDdf540PO3fpLNYjQ1Zb9eYNlc3dnIPB14Z0MIpUYls2Szge1ZVVjbtaWc5w9YllOo4yUWZd/kKp7e7hVsosD5hb0klIS4IbDUf0ZWT1P3UWnn36CDETiC2icObjVnOk9gEUs+CwnBrXRZ8lmCW+FCJl6IDK/UskGGhoK1PPmai6sXWRNFkCouVK1WJjKT50dEgiFjNI+hF85yoFOGIjIG3QcbvlLQ5hs2IoSGEfmGBTtDKIWQ7J7PN3dIyHPfiUj2AJQTS0aeh9/4L+aStPh15LwkEAiJZ5/FSxCsfjUzn8TDxBn46ovRPoSfIL8mP1X03Pabiy68ka+pJqRslV8laE6k+Q9HcHLpI+AQVtppJr8BoJzx6B750IQ8uuCrnhC5jQqKwkBTECiKgQ4HUqd4N/7BkwqOVTyp+LzCk4rHig8rPFdwXvFc8cnCkxXPFZ8sPFhwrHi68CH9xGzMoB3jyrOhVB3SqMvm"));
|
||||||
// display logo in kickin' ass-style of panasonic
|
// display logo in kickin' ass-style of panasonic
|
||||||
// hide entire framebuffer with black text to hide the slow image drawing
|
// hide entire framebuffer with black text to hide the slow image drawing
|
||||||
/*
|
/*
|
||||||
@@ -77,7 +76,7 @@ tmr = sys.nanoTime();
|
|||||||
while (sys.nanoTime() - tmr < 2147483648) sys.spin();
|
while (sys.nanoTime() - tmr < 2147483648) sys.spin();
|
||||||
// clear screen
|
// clear screen
|
||||||
graphics.clearPixels(255);con.color_pair(239,255);
|
graphics.clearPixels(255);con.color_pair(239,255);
|
||||||
con.clear();con.move(1,1);graphics.resetPalette();
|
con.clear();con.move(1,1);
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
|||||||
BIN
assets/bios/tsvmlogo.bin.zst
Normal file
BIN
assets/bios/tsvmlogo.bin.zst
Normal file
Binary file not shown.
@@ -1,12 +1,11 @@
|
|||||||
echo "Starting TVDOS..."
|
rem AUTOEXEC.BAT -- per-console launch script. Run once for every console:
|
||||||
|
rem each virtual-console pane runs it (via vtmgr's bootstrap), and the boot
|
||||||
rem put set-xxx commands here:
|
rem shell runs it as the fallback once vtmgr exits (Alt-0). Environment setup
|
||||||
set PATH=\tvdos\installer;\tvdos\tuidev;\tbas;\hopper\bin;$PATH
|
rem (`set` commands) lives in \commandrc, which TVDOS.SYS runs before this.
|
||||||
set INCLPATH=\hopper\include;$INCLPATH
|
rem
|
||||||
set HELPPATH=\hopper\help;$HELPPATH
|
rem Korean IME registers a per-CONTEXT handler (unicode.uniprint), so it must
|
||||||
set KEYBOARD=us_colemak
|
rem run per-console here rather than once at boot.
|
||||||
|
|
||||||
rem this line specifies which shell to be presented after the boot precess:
|
|
||||||
tvdos/i18n/korean
|
tvdos/i18n/korean
|
||||||
zfm
|
|
||||||
|
rem The interactive shell for this console.
|
||||||
command -fancy
|
command -fancy
|
||||||
|
|||||||
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
|
||||||
@@ -1471,9 +1471,40 @@ try {
|
|||||||
serial.println("Warning: Could not load HSDPA driver: " + e.message)
|
serial.println("Warning: Could not load HSDPA driver: " + e.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Boot script
|
// Boot script. The work is split across two files:
|
||||||
serial.println(`TVDOS.SYS initialised on VM ${sys.getVmId()}, running boot script...`);
|
// \commandrc -- environment (`set` commands); run in EVERY context.
|
||||||
|
// \AUTOEXEC.BAT -- per-console launch (IME + interactive shell).
|
||||||
|
// vtmgr re-evaluates TVDOS.SYS inside each per-VT pane; a pane sets
|
||||||
|
// _TVDOS_IS_VT_PANE so it only replays the environment here and leaves the
|
||||||
|
// AUTOEXEC launch to vtmgr's pane bootstrap (which avoids recursively
|
||||||
|
// spawning vtmgr inside a pane).
|
||||||
|
{
|
||||||
|
let cmdsrc = files.open("A:/tvdos/bin/command.js").sread()
|
||||||
|
let runBatch = (path) => eval(`var _BAT=function(exec_args){${cmdsrc}\n};_BAT`)(["", "-c", path])
|
||||||
|
|
||||||
let cmdfile = files.open("A:/tvdos/bin/command.js")
|
// Environment first, boot and pane alike. Gives every pane the same
|
||||||
eval(`var _AUTOEXEC=function(exec_args){${cmdfile.sread()}\n};` +
|
// PATH / KEYBOARD / etc. natively, with no env-snapshot replay needed.
|
||||||
`_AUTOEXEC`)(["", "-c", "\\AUTOEXEC.BAT"])
|
// \commandrc has no .BAT extension (so command.js's batch-file path,
|
||||||
|
// which keys off the extension, won't pick it up); run it line-by-line.
|
||||||
|
// `set` mutates the shared _TVDOS.variables, so the effect persists across
|
||||||
|
// the per-line shell invocations. Skip blanks and `rem` comments.
|
||||||
|
let rcFile = files.open("A:/commandrc")
|
||||||
|
if (rcFile.exists) {
|
||||||
|
rcFile.sread().split('\n').forEach((line) => {
|
||||||
|
let t = line.trim()
|
||||||
|
if (t.length > 0 && !/^rem(\s|$)/i.test(t)) runBatch(line)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof _TVDOS_IS_VT_PANE === "undefined" || !_TVDOS_IS_VT_PANE) {
|
||||||
|
serial.println(`TVDOS.SYS initialised on VM ${sys.getVmId()}, running boot script...`);
|
||||||
|
// Boot console: hand the screen to the virtual-console multiplexer.
|
||||||
|
// When it exits (Alt-0), fall through to AUTOEXEC so the console is
|
||||||
|
// never left bare.
|
||||||
|
runBatch("tvdos/VTMGR.SYS")
|
||||||
|
runBatch("\\AUTOEXEC.BAT")
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
serial.println(`TVDOS.SYS re-initialised in VT pane on VM ${sys.getVmId()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
572
assets/disk0/tvdos/VTMGR.SYS
Normal file
572
assets/disk0/tvdos/VTMGR.SYS
Normal file
@@ -0,0 +1,572 @@
|
|||||||
|
// vtmgr — virtual console manager for TVDOS
|
||||||
|
//
|
||||||
|
// Spawns up to 6 independent shell sessions (virtual consoles), each in its
|
||||||
|
// own parallel GraalVM context with its own thread. Each pane runs a real
|
||||||
|
// `command -fancy` shell. The dispatcher (this file) owns the physical
|
||||||
|
// keyboard, polls Alt-N hotkeys at 30 Hz, blits the active pane's text
|
||||||
|
// plane to the GPU's text area, and routes typed characters into the
|
||||||
|
// active pane's input ring buffer.
|
||||||
|
//
|
||||||
|
// Hotkeys: Alt-1..Alt-6 switch to that VT (lazy-spawn on first use).
|
||||||
|
// Alt-0 cleanly tears down vtmgr.
|
||||||
|
// Builtins: `chvt N` from inside a pane writes to the switch register.
|
||||||
|
|
||||||
|
// ─── shared memory layout ───────────────────────────────────────────────────
|
||||||
|
// CTRL_AREA (64 bytes from base)
|
||||||
|
// +0 active_vt u8 (1..6)
|
||||||
|
// +1 switch_request u8 (0 = none, 1..6 = target; set by chvt, cleared by dispatcher)
|
||||||
|
// +2 debounce_held u8
|
||||||
|
// +3 vt_spawned_bits u8 (bit n-1 set if VT n is alive)
|
||||||
|
// +4..63 reserved
|
||||||
|
// VT block (× MAX_VT) starting at base + 64, each VT_BLOCK_SIZE bytes
|
||||||
|
// +0..7 reserved (cursor & color state lives inside text plane itself)
|
||||||
|
// +8 queue_head u8 (next-read index)
|
||||||
|
// +9 queue_tail u8 (next-write index)
|
||||||
|
// +10..11 reserved
|
||||||
|
// +12..267 queue_data (256-byte ring buffer; one slot lost to full/empty disambiguation)
|
||||||
|
// +268..271 reserved (alignment)
|
||||||
|
// +272..7953 text_plane (7682 bytes; mirrors GPU textArea layout exactly)
|
||||||
|
|
||||||
|
const MAX_VT = 6
|
||||||
|
const CTRL_AREA_SIZE = 64
|
||||||
|
const VT_BLOCK_SIZE = 8000
|
||||||
|
const TEXT_PLANE_OFFSET = 272
|
||||||
|
const TEXT_PLANE_SIZE = 7682
|
||||||
|
const QUEUE_DATA_OFFSET = 12
|
||||||
|
|
||||||
|
const CTRL_ACTIVE_VT = 0
|
||||||
|
const CTRL_SWITCH_REQUEST = 1
|
||||||
|
const CTRL_DEBOUNCE_HELD = 2
|
||||||
|
const CTRL_SPAWNED_BITS = 3
|
||||||
|
|
||||||
|
const GPU_TEXTAREA_OFFSET = 253950
|
||||||
|
const TEXT_COLS = 80
|
||||||
|
const TEXT_ROWS = 32
|
||||||
|
|
||||||
|
const TP_FORE_BASE = 2
|
||||||
|
const TP_BACK_BASE = 2 + 2560
|
||||||
|
const TP_TEXT_BASE = 2 + 2560 + 2560
|
||||||
|
|
||||||
|
const TOTAL_ALLOC_SIZE = CTRL_AREA_SIZE + MAX_VT * VT_BLOCK_SIZE
|
||||||
|
const BASE = sys.malloc(TOTAL_ALLOC_SIZE)
|
||||||
|
if (!BASE || BASE === 0) { printerrln("vtmgr: sys.malloc failed"); return 1 }
|
||||||
|
for (let i = 0; i < TOTAL_ALLOC_SIZE; i++) sys.poke(BASE + i, 0)
|
||||||
|
|
||||||
|
const CTRL = BASE
|
||||||
|
function vtBlockAddr(n) { return BASE + CTRL_AREA_SIZE + (n - 1) * VT_BLOCK_SIZE }
|
||||||
|
function vtTextPlaneAddr(n) { return vtBlockAddr(n) + TEXT_PLANE_OFFSET }
|
||||||
|
|
||||||
|
// ─── pane bootstrap ─────────────────────────────────────────────────────────
|
||||||
|
// Read TVDOS.SYS once at startup. Each pane's bootstrap embeds the source
|
||||||
|
// (via JSON.stringify-escaped string literal) and evaluates it together with
|
||||||
|
// the shell-start code as ONE direct-eval call. This matters because strict-
|
||||||
|
// mode direct eval is scope-isolated; if TVDOS.SYS and the shell launcher
|
||||||
|
// were two separate evals, the shell launcher wouldn't see `_TVDOS`,
|
||||||
|
// `files`, `execApp`, etc. defined by the first eval.
|
||||||
|
|
||||||
|
const TVDOS_SYS_SRC = files.open("A:/tvdos/TVDOS.SYS").sread()
|
||||||
|
|
||||||
|
// _BIOS is set by the real BIOS before TVDOS.SYS runs; TVDOS.SYS reads
|
||||||
|
// _BIOS.FIRST_BOOTABLE_PORT during init. Each pane is a fresh context with no
|
||||||
|
// BIOS, so capture the live value here (vtmgr runs in the main context where
|
||||||
|
// _BIOS is visible) and re-declare it in every pane bootstrap.
|
||||||
|
const BIOS_FIRST_BOOTABLE_PORT = JSON.stringify(_BIOS.FIRST_BOOTABLE_PORT)
|
||||||
|
|
||||||
|
// Environment no longer needs snapshotting/replaying: each pane re-evaluates
|
||||||
|
// TVDOS.SYS, whose boot block runs \commandrc in every context, so the pane
|
||||||
|
// gets the same PATH / KEYBOARD / etc. natively. The pane then runs
|
||||||
|
// \AUTOEXEC.BAT (the per-console launch script: IME + interactive shell).
|
||||||
|
|
||||||
|
function makePaneBootstrap(vtNum) {
|
||||||
|
const TP_BASE = vtTextPlaneAddr(vtNum)
|
||||||
|
const VT_BLK = vtBlockAddr(vtNum)
|
||||||
|
|
||||||
|
// Launcher code runs after TVDOS.SYS in the SAME eval scope, so `files`,
|
||||||
|
// `eval`, `_TVDOS` etc. resolve via lexical closure. TVDOS.SYS's boot
|
||||||
|
// block already ran \commandrc (env) and skipped its own AUTOEXEC because
|
||||||
|
// the pane sets _TVDOS_IS_VT_PANE; here we run \AUTOEXEC.BAT to launch the
|
||||||
|
// per-console shell.
|
||||||
|
const SHELL_START = ";\n"
|
||||||
|
+ "var _cmdfileSrc = files.open('A:/tvdos/bin/command.js').sread();\n"
|
||||||
|
+ "eval('var _VTSHELL=function(exec_args){' + _cmdfileSrc + '\\n};_VTSHELL')(['', '-c', '\\\\AUTOEXEC.BAT']);\n"
|
||||||
|
|
||||||
|
const combined = TVDOS_SYS_SRC + SHELL_START
|
||||||
|
|
||||||
|
const raw = `
|
||||||
|
globalThis.VT_NUM = ${vtNum}
|
||||||
|
globalThis.VT_TEXT_PLANE = ${TP_BASE}
|
||||||
|
globalThis.VT_BLOCK_ADDR = ${VT_BLK}
|
||||||
|
globalThis.VT_CTRL_ADDR = ${CTRL}
|
||||||
|
const TP = ${TP_BASE}
|
||||||
|
const VT_BLK = ${VT_BLK}
|
||||||
|
const CTRL = ${CTRL}
|
||||||
|
const QUEUE_DATA = VT_BLK + ${QUEUE_DATA_OFFSET}
|
||||||
|
const QUEUE_HEAD_ADDR = VT_BLK + 8
|
||||||
|
const QUEUE_TAIL_ADDR = VT_BLK + 9
|
||||||
|
const ACTIVE_VT_ADDR = CTRL + ${CTRL_ACTIVE_VT}
|
||||||
|
const COLS = ${TEXT_COLS}, ROWS = ${TEXT_ROWS}
|
||||||
|
const FORE_BASE = ${TP_FORE_BASE}, BACK_BASE = ${TP_BACK_BASE}, TEXT_BASE = ${TP_TEXT_BASE}
|
||||||
|
|
||||||
|
// ── output shims (write into the per-VT text-plane buffer in shared mem) ──
|
||||||
|
// This is a faithful JS port of the GPU's TTY interpreter (GlassTty.acceptChar
|
||||||
|
// + GraphicsAdapter handlers). TVDOS apps drive the screen by printing control
|
||||||
|
// bytes and escape sequences through print(), so the shim must interpret them
|
||||||
|
// exactly as the hardware would: the \\x84<decimal>u "emit char by code" escape
|
||||||
|
// (used by con.prnch), CSI cursor moves / erase / SGR colours, and the ?25
|
||||||
|
// cursor-visibility private sequence.
|
||||||
|
let curX = 0, curY = 0
|
||||||
|
let foreCol = 254
|
||||||
|
let backCol = 255
|
||||||
|
|
||||||
|
// Per-pane cursor visibility lives at VT_BLK+2 (1 = blink on, 0 = hidden).
|
||||||
|
// The compositor pushes the active pane's value into the GPU's blink bit.
|
||||||
|
const CURSOR_VIS_ADDR = VT_BLK + 2
|
||||||
|
sys.poke(CURSOR_VIS_ADDR, 1)
|
||||||
|
|
||||||
|
// SGR 30-37 / 40-47 → default 8-colour palette (matches GraphicsAdapter).
|
||||||
|
const SGR_PAL = [240, 211, 61, 230, 49, 219, 114, 254]
|
||||||
|
|
||||||
|
function writeCursor() {
|
||||||
|
let pos = curY * COLS + curX
|
||||||
|
sys.poke(TP + 0, pos & 0xFF)
|
||||||
|
sys.poke(TP + 1, (pos >> 8) & 0xFF)
|
||||||
|
}
|
||||||
|
function scrollBufUp(n) {
|
||||||
|
if (n < 1) n = 1
|
||||||
|
if (n > ROWS) n = ROWS
|
||||||
|
for (let p of [FORE_BASE, BACK_BASE, TEXT_BASE]) {
|
||||||
|
for (let y = 0; y < ROWS - n; y++) {
|
||||||
|
for (let x = 0; x < COLS; x++) {
|
||||||
|
sys.poke(TP + p + y * COLS + x, sys.peek(TP + p + (y + n) * COLS + x))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let clearVal = (p === TEXT_BASE) ? 0 : (p === FORE_BASE ? foreCol : backCol)
|
||||||
|
for (let y = ROWS - n; y < ROWS; y++)
|
||||||
|
for (let x = 0; x < COLS; x++) sys.poke(TP + p + y * COLS + x, clearVal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function putCharRaw(x, y, c) {
|
||||||
|
if (x < 0 || x >= COLS || y < 0 || y >= ROWS) return
|
||||||
|
let off = y * COLS + x
|
||||||
|
sys.poke(TP + TEXT_BASE + off, c & 0xFF)
|
||||||
|
sys.poke(TP + FORE_BASE + off, foreCol)
|
||||||
|
sys.poke(TP + BACK_BASE + off, backCol)
|
||||||
|
}
|
||||||
|
// Mirror of GraphicsAdapter.setCursorPos: wrap on overflow x, scroll on
|
||||||
|
// overflow y, clamp y above the screen.
|
||||||
|
function setCursorPos(x, y) {
|
||||||
|
let nx = x, ny = y
|
||||||
|
if (nx >= COLS) { nx = 0; ny += 1 }
|
||||||
|
else if (nx < 0) nx = 0
|
||||||
|
if (ny < 0) ny = 0
|
||||||
|
else if (ny >= ROWS) { scrollBufUp(ny - ROWS + 1); ny = ROWS - 1 }
|
||||||
|
curX = nx; curY = ny
|
||||||
|
writeCursor()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TTY actions (mirror the GraphicsAdapter overrides) ────────────────────
|
||||||
|
function ttyPrintable(c) { putCharRaw(curX, curY, c); setCursorPos(curX + 1, curY) }
|
||||||
|
function ttyCrlf() {
|
||||||
|
let ny = curY + 1
|
||||||
|
setCursorPos(0, (ny >= ROWS) ? ROWS - 1 : ny)
|
||||||
|
if (ny >= ROWS) scrollBufUp(1)
|
||||||
|
}
|
||||||
|
function ttyBackspace() { let x = curX, y = curY; setCursorPos(x - 1, y); putCharRaw(curX, curY, 0x20) }
|
||||||
|
function ttyTab() { setCursorPos(((curX / 8 | 0) + 1) * 8, curY) }
|
||||||
|
function ttyResetStatus() { foreCol = 253; backCol = 255 }
|
||||||
|
function ttyEmitChar(code) { putCharRaw(curX, curY, code); setCursorPos(curX + 1, curY) }
|
||||||
|
function ttyCursorUp(n) { setCursorPos(curX, curY - n) }
|
||||||
|
function ttyCursorDown(n) { let ny = curY + n; setCursorPos(curX, (ny >= ROWS) ? ROWS - 1 : ny) }
|
||||||
|
function ttyCursorFwd(n) { setCursorPos(curX + n, curY) }
|
||||||
|
function ttyCursorBack(n) { setCursorPos(curX - n, curY) }
|
||||||
|
function ttyCursorNextLine(n) { let ny = curY + n; setCursorPos(0, (ny >= ROWS) ? ROWS - 1 : ny); if (ny >= ROWS) scrollBufUp(ny - ROWS + 1) }
|
||||||
|
function ttyCursorPrevLine(n) { setCursorPos(0, curY - n) }
|
||||||
|
function ttyCursorX(n) { setCursorPos(n, curY) }
|
||||||
|
function ttyCursorXY(row, col) { setCursorPos(col - 1, row - 1) }
|
||||||
|
function ttyEraseInDisp(arg) {
|
||||||
|
if (arg === 2) {
|
||||||
|
for (let i = 0; i < COLS * ROWS; i++) {
|
||||||
|
sys.poke(TP + TEXT_BASE + i, 0)
|
||||||
|
sys.poke(TP + FORE_BASE + i, foreCol)
|
||||||
|
sys.poke(TP + BACK_BASE + i, backCol)
|
||||||
|
}
|
||||||
|
curX = 0; curY = 0; writeCursor()
|
||||||
|
}
|
||||||
|
// other args: GraphicsAdapter TODOs (throws); we no-op for safety
|
||||||
|
}
|
||||||
|
function ttySgr1(arg) {
|
||||||
|
if (arg >= 30 && arg <= 37) foreCol = SGR_PAL[arg - 30]
|
||||||
|
else if (arg >= 40 && arg <= 47) backCol = SGR_PAL[arg - 40]
|
||||||
|
else if (arg === 7) { let t = foreCol; foreCol = backCol; backCol = t }
|
||||||
|
else if (arg === 0) { foreCol = 253; backCol = 255; sys.poke(CURSOR_VIS_ADDR, 1) }
|
||||||
|
}
|
||||||
|
function ttySgr3(a1, a2, a3) {
|
||||||
|
if (a1 === 38 && a2 === 5) foreCol = a3
|
||||||
|
else if (a1 === 48 && a2 === 5) backCol = a3
|
||||||
|
}
|
||||||
|
function ttyPrivH(arg) { if (arg === 25) sys.poke(CURSOR_VIS_ADDR, 1) }
|
||||||
|
function ttyPrivL(arg) { if (arg === 25) sys.poke(CURSOR_VIS_ADDR, 0) }
|
||||||
|
|
||||||
|
// ── escape-sequence state machine (mirror of GlassTty.acceptChar) ─────────
|
||||||
|
// States: 0 INITIAL, 1 ESC, 2 CSI, 3 NUM1, 4 SEP1, 5 NUM2, 6 SEP2, 7 NUM3,
|
||||||
|
// 8 PRIVATESEQ, 9 PRIVATENUM, 10 XCSI, 11 XNUM1
|
||||||
|
let escState = 0
|
||||||
|
let escArgs = []
|
||||||
|
function isDig(c) { return c >= 0x30 && c <= 0x39 }
|
||||||
|
function escReset() { escState = 0; escArgs.length = 0 }
|
||||||
|
// reject() in hardware returns the char as printable; replicate by printing it
|
||||||
|
function escRejectPrint(c) { escReset(); ttyPrintable(c) }
|
||||||
|
|
||||||
|
function processByte(c) {
|
||||||
|
switch (escState) {
|
||||||
|
case 0: // INITIAL
|
||||||
|
if (c === 0x1B) escState = 1
|
||||||
|
else if (c === 0x84) escState = 10
|
||||||
|
else if (c === 0x0A) ttyCrlf()
|
||||||
|
else if (c === 0x08) ttyBackspace()
|
||||||
|
else if (c === 0x09) ttyTab()
|
||||||
|
else if (c === 0x07) { /* bell */ }
|
||||||
|
else if (c >= 0x00 && c <= 0x1F) { /* other control: ignored */ }
|
||||||
|
else ttyPrintable(c)
|
||||||
|
break
|
||||||
|
case 1: // ESC
|
||||||
|
if (c === 0x63) { ttyResetStatus(); escReset() } // 'c'
|
||||||
|
else if (c === 0x5B) escState = 2 // '['
|
||||||
|
else escRejectPrint(c)
|
||||||
|
break
|
||||||
|
case 2: // CSI
|
||||||
|
if (c === 0x41) { ttyCursorUp(1); escReset() }
|
||||||
|
else if (c === 0x42) { ttyCursorDown(1); escReset() }
|
||||||
|
else if (c === 0x43) { ttyCursorFwd(1); escReset() }
|
||||||
|
else if (c === 0x44) { ttyCursorBack(1); escReset() }
|
||||||
|
else if (c === 0x45) { ttyCursorNextLine(1); escReset() }
|
||||||
|
else if (c === 0x46) { ttyCursorPrevLine(1); escReset() }
|
||||||
|
else if (c === 0x47) { ttyCursorX(1); escReset() }
|
||||||
|
else if (c === 0x4A) { ttyEraseInDisp(0); escReset() }
|
||||||
|
else if (c === 0x4B) { escReset() } // eraseInLine: no-op
|
||||||
|
else if (c === 0x53) { scrollBufUp(1); escReset() } // S
|
||||||
|
else if (c === 0x54) { escReset() } // T scrollDown: no-op
|
||||||
|
else if (c === 0x6D) { ttySgr1(0); escReset() } // m
|
||||||
|
else if (c === 0x3F) escState = 8 // '?'
|
||||||
|
else if (c === 0x3B) { escArgs.push(0); escState = 4 } // ';'
|
||||||
|
else if (isDig(c)) { escArgs.push(c - 0x30); escState = 3 }
|
||||||
|
else escRejectPrint(c)
|
||||||
|
break
|
||||||
|
case 3: // NUM1
|
||||||
|
if (c === 0x41) { ttyCursorUp(escArgs.pop()); escReset() }
|
||||||
|
else if (c === 0x42) { ttyCursorDown(escArgs.pop()); escReset() }
|
||||||
|
else if (c === 0x43) { ttyCursorFwd(escArgs.pop()); escReset() }
|
||||||
|
else if (c === 0x44) { ttyCursorBack(escArgs.pop()); escReset() }
|
||||||
|
else if (c === 0x45) { ttyCursorNextLine(escArgs.pop()); escReset() }
|
||||||
|
else if (c === 0x46) { ttyCursorPrevLine(escArgs.pop()); escReset() }
|
||||||
|
else if (c === 0x47) { ttyCursorX(escArgs.pop()); escReset() }
|
||||||
|
else if (c === 0x4A) { ttyEraseInDisp(escArgs.pop()); escReset() }
|
||||||
|
else if (c === 0x4B) { escArgs.pop(); escReset() }
|
||||||
|
else if (c === 0x53) { scrollBufUp(escArgs.pop()); escReset() }
|
||||||
|
else if (c === 0x54) { escArgs.pop(); escReset() }
|
||||||
|
else if (c === 0x6D) { ttySgr1(escArgs.pop()); escReset() }
|
||||||
|
else if (c === 0x3B) escState = 4
|
||||||
|
else if (isDig(c)) escArgs.push(escArgs.pop() * 10 + (c - 0x30))
|
||||||
|
else escRejectPrint(c)
|
||||||
|
break
|
||||||
|
case 4: // SEP1 (seen "n;")
|
||||||
|
if (isDig(c)) { escArgs.push(c - 0x30); escState = 5 }
|
||||||
|
else if (c === 0x48) { let a1 = escArgs.pop(); ttyCursorXY(a1, 0); escReset() } // H
|
||||||
|
else if (c === 0x6D) { ttySgr1(escArgs.pop()); escReset() } // m (2-arg unimpl in HW)
|
||||||
|
else if (c === 0x3B) { escArgs.push(0); escState = 6 }
|
||||||
|
else escRejectPrint(c)
|
||||||
|
break
|
||||||
|
case 5: // NUM2 (seen "n;n")
|
||||||
|
if (isDig(c)) escArgs.push(escArgs.pop() * 10 + (c - 0x30))
|
||||||
|
else if (c === 0x48) { let a2 = escArgs.pop(), a1 = escArgs.pop(); ttyCursorXY(a1, a2); escReset() }
|
||||||
|
else if (c === 0x6D) { escArgs.pop(); escArgs.pop(); escReset() } // 2-arg SGR unimpl in HW
|
||||||
|
else if (c === 0x3B) escState = 6
|
||||||
|
else escRejectPrint(c)
|
||||||
|
break
|
||||||
|
case 6: // SEP2 (seen "n;n;")
|
||||||
|
if (c === 0x6D) { let a2 = escArgs.pop(), a1 = escArgs.pop(); ttySgr3(a1, a2, 0); escReset() }
|
||||||
|
else if (isDig(c)) { escArgs.push(c - 0x30); escState = 7 }
|
||||||
|
else escRejectPrint(c)
|
||||||
|
break
|
||||||
|
case 7: // NUM3 (seen "n;n;n")
|
||||||
|
if (isDig(c)) escArgs.push(escArgs.pop() * 10 + (c - 0x30))
|
||||||
|
else if (c === 0x6D) { let a3 = escArgs.pop(), a2 = escArgs.pop(), a1 = escArgs.pop(); ttySgr3(a1, a2, a3); escReset() }
|
||||||
|
else escRejectPrint(c)
|
||||||
|
break
|
||||||
|
case 8: // PRIVATESEQ (seen "?")
|
||||||
|
if (isDig(c)) { escArgs.push(c - 0x30); escState = 9 }
|
||||||
|
else escRejectPrint(c)
|
||||||
|
break
|
||||||
|
case 9: // PRIVATENUM (seen "?n")
|
||||||
|
if (c === 0x68) { ttyPrivH(escArgs.pop()); escReset() } // h
|
||||||
|
else if (c === 0x6C) { ttyPrivL(escArgs.pop()); escReset() } // l
|
||||||
|
else if (isDig(c)) escArgs.push(escArgs.pop() * 10 + (c - 0x30))
|
||||||
|
else escRejectPrint(c)
|
||||||
|
break
|
||||||
|
case 10: // XCSI (seen \\x84)
|
||||||
|
if (c === 0x75) { ttyEmitChar(0); escReset() } // 'u'
|
||||||
|
else if (isDig(c)) { escArgs.push(c - 0x30); escState = 11 }
|
||||||
|
else escRejectPrint(c)
|
||||||
|
break
|
||||||
|
case 11: // XNUM1 (seen \\x84<digits>)
|
||||||
|
if (c === 0x75) { ttyEmitChar(escArgs.pop()); escReset() } // 'u'
|
||||||
|
else if (isDig(c)) escArgs.push(escArgs.pop() * 10 + (c - 0x30))
|
||||||
|
else escRejectPrint(c)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
print = function(s) {
|
||||||
|
if (s === undefined || s === null) return
|
||||||
|
let str = '' + s
|
||||||
|
for (let i = 0; i < str.length; i++) processByte(str.charCodeAt(i))
|
||||||
|
}
|
||||||
|
println = function(s) {
|
||||||
|
if (s === undefined) print("\\n")
|
||||||
|
else print(s + "\\n")
|
||||||
|
}
|
||||||
|
printerr = function(s) { print(s) }
|
||||||
|
printerrln = function(s) { println(s) }
|
||||||
|
|
||||||
|
// command.js's shell.execute reassigns the global print/println/printerr/
|
||||||
|
// printerrln to shell.stdio.out.* (which call sys.print → physical GPU,
|
||||||
|
// bypassing these shims). Expose the buffer writers through a global hook so
|
||||||
|
// shell.stdio.out can delegate to them when running inside a VT pane. The
|
||||||
|
// non-VT path in command.js stays unchanged (hook is undefined there).
|
||||||
|
globalThis.__VT_OUT = { print: print, println: println, printerr: printerr, printerrln: printerrln }
|
||||||
|
|
||||||
|
// con.move / con.getyx are 1-based in TVDOS (graphics.setCursorYX does cx-1,
|
||||||
|
// getCursorYX returns cx+1). Internal curX/curY are 0-based, so convert.
|
||||||
|
con.move = function(y, x) {
|
||||||
|
curY = Math.max(0, Math.min(ROWS - 1, (y | 0) - 1))
|
||||||
|
curX = Math.max(0, Math.min(COLS - 1, (x | 0) - 1))
|
||||||
|
writeCursor()
|
||||||
|
}
|
||||||
|
con.getyx = function() { return [curY + 1, curX + 1] }
|
||||||
|
con.getmaxyx = function() { return [ROWS, COLS] }
|
||||||
|
con.color_pair = function(f, b) { foreCol = f & 0xFF; backCol = b & 0xFF }
|
||||||
|
con.color_fore = function(n) { foreCol = n & 0xFF }
|
||||||
|
con.color_back = function(n) { backCol = n & 0xFF }
|
||||||
|
con.get_color_fore = function() { return foreCol }
|
||||||
|
con.get_color_back = function() { return backCol }
|
||||||
|
// addch writes a glyph at the cursor WITHOUT advancing — matching
|
||||||
|
// graphics.putSymbol(). TVDOS code pairs addch with explicit curs_right();
|
||||||
|
// advancing here would double-step and leave gaps (e.g. the fancy prompt).
|
||||||
|
con.addch = function(c) { putCharRaw(curX, curY, c) }
|
||||||
|
con.mvaddch = function(y, x, c) { con.move(y, x); con.addch(c) }
|
||||||
|
con.curs_up = function(n) { n = n || 1; curY = Math.max(0, curY - n); writeCursor() }
|
||||||
|
con.curs_down = function(n) { n = n || 1; curY = Math.min(ROWS - 1, curY + n); writeCursor() }
|
||||||
|
con.curs_left = function(n) { n = n || 1; curX = Math.max(0, curX - n); writeCursor() }
|
||||||
|
con.curs_right = function(n) { n = n || 1; curX = Math.min(COLS - 1, curX + n); writeCursor() }
|
||||||
|
con.curs_set = function(arg) { sys.poke(CURSOR_VIS_ADDR, ((arg | 0) === 0) ? 0 : 1) }
|
||||||
|
con.video_reverse = function() { /* unsupported; ANSI swallowed */ }
|
||||||
|
con.reset_graphics = function() { foreCol = 254; backCol = 255 }
|
||||||
|
con.clear = function() {
|
||||||
|
for (let i = 0; i < COLS * ROWS; i++) {
|
||||||
|
sys.poke(TP + TEXT_BASE + i, 0)
|
||||||
|
sys.poke(TP + FORE_BASE + i, foreCol)
|
||||||
|
sys.poke(TP + BACK_BASE + i, backCol)
|
||||||
|
}
|
||||||
|
curX = 0; curY = 0; writeCursor()
|
||||||
|
}
|
||||||
|
// prnch prints a glyph and DOES advance (unlike addch) — the real impl emits
|
||||||
|
// it through print() as \\x84<code>u, so route it through the interpreter.
|
||||||
|
con.prnch = function(c) {
|
||||||
|
if (Array.isArray(c)) c.forEach(x => ttyEmitChar(x))
|
||||||
|
else ttyEmitChar(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── input shims ──────────────────────────────────────────────────────────
|
||||||
|
// Pane reads from its own ring buffer in shared mem. NEVER touches physical
|
||||||
|
// keyboard MMIO — that's the dispatcher's exclusive territory. Cooperative
|
||||||
|
// gate on active_vt keeps background panes parked when they call getch.
|
||||||
|
|
||||||
|
function queuePop() {
|
||||||
|
let head = sys.peek(QUEUE_HEAD_ADDR)
|
||||||
|
let tail = sys.peek(QUEUE_TAIL_ADDR)
|
||||||
|
if (head === tail) return -1
|
||||||
|
let b = sys.peek(QUEUE_DATA + head)
|
||||||
|
sys.poke(QUEUE_HEAD_ADDR, (head + 1) & 0xFF)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
con.getch = function() {
|
||||||
|
while (true) {
|
||||||
|
if (sys.peek(ACTIVE_VT_ADDR) === VT_NUM) {
|
||||||
|
let k = queuePop()
|
||||||
|
if (k >= 0) return k
|
||||||
|
}
|
||||||
|
sys.sleep(20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
con.hitterminate = function() { return false }
|
||||||
|
con.hiteof = function() { return false }
|
||||||
|
con.resetkeybuf = function() { sys.poke(QUEUE_HEAD_ADDR, sys.peek(QUEUE_TAIL_ADDR)) }
|
||||||
|
con.poll_keys = function() { return [0,0,0,0,0,0,0,0] }
|
||||||
|
|
||||||
|
// ── TVDOS.SYS init flags + BIOS stub ───────────────────────────────────────
|
||||||
|
globalThis._TVDOS_IS_VT_PANE = true
|
||||||
|
globalThis._BIOS = { FIRST_BOOTABLE_PORT: ${BIOS_FIRST_BOOTABLE_PORT} }
|
||||||
|
|
||||||
|
// ── load TVDOS.SYS and run AUTOEXEC.BAT (the per-console shell) in one direct-eval ─────
|
||||||
|
// Strict-mode direct eval is scope-isolated, so TVDOS.SYS's \`const _TVDOS\`
|
||||||
|
// only survives within the eval scope. The shell launcher must run inside
|
||||||
|
// the same eval to access it (via lexical closure into nested evals).
|
||||||
|
eval(${JSON.stringify(combined)})
|
||||||
|
`
|
||||||
|
// The outer execApp's injectIntChk rewrote the first while/for/do (each
|
||||||
|
// kind) in our literal source to call a per-exec SIGTERM check function.
|
||||||
|
// Some of those rewrites landed inside this template literal — the pane
|
||||||
|
// has no such symbol in scope. Strip them; panes don't need SIGTERM
|
||||||
|
// checks (parallel.kill handles teardown).
|
||||||
|
return raw.replace(/tvdosSIGTERM_[A-Za-z0-9_]+\(\);?/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── pane lifecycle ─────────────────────────────────────────────────────────
|
||||||
|
// Lazy spawn: VT 1 at boot; VT 2-6 the first time the user requests them.
|
||||||
|
// Re-spawn if the previous pane's thread has died (e.g. user typed `exit`).
|
||||||
|
|
||||||
|
const panes = {} // n -> { runner, thread }
|
||||||
|
|
||||||
|
function isPaneAlive(n) {
|
||||||
|
return panes[n] && parallel.isRunning(panes[n].thread)
|
||||||
|
}
|
||||||
|
|
||||||
|
function spawnPane(n) {
|
||||||
|
serial.println("[vtmgr] spawning VT " + n)
|
||||||
|
let runner = parallel.spawnNewContext()
|
||||||
|
let thread = parallel.attachProgram("vt" + n, runner, makePaneBootstrap(n))
|
||||||
|
parallel.launch(thread)
|
||||||
|
panes[n] = { runner: runner, thread: thread }
|
||||||
|
sys.poke(CTRL + CTRL_SPAWNED_BITS, sys.peek(CTRL + CTRL_SPAWNED_BITS) | (1 << (n - 1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePane(n) {
|
||||||
|
if (!isPaneAlive(n)) {
|
||||||
|
sys.poke(CTRL + CTRL_SPAWNED_BITS, sys.peek(CTRL + CTRL_SPAWNED_BITS) & ~(1 << (n - 1)))
|
||||||
|
spawnPane(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ensurePane(1)
|
||||||
|
sys.poke(CTRL + CTRL_ACTIVE_VT, 1)
|
||||||
|
// VT 1's TVDOS.SYS eval is slow; give it room before we start compositing.
|
||||||
|
sys.sleep(800)
|
||||||
|
|
||||||
|
// ─── compositor / dispatcher loop ───────────────────────────────────────────
|
||||||
|
// 30 Hz: blit active pane → GPU text area; honour switch_request; detect
|
||||||
|
// Alt-N with debounce; drain typed chars into active pane's queue.
|
||||||
|
|
||||||
|
const gpuBase = graphics.getGpuMemBase()
|
||||||
|
const TEXTAREA_BASE_ABS = gpuBase - GPU_TEXTAREA_OFFSET
|
||||||
|
function blitVt(srcAddr) {
|
||||||
|
sys.memcpy(srcAddr, TEXTAREA_BASE_ABS, TEXT_PLANE_SIZE - 2)
|
||||||
|
sys.poke(TEXTAREA_BASE_ABS - (TEXT_PLANE_SIZE - 2), sys.peek(srcAddr + TEXT_PLANE_SIZE - 2))
|
||||||
|
sys.poke(TEXTAREA_BASE_ABS - (TEXT_PLANE_SIZE - 1), sys.peek(srcAddr + TEXT_PLANE_SIZE - 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GPU textmode-attribute MMIO byte (offset 6): bit 0 = blinkCursor, bit 1 =
|
||||||
|
// rawMode, bits 4-7 = chrrom. We flip only bit 0 to match the active pane's
|
||||||
|
// cursor visibility. getGpuMemBase() = -1 - 1MB*slot; the peripheral's MMIO
|
||||||
|
// window sits at IOSpace offset 128KB*slot, so MMIO byte k = -1 - (128KB*slot + k).
|
||||||
|
const gpuSlot = (((-gpuBase) - 1) / 1048576) | 0
|
||||||
|
const GPU_MMIO_ATTR = -1 - (131072 * gpuSlot + 6)
|
||||||
|
let lastCursorVis = -1
|
||||||
|
function applyCursorVis(active) {
|
||||||
|
let vis = sys.peek(vtBlockAddr(active) + 2)
|
||||||
|
if (vis === lastCursorVis) return
|
||||||
|
let attr = sys.peek(GPU_MMIO_ATTR)
|
||||||
|
sys.poke(GPU_MMIO_ATTR, vis ? (attr | 1) : (attr & 0xFE))
|
||||||
|
lastCursorVis = vis
|
||||||
|
}
|
||||||
|
|
||||||
|
function queuePush(vtN, byte) {
|
||||||
|
let qBase = vtBlockAddr(vtN)
|
||||||
|
let head = sys.peek(qBase + 8)
|
||||||
|
let tail = sys.peek(qBase + 9)
|
||||||
|
let next = (tail + 1) & 0xFF
|
||||||
|
if (next === head) return false
|
||||||
|
sys.poke(qBase + QUEUE_DATA_OFFSET + tail, byte)
|
||||||
|
sys.poke(qBase + 9, next)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchTo(n) {
|
||||||
|
if (n < 1 || n > MAX_VT) return
|
||||||
|
ensurePane(n)
|
||||||
|
sys.poke(CTRL + CTRL_ACTIVE_VT, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
sys.poke(-39, 1) // enable physical keyboard input collection
|
||||||
|
|
||||||
|
let running = true
|
||||||
|
while (running) {
|
||||||
|
let active = sys.peek(CTRL + CTRL_ACTIVE_VT)
|
||||||
|
if (active < 1 || active > MAX_VT) active = 1
|
||||||
|
blitVt(vtTextPlaneAddr(active))
|
||||||
|
applyCursorVis(active)
|
||||||
|
|
||||||
|
// honour chvt's switch request
|
||||||
|
let req = sys.peek(CTRL + CTRL_SWITCH_REQUEST)
|
||||||
|
if (req >= 1 && req <= MAX_VT) {
|
||||||
|
if (req !== active) {
|
||||||
|
serial.println("[vtmgr] chvt switch -> VT " + req)
|
||||||
|
switchTo(req)
|
||||||
|
}
|
||||||
|
sys.poke(CTRL + CTRL_SWITCH_REQUEST, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alt-N (and Alt-0 = exit) detection
|
||||||
|
sys.poke(-40, 1)
|
||||||
|
let keys = [sys.peek(-41), sys.peek(-42), sys.peek(-43), sys.peek(-44),
|
||||||
|
sys.peek(-45), sys.peek(-46), sys.peek(-47), sys.peek(-48)]
|
||||||
|
let altHeld = keys.indexOf(57) >= 0 || keys.indexOf(58) >= 0
|
||||||
|
let digit = -1
|
||||||
|
for (let n = 0; n <= MAX_VT; n++) {
|
||||||
|
if (keys.indexOf(7 + n) >= 0) { digit = n; break }
|
||||||
|
}
|
||||||
|
let debounce = sys.peek(CTRL + CTRL_DEBOUNCE_HELD) !== 0
|
||||||
|
|
||||||
|
if (debounce) {
|
||||||
|
if (!altHeld && digit < 0) sys.poke(CTRL + CTRL_DEBOUNCE_HELD, 0)
|
||||||
|
}
|
||||||
|
else if (altHeld && digit === 0) {
|
||||||
|
serial.println("[vtmgr] Alt-0 -> exit")
|
||||||
|
running = false
|
||||||
|
sys.poke(CTRL + CTRL_DEBOUNCE_HELD, 1)
|
||||||
|
sys.poke(-39, 1)
|
||||||
|
}
|
||||||
|
else if (altHeld && digit >= 1) {
|
||||||
|
serial.println("[vtmgr] Alt-" + digit + " -> switching to VT " + digit)
|
||||||
|
switchTo(digit)
|
||||||
|
sys.poke(CTRL + CTRL_DEBOUNCE_HELD, 1)
|
||||||
|
sys.poke(-39, 1) // swallow the digit char so it doesn't leak into the queue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!running) break
|
||||||
|
|
||||||
|
// Re-assert ownership of the cooked keyboard. keyboardInputRequested (-39)
|
||||||
|
// is a single global flag gating whether typed chars reach keyboardBuffer;
|
||||||
|
// the dispatcher relies on it staying 1. An active-pane app that used cooked
|
||||||
|
// host input (sys.read / sys.readKey leave it at 0) or crashed mid-read
|
||||||
|
// leaves it off, and the shimmed con.getch — unlike the base getch, which
|
||||||
|
// calls sys.readKey (→ -39=1) every time — never re-asserts it. That is why
|
||||||
|
// the current VT's keyboard locks up (and why it never happens without
|
||||||
|
// vtmgr). Re-enable ONLY when it is actually off: poke(-39,1) clears
|
||||||
|
// keyboardBuffer, so doing it every frame would drop chars typed last frame.
|
||||||
|
if (sys.peek(-39) === 0) sys.poke(-39, 1)
|
||||||
|
|
||||||
|
// drain typed chars into the active pane's queue
|
||||||
|
while (sys.peek(-50) !== 0) {
|
||||||
|
let k = sys.peek(-38)
|
||||||
|
if (k < 0) k += 256
|
||||||
|
queuePush(active, k)
|
||||||
|
}
|
||||||
|
|
||||||
|
sys.sleep(33)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let n = 1; n <= MAX_VT; n++) if (panes[n]) parallel.kill(panes[n].thread)
|
||||||
|
con.color_pair(254, 255)
|
||||||
|
con.clear()
|
||||||
|
println("vtmgr exited.")
|
||||||
|
return 0
|
||||||
16
assets/disk0/tvdos/bin/color.js.synopsis
Normal file
16
assets/disk0/tvdos/bin/color.js.synopsis
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "color",
|
||||||
|
"summary": "Set the screen background and foreground colours",
|
||||||
|
"symbols": {
|
||||||
|
"code": {
|
||||||
|
"kind": "positional",
|
||||||
|
"type": "string",
|
||||||
|
"name": "BF",
|
||||||
|
"summary": "Two hex digits: background then foreground",
|
||||||
|
"validation": { "pattern": "^[0-9A-Fa-f]{2}$" },
|
||||||
|
"completion": { "method": "none" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"synopsis": { "type": "reference", "symbol": "code" }
|
||||||
|
}
|
||||||
@@ -30,7 +30,18 @@ function makeHash() {
|
|||||||
const shellID = makeHash()
|
const shellID = makeHash()
|
||||||
|
|
||||||
function print_prompt_text() {
|
function print_prompt_text() {
|
||||||
|
// VT pane indicator: shown for VT 2..6, not VT 1 (the default) so the
|
||||||
|
// unmodified prompt is what users see when they never touch virtual
|
||||||
|
// consoles. VT_NUM is set by vtmgr's pane bootstrap.
|
||||||
|
let vtPrefix = ""
|
||||||
|
if (typeof VT_NUM !== "undefined" && VT_NUM > 1) vtPrefix = "[" + VT_NUM + "] "
|
||||||
if (goFancy) {
|
if (goFancy) {
|
||||||
|
if (vtPrefix) {
|
||||||
|
con.color_pair(161,253)
|
||||||
|
print(`\u00DD${VT_NUM}`)
|
||||||
|
con.color_pair(253,161)
|
||||||
|
con.addch(16);con.curs_right()
|
||||||
|
}
|
||||||
con.color_pair(239,161)
|
con.color_pair(239,161)
|
||||||
print(" "+CURRENT_DRIVE+":")
|
print(" "+CURRENT_DRIVE+":")
|
||||||
con.color_pair(161,253)
|
con.color_pair(161,253)
|
||||||
@@ -49,9 +60,9 @@ function print_prompt_text() {
|
|||||||
else {
|
else {
|
||||||
// con.color_pair(253,255)
|
// con.color_pair(253,255)
|
||||||
if (errorlevel != 0 && errorlevel != "undefined" && errorlevel != undefined)
|
if (errorlevel != 0 && errorlevel != "undefined" && errorlevel != undefined)
|
||||||
print(CURRENT_DRIVE + ":\\" + shell_pwd.join("\\") + " [" + errorlevel + "]" + PROMPT_TEXT)
|
print(vtPrefix + CURRENT_DRIVE + ":\\" + shell_pwd.join("\\") + " [" + errorlevel + "]" + PROMPT_TEXT)
|
||||||
else
|
else
|
||||||
print(CURRENT_DRIVE + ":\\" + shell_pwd.join("\\") + PROMPT_TEXT)
|
print(vtPrefix + CURRENT_DRIVE + ":\\" + shell_pwd.join("\\") + PROMPT_TEXT)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -620,6 +631,21 @@ shell.coreutils = {
|
|||||||
},
|
},
|
||||||
panic: function(args) {
|
panic: function(args) {
|
||||||
throw Error("Panicking command.js")
|
throw Error("Panicking command.js")
|
||||||
|
},
|
||||||
|
chvt: function(args) {
|
||||||
|
// Request a switch to another virtual console. Only meaningful when
|
||||||
|
// running inside a pane spawned by vtmgr (VT_CTRL_ADDR is set by the
|
||||||
|
// pane bootstrap). Outside that environment this is a no-op error.
|
||||||
|
if (args[1] === undefined) { printerrln("Usage: chvt N (1..6)"); return 1 }
|
||||||
|
let n = parseInt(args[1])
|
||||||
|
if (isNaN(n) || n < 1 || n > 6) { printerrln("chvt: N must be in 1..6"); return 1 }
|
||||||
|
if (typeof VT_CTRL_ADDR === "undefined") {
|
||||||
|
printerrln("chvt: not running under vtmgr (no VT context)"); return 1
|
||||||
|
}
|
||||||
|
// CTRL_SWITCH_REQUEST is byte +1 of the shared CTRL area. Dispatcher
|
||||||
|
// picks this up on its next 30 Hz tick and performs the switch.
|
||||||
|
sys.poke(VT_CTRL_ADDR + 1, n)
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// define command aliases here
|
// define command aliases here
|
||||||
@@ -636,10 +662,14 @@ shell.coreutils.where = shell.coreutils.which
|
|||||||
Object.freeze(shell.coreutils)
|
Object.freeze(shell.coreutils)
|
||||||
shell.stdio = {
|
shell.stdio = {
|
||||||
out: {
|
out: {
|
||||||
print: function(s) { sys.print(s) },
|
// When running inside a vtmgr virtual console, __VT_OUT routes output
|
||||||
println: function(s) { if (s === undefined) sys.print("\n"); else sys.print(s+"\n") },
|
// to the pane's text-plane buffer instead of the physical GPU (which
|
||||||
printerr: function(s) { sys.print("\x1B[31m"+s+"\x1B[m") },
|
// the compositor would otherwise overwrite). Outside a VT the hook is
|
||||||
printerrln: function(s) { if (s === undefined) sys.print("\n"); else sys.print("\x1B[31m"+s+"\x1B[m\n") },
|
// absent and these fall through to sys.print exactly as before.
|
||||||
|
print: function(s) { if (globalThis.__VT_OUT) globalThis.__VT_OUT.print(s); else sys.print(s) },
|
||||||
|
println: function(s) { if (globalThis.__VT_OUT) globalThis.__VT_OUT.println(s); else { if (s === undefined) sys.print("\n"); else sys.print(s+"\n") } },
|
||||||
|
printerr: function(s) { if (globalThis.__VT_OUT) globalThis.__VT_OUT.printerr(s); else sys.print("\x1B[31m"+s+"\x1B[m") },
|
||||||
|
printerrln: function(s) { if (globalThis.__VT_OUT) globalThis.__VT_OUT.printerrln(s); else { if (s === undefined) sys.print("\n"); else sys.print("\x1B[31m"+s+"\x1B[m\n") } },
|
||||||
},
|
},
|
||||||
pipe: {
|
pipe: {
|
||||||
print: function(s) { if (shell.getPipe() === undefined) throw Error("No pipe opened"); shell.appendToCurrentPipe(s); },
|
print: function(s) { if (shell.getPipe() === undefined) throw Error("No pipe opened"); shell.appendToCurrentPipe(s); },
|
||||||
@@ -955,6 +985,234 @@ shell.removePipe = function() {
|
|||||||
Object.freeze(shell)
|
Object.freeze(shell)
|
||||||
_G.shell = shell
|
_G.shell = shell
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// TAB AUTOCOMPLETION
|
||||||
|
//
|
||||||
|
// Invoked by TAB at the interactive prompt. Only active when BOTH:
|
||||||
|
// 1. wintex.mjs is available (provides the selection popup), AND
|
||||||
|
// 2. goFancy == true.
|
||||||
|
// One candidate -> expand immediately (no popup).
|
||||||
|
// Many candidates -> wintex popup; user scrolls and selects, or Esc/Cancel to
|
||||||
|
// discard. The popup over-draws the screen without saving
|
||||||
|
// what was beneath it, so we snapshot the text plane before
|
||||||
|
// and copy it back after (the shell can't just redraw like a
|
||||||
|
// full-screen TUI — there's scrollback above the prompt).
|
||||||
|
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// Lazily-resolved wintex module. undefined = not probed yet, null = unavailable.
|
||||||
|
let _acWin = undefined
|
||||||
|
function getAutocompleteWin() {
|
||||||
|
if (_acWin !== undefined) return _acWin
|
||||||
|
_acWin = null
|
||||||
|
try {
|
||||||
|
let w = require("wintex") // resolved through INCLPATH (\tvdos\include\wintex.mjs)
|
||||||
|
if (w && typeof w.showDialog === "function") _acWin = w
|
||||||
|
} catch (e) {
|
||||||
|
debugprintln("command.js > autocomplete: wintex unavailable: " + e)
|
||||||
|
}
|
||||||
|
return _acWin
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazily-resolved synopsis module (TSF loader/completion resolver). Held for
|
||||||
|
// the whole session so its in-memory cache survives across keystrokes.
|
||||||
|
// undefined = not probed yet, null = unavailable.
|
||||||
|
let _acSyn = undefined
|
||||||
|
function getSynopsisMod() {
|
||||||
|
if (_acSyn !== undefined) return _acSyn
|
||||||
|
_acSyn = null
|
||||||
|
try {
|
||||||
|
let m = require("synopsis") // resolved through INCLPATH (\tvdos\include\synopsis.mjs)
|
||||||
|
if (m && typeof m.getCompletion === "function") _acSyn = m
|
||||||
|
} catch (e) {
|
||||||
|
debugprintln("command.js > autocomplete: synopsis unavailable: " + e)
|
||||||
|
}
|
||||||
|
return _acSyn
|
||||||
|
}
|
||||||
|
|
||||||
|
// List a directory's entries, swallowing any IO error.
|
||||||
|
function _acListDir(fullPath) {
|
||||||
|
try {
|
||||||
|
let f = files.open(fullPath)
|
||||||
|
if (!f.exists || !f.isDirectory) return []
|
||||||
|
return f.list() || []
|
||||||
|
} catch (e) { return [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip a trailing PATHEXT extension so command names show without ".js" etc.
|
||||||
|
function _acStripExt(name) {
|
||||||
|
let lower = name.toLowerCase()
|
||||||
|
let exts = (_TVDOS.variables.PATHEXT || "").split(';').filter(function(e){ return e.length > 0 })
|
||||||
|
for (let i = 0; i < exts.length; i++) {
|
||||||
|
let e = exts[i].toLowerCase()
|
||||||
|
if (lower.endsWith(e)) return name.substring(0, name.length - e.length)
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Candidates for the command position (first word, no path separators):
|
||||||
|
// shell built-ins + runnable files found along the current dir, drive root and PATH.
|
||||||
|
function _acCommandCandidates(prefix) {
|
||||||
|
let lower = prefix.toLowerCase()
|
||||||
|
let seen = {}
|
||||||
|
let out = []
|
||||||
|
function add(name) {
|
||||||
|
let k = name.toLowerCase()
|
||||||
|
if (seen[k]) return
|
||||||
|
seen[k] = true
|
||||||
|
out.push({ label: name, value: name + ' ', isDir: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
// shell built-ins (and their aliases)
|
||||||
|
Object.keys(shell.coreutils).forEach(function(k) {
|
||||||
|
if (k.toLowerCase().startsWith(lower)) add(k)
|
||||||
|
})
|
||||||
|
|
||||||
|
// runnable files: search the same places shell.execute does, in the same order
|
||||||
|
let exts = (_TVDOS.variables.PATHEXT || "").split(';')
|
||||||
|
.filter(function(e){ return e.length > 0 }).map(function(e){ return e.toLowerCase() })
|
||||||
|
let dirFulls = [shell.resolvePathInput('.').full] // current directory first
|
||||||
|
_TVDOS.getPath().forEach(function(d) {
|
||||||
|
dirFulls.push((d === '' || d === undefined) ? `${CURRENT_DRIVE}:\\` : shell.resolvePathInput(d).full)
|
||||||
|
})
|
||||||
|
dirFulls.forEach(function(full) {
|
||||||
|
_acListDir(full).forEach(function(it) {
|
||||||
|
if (it.isDirectory) return
|
||||||
|
let nameLower = (it.name || '').toLowerCase()
|
||||||
|
if (!exts.some(function(e){ return nameLower.endsWith(e) })) return // only runnables
|
||||||
|
let stripped = _acStripExt(it.name)
|
||||||
|
if (stripped.toLowerCase().startsWith(lower)) add(stripped)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Candidates for a path argument. The word may carry a directory prefix
|
||||||
|
// (kept verbatim) and a partial basename that we match against the directory.
|
||||||
|
function _acPathCandidates(word) {
|
||||||
|
let sepIdx = Math.max(word.lastIndexOf('\\'), word.lastIndexOf('/'))
|
||||||
|
let dirPart, basePart, listArg
|
||||||
|
if (sepIdx >= 0) {
|
||||||
|
dirPart = word.substring(0, sepIdx + 1) // includes the trailing separator
|
||||||
|
basePart = word.substring(sepIdx + 1)
|
||||||
|
listArg = dirPart
|
||||||
|
} else {
|
||||||
|
dirPart = ''
|
||||||
|
basePart = word
|
||||||
|
listArg = '.'
|
||||||
|
}
|
||||||
|
let resolved = shell.resolvePathInput(listArg)
|
||||||
|
if (resolved === undefined) return []
|
||||||
|
let sep = (dirPart.length > 0 && dirPart.charAt(dirPart.length - 1) === '/') ? '/' : '\\'
|
||||||
|
let lower = basePart.toLowerCase()
|
||||||
|
let out = []
|
||||||
|
_acListDir(resolved.full).forEach(function(it) {
|
||||||
|
let name = it.name || ''
|
||||||
|
if (!name.toLowerCase().startsWith(lower)) return
|
||||||
|
out.push({
|
||||||
|
// directories get a trailing separator so completion can continue into them;
|
||||||
|
// files get a trailing space so the next argument can be typed straight away.
|
||||||
|
label: name + (it.isDirectory ? '\\' : ''),
|
||||||
|
value: dirPart + name + (it.isDirectory ? sep : ' '),
|
||||||
|
isDir: it.isDirectory
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Candidates for an argument (not the command word). Consults the command's
|
||||||
|
// TSF synopsis (via synopsis.mjs) for option flags, enum/list values and
|
||||||
|
// subcommand names, and merges in filesystem entries when the synopsis says the
|
||||||
|
// slot expects a path/file/directory. Falls back to plain path completion when
|
||||||
|
// no synopsis exists, so behaviour is unchanged for commands without one.
|
||||||
|
function _acArgCandidates(prefix, word) {
|
||||||
|
let syn = getSynopsisMod()
|
||||||
|
if (syn) {
|
||||||
|
try {
|
||||||
|
let toks = prefix.trim().split(/\s+/)
|
||||||
|
let cmd = toks[0]
|
||||||
|
let argToks = toks.slice(1)
|
||||||
|
let r = syn.getCompletion(cmd, argToks, word)
|
||||||
|
if (r && r.ok) {
|
||||||
|
let out = (r.candidates || []).slice()
|
||||||
|
if (r.filesystem) {
|
||||||
|
_acPathCandidates(word).forEach(function(c) {
|
||||||
|
if (r.filesystem === 'directory' && !c.isDir) return // dirs only
|
||||||
|
out.push(c)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// de-dupe by the text that would be inserted
|
||||||
|
let seen = {}, dedup = []
|
||||||
|
out.forEach(function(c) { if (seen[c.value]) return; seen[c.value] = true; dedup.push(c) })
|
||||||
|
return dedup
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugprintln("command.js > _acArgCandidates: " + e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _acPathCandidates(word)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Work out what is being completed at `caret` within `line`.
|
||||||
|
// Returns { wordStart, word, candidates } (candidates sorted by label).
|
||||||
|
function computeCompletion(line, caret) {
|
||||||
|
let wordStart = caret
|
||||||
|
while (wordStart > 0 && line.charAt(wordStart - 1) !== ' ') wordStart -= 1
|
||||||
|
let word = line.substring(wordStart, caret)
|
||||||
|
let prefix = line.substring(0, wordStart)
|
||||||
|
let isFirstWord = (prefix.trim().length === 0)
|
||||||
|
let hasPathSep = (word.indexOf('\\') >= 0 || word.indexOf('/') >= 0 || word.indexOf(':') >= 0)
|
||||||
|
let candidates
|
||||||
|
if (isFirstWord)
|
||||||
|
candidates = hasPathSep ? _acPathCandidates(word) : _acCommandCandidates(word)
|
||||||
|
else
|
||||||
|
candidates = _acArgCandidates(prefix, word)
|
||||||
|
candidates.sort(function(a, b) { return (a.label < b.label) ? -1 : (a.label > b.label) ? 1 : 0 })
|
||||||
|
return { wordStart: wordStart, word: word, candidates: candidates }
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- text-plane snapshot/restore (so the popup leaves no artefacts) ---------
|
||||||
|
// In a vtmgr pane the shimmed con/print draw into the pane buffer
|
||||||
|
// (globalThis.VT_TEXT_PLANE, forward layout); on the physical console they
|
||||||
|
// draw into the GPU text area (mapped at getGpuMemBase()-253950). vaddr(0) is
|
||||||
|
// that base in either case; sys.memcpy reads/writes it forward-native.
|
||||||
|
// NOTE: 7681, not the full 7682-byte text area: relPtrInDev() bounds-checks
|
||||||
|
// `from+len` inclusively, so the final byte (bottom-right char cell, never
|
||||||
|
// touched by a centred popup) is unreachable by a single memcpy.
|
||||||
|
const _AC_TEXTAREA_BYTES = 7681
|
||||||
|
let _acTextBase = null
|
||||||
|
let _acScratchPtr = 0
|
||||||
|
function _acTextAreaBase() {
|
||||||
|
if (_acTextBase === null) {
|
||||||
|
_acTextBase = (typeof globalThis.VT_TEXT_PLANE !== 'undefined')
|
||||||
|
? globalThis.VT_TEXT_PLANE
|
||||||
|
: (graphics.getGpuMemBase() - 253950)
|
||||||
|
}
|
||||||
|
return _acTextBase
|
||||||
|
}
|
||||||
|
function _acSnapshotScreen() {
|
||||||
|
if (_acScratchPtr === 0) _acScratchPtr = sys.malloc(_AC_TEXTAREA_BYTES)
|
||||||
|
sys.memcpy(_acTextAreaBase(), _acScratchPtr, _AC_TEXTAREA_BYTES)
|
||||||
|
}
|
||||||
|
function _acRestoreScreen() {
|
||||||
|
if (_acScratchPtr === 0) return
|
||||||
|
sys.memcpy(_acScratchPtr, _acTextAreaBase(), _AC_TEXTAREA_BYTES)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal popup of candidates. Returns the chosen item, or null if discarded.
|
||||||
|
function _acShowPopup(win, candidates) {
|
||||||
|
let res = win.showDialog({
|
||||||
|
title: `Complete (${candidates.length})`,
|
||||||
|
list: {
|
||||||
|
items: candidates,
|
||||||
|
height: Math.min(12, candidates.length),
|
||||||
|
onActivate: function(item, idx, key) { return 'select' }
|
||||||
|
},
|
||||||
|
buttons: [{ label: 'Cancel', action: 'cancel' }]
|
||||||
|
})
|
||||||
|
if (res && res.action === 'select' && res.listItem) return res.listItem
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
// ensure USERCONFIGPATH directory exists
|
// ensure USERCONFIGPATH directory exists
|
||||||
@@ -1012,23 +1270,133 @@ if (goInteractive) {
|
|||||||
print_prompt_text()
|
print_prompt_text()
|
||||||
|
|
||||||
var cmdbuf = ""
|
var cmdbuf = ""
|
||||||
|
var caret = 0 // insertion point within cmdbuf, 0..cmdbuf.length
|
||||||
|
|
||||||
|
// Self-contained line editor with a movable caret (so command.js does
|
||||||
|
// NOT depend on wintex being installed). The prompt has just been
|
||||||
|
// printed, so the current cursor marks where the editable text begins.
|
||||||
|
// We track that anchor and rebuild the on-screen line from it, decoding
|
||||||
|
// line-wrap ourselves so the maths holds in both the physical console
|
||||||
|
// and a vtmgr pane (whose con.move CLAMPS x instead of wrapping it).
|
||||||
|
let [baseY, baseX] = con.getyx() // 1-based
|
||||||
|
let termCols = con.getmaxyx()[1]
|
||||||
|
|
||||||
|
// absolute (y,x) on screen for caret index `idx`
|
||||||
|
function caretPos(idx) {
|
||||||
|
let abs = (baseX - 1) + idx
|
||||||
|
return [baseY + ((abs / termCols) | 0), (abs % termCols) + 1]
|
||||||
|
}
|
||||||
|
function gotoCaret() {
|
||||||
|
let [cy, cx] = caretPos(caret)
|
||||||
|
con.move(cy, cx)
|
||||||
|
}
|
||||||
|
// reprint cmdbuf from index `from` to the end, optionally padding with
|
||||||
|
// `clearTrail` blanks to wipe characters left over by a now-shorter
|
||||||
|
// line, then park the hardware cursor back on the caret.
|
||||||
|
function refresh(from, clearTrail) {
|
||||||
|
let [py, px] = caretPos(from)
|
||||||
|
con.move(py, px)
|
||||||
|
print(cmdbuf.substring(from))
|
||||||
|
for (let i = 0; i < clearTrail; i++) print(" ")
|
||||||
|
gotoCaret()
|
||||||
|
}
|
||||||
|
// replace the whole buffer (used by history recall)
|
||||||
|
function setBuf(next) {
|
||||||
|
let oldLen = cmdbuf.length
|
||||||
|
cmdbuf = next
|
||||||
|
caret = cmdbuf.length
|
||||||
|
refresh(0, Math.max(0, oldLen - cmdbuf.length))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the word [wordStart, caret) with `value`, keeping any text to
|
||||||
|
// the right of the caret, then reprint the line from `wordStart`.
|
||||||
|
function applyCompletion(wordStart, value) {
|
||||||
|
let oldLen = cmdbuf.length
|
||||||
|
cmdbuf = cmdbuf.substring(0, wordStart) + value + cmdbuf.substring(caret)
|
||||||
|
caret = wordStart + value.length
|
||||||
|
con.color_pair(shell.usrcfg.textCol, 255)
|
||||||
|
refresh(wordStart, Math.max(0, oldLen - cmdbuf.length))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TAB handler. No-op unless fancy mode is on and wintex is installed.
|
||||||
|
function tryAutocomplete() {
|
||||||
|
if (!goFancy) return
|
||||||
|
let win = getAutocompleteWin()
|
||||||
|
if (!win) return
|
||||||
|
|
||||||
|
let comp = computeCompletion(cmdbuf, caret)
|
||||||
|
let cands = comp.candidates
|
||||||
|
if (cands.length === 0) return
|
||||||
|
if (cands.length === 1) { applyCompletion(comp.wordStart, cands[0].value); return }
|
||||||
|
|
||||||
|
_acSnapshotScreen()
|
||||||
|
let chosen = _acShowPopup(win, cands)
|
||||||
|
_acRestoreScreen()
|
||||||
|
|
||||||
|
// The popup drives input through input.withEvent (physical held-key
|
||||||
|
// state), which bypasses the buffer con.getch reads. Inside a vtmgr
|
||||||
|
// pane the dispatcher keeps draining physical keystrokes into this
|
||||||
|
// pane's input ring the whole time the popup is open, so the navigation
|
||||||
|
// keys (and the closing Enter) would otherwise surface as phantom input
|
||||||
|
// afterwards. Flush them. (On the physical console readKey self-clears,
|
||||||
|
// so this is harmless there.)
|
||||||
|
con.resetkeybuf()
|
||||||
|
|
||||||
|
// The popup hid the caret and clobbered colours; restore the prompt
|
||||||
|
// editing state. The screen content is already back from the snapshot.
|
||||||
|
con.curs_set(1)
|
||||||
|
con.color_pair(shell.usrcfg.textCol, 255)
|
||||||
|
gotoCaret()
|
||||||
|
|
||||||
|
if (chosen) applyCompletion(comp.wordStart, chosen.value)
|
||||||
|
}
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
let key = con.getch()
|
let key = con.getch()
|
||||||
|
|
||||||
// printable chars
|
// printable chars
|
||||||
if (key >= 32 && key <= 126) {
|
if (key >= 32 && key <= 126) {
|
||||||
var s = String.fromCharCode(key)
|
let s = String.fromCharCode(key)
|
||||||
cmdbuf += s
|
let atEnd = (caret === cmdbuf.length)
|
||||||
print(s)
|
cmdbuf = cmdbuf.substring(0, caret) + s + cmdbuf.substring(caret)
|
||||||
|
caret += 1
|
||||||
|
if (atEnd) print(s) // fast path: simple append
|
||||||
|
else refresh(caret - 1, 0)
|
||||||
}
|
}
|
||||||
// backspace
|
// TAB: autocomplete (fancy mode + wintex only; otherwise a no-op)
|
||||||
else if (key === con.KEY_BACKSPACE && cmdbuf.length > 0) {
|
else if (key === con.KEY_TAB) {
|
||||||
cmdbuf = cmdbuf.substring(0, cmdbuf.length - 1)
|
tryAutocomplete()
|
||||||
print(String.fromCharCode(key))
|
}
|
||||||
|
// backspace: delete the char to the left of the caret
|
||||||
|
else if (key === con.KEY_BACKSPACE && caret > 0) {
|
||||||
|
cmdbuf = cmdbuf.substring(0, caret - 1) + cmdbuf.substring(caret)
|
||||||
|
caret -= 1
|
||||||
|
refresh(caret, 1)
|
||||||
|
}
|
||||||
|
// forward delete: delete the char under the caret
|
||||||
|
else if (key === con.KEY_DELETE && caret < cmdbuf.length) {
|
||||||
|
cmdbuf = cmdbuf.substring(0, caret) + cmdbuf.substring(caret + 1)
|
||||||
|
refresh(caret, 1)
|
||||||
|
}
|
||||||
|
// caret left
|
||||||
|
else if (key === con.KEY_LEFT) {
|
||||||
|
if (caret > 0) { caret -= 1; gotoCaret() }
|
||||||
|
}
|
||||||
|
// caret right
|
||||||
|
else if (key === con.KEY_RIGHT) {
|
||||||
|
if (caret < cmdbuf.length) { caret += 1; gotoCaret() }
|
||||||
|
}
|
||||||
|
// jump to start of line
|
||||||
|
else if (key === con.KEY_HOME) {
|
||||||
|
caret = 0; gotoCaret()
|
||||||
|
}
|
||||||
|
// jump to end of line
|
||||||
|
else if (key === con.KEY_END) {
|
||||||
|
caret = cmdbuf.length; gotoCaret()
|
||||||
}
|
}
|
||||||
// enter
|
// enter
|
||||||
else if (key === 10 || key === con.KEY_RETURN) {
|
else if (key === 10 || key === con.KEY_RETURN) {
|
||||||
|
caret = cmdbuf.length; gotoCaret()
|
||||||
println()
|
println()
|
||||||
|
|
||||||
errorlevel = shell.execute(cmdbuf)
|
errorlevel = shell.execute(cmdbuf)
|
||||||
@@ -1044,32 +1412,17 @@ if (goInteractive) {
|
|||||||
// up arrow
|
// up arrow
|
||||||
else if (key === con.KEY_UP && cmdHistory.length > 0 && cmdHistoryScroll < cmdHistory.length) {
|
else if (key === con.KEY_UP && cmdHistory.length > 0 && cmdHistoryScroll < cmdHistory.length) {
|
||||||
cmdHistoryScroll += 1
|
cmdHistoryScroll += 1
|
||||||
|
setBuf(cmdHistory[cmdHistory.length - cmdHistoryScroll])
|
||||||
// back the cursor in order to type new cmd
|
|
||||||
var x = 0
|
|
||||||
for (x = 0; x < cmdbuf.length; x++) print(String.fromCharCode(8))
|
|
||||||
cmdbuf = cmdHistory[cmdHistory.length - cmdHistoryScroll]
|
|
||||||
// re-type the new command
|
|
||||||
print(cmdbuf)
|
|
||||||
|
|
||||||
}
|
}
|
||||||
// down arrow
|
// down arrow
|
||||||
else if (key === con.KEY_DOWN) {
|
else if (key === con.KEY_DOWN) {
|
||||||
if (cmdHistoryScroll > 0) {
|
if (cmdHistoryScroll > 1) {
|
||||||
// back the cursor in order to type new cmd
|
|
||||||
var x = 0
|
|
||||||
for (x = 0; x < cmdbuf.length; x++) print(String.fromCharCode(8))
|
|
||||||
cmdbuf = cmdHistory[cmdHistory.length - cmdHistoryScroll]
|
|
||||||
// re-type the new command
|
|
||||||
print(cmdbuf)
|
|
||||||
|
|
||||||
cmdHistoryScroll -= 1
|
cmdHistoryScroll -= 1
|
||||||
|
setBuf(cmdHistory[cmdHistory.length - cmdHistoryScroll])
|
||||||
}
|
}
|
||||||
else {
|
else if (cmdHistoryScroll === 1) {
|
||||||
// back the cursor in order to type new cmd
|
cmdHistoryScroll = 0
|
||||||
var x = 0
|
setBuf("")
|
||||||
for (x = 0; x < cmdbuf.length; x++) print(String.fromCharCode(8))
|
|
||||||
cmdbuf = ""
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
7
assets/disk0/tvdos/bin/drives.js.synopsis
Normal file
7
assets/disk0/tvdos/bin/drives.js.synopsis
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "drives",
|
||||||
|
"summary": "List connected and mounted disk drives",
|
||||||
|
"symbols": {},
|
||||||
|
"synopsis": { "type": "sequence", "children": [] }
|
||||||
|
}
|
||||||
12
assets/disk0/tvdos/bin/edit.js.synopsis
Normal file
12
assets/disk0/tvdos/bin/edit.js.synopsis
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "edit",
|
||||||
|
"summary": "Full-screen text editor",
|
||||||
|
"symbols": {
|
||||||
|
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "File to edit; a new buffer when omitted" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "optional",
|
||||||
|
"child": { "type": "reference", "symbol": "file" }
|
||||||
|
}
|
||||||
|
}
|
||||||
9
assets/disk0/tvdos/bin/geturl.js.synopsis
Normal file
9
assets/disk0/tvdos/bin/geturl.js.synopsis
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "geturl",
|
||||||
|
"summary": "Fetch a URL and print the response",
|
||||||
|
"symbols": {
|
||||||
|
"url": { "kind": "positional", "type": "url", "name": "URL", "summary": "Address to fetch" }
|
||||||
|
},
|
||||||
|
"synopsis": { "type": "reference", "symbol": "url" }
|
||||||
|
}
|
||||||
18
assets/disk0/tvdos/bin/gzip.js.synopsis
Normal file
18
assets/disk0/tvdos/bin/gzip.js.synopsis
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "gzip",
|
||||||
|
"summary": "Compress or decompress a file (Zstd-backed)",
|
||||||
|
"symbols": {
|
||||||
|
"decompress": { "kind": "option", "short": "-d", "summary": "Decompress instead of compress" },
|
||||||
|
"stdout": { "kind": "option", "short": "-c", "summary": "Write to the pipe instead of a file" },
|
||||||
|
"options": { "kind": "group", "summary": "Options", "members": ["decompress", "stdout"] },
|
||||||
|
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "File to process" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "sequence",
|
||||||
|
"children": [
|
||||||
|
{ "type": "repeat", "child": { "type": "reference", "symbol": "options" } },
|
||||||
|
{ "type": "reference", "symbol": "file" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
1
assets/disk0/tvdos/bin/help.alias
Normal file
1
assets/disk0/tvdos/bin/help.alias
Normal file
@@ -0,0 +1 @@
|
|||||||
|
synopsis $0
|
||||||
12
assets/disk0/tvdos/bin/hexdump.js.synopsis
Normal file
12
assets/disk0/tvdos/bin/hexdump.js.synopsis
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "hexdump",
|
||||||
|
"summary": "Print a file as a hexadecimal dump",
|
||||||
|
"symbols": {
|
||||||
|
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "File to dump; reads from the pipe when omitted" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "optional",
|
||||||
|
"child": { "type": "reference", "symbol": "file" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ const net = require("net")
|
|||||||
// hopper {search,se} [--provides, --requires, --description, --author] query
|
// hopper {search,se} [--provides, --requires, --description, --author] query
|
||||||
//// default searches from ProperName
|
//// default searches from ProperName
|
||||||
// hopper {install,in} query [-v version]
|
// hopper {install,in} query [-v version]
|
||||||
|
// hopper {upgrade,up} [package...]
|
||||||
|
//// no package names upgrades every user-installed package
|
||||||
// hopper {remove,rm} query
|
// hopper {remove,rm} query
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -372,14 +374,31 @@ function findProviders(idx, name) {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort: installed first (no churn), then highest version, then upstream order.
|
// Sort candidates by preference. Normally installed-first (no churn) then
|
||||||
function sortCandidates(cands) {
|
// highest version; with `preferNewest` (used by `upgrade`) the installed
|
||||||
|
// bias is dropped so the newest package version wins regardless of source.
|
||||||
|
function sortCandidates(cands, preferNewest) {
|
||||||
return cands.slice().sort((a, b) => {
|
return cands.slice().sort((a, b) => {
|
||||||
if (a.source !== b.source) return (a.source === "installed") ? -1 : 1
|
if (!preferNewest && a.source !== b.source) return (a.source === "installed") ? -1 : 1
|
||||||
return -compareVersion(a.version, b.version)
|
return -compareVersion(a.version, b.version)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Highest *package* version (HopperPackageVersion) among the candidates that
|
||||||
|
// ARE the package `name` -- installed or upstream -- or null if there are
|
||||||
|
// none. `upgrade` uses this to decide whether a newer build exists. It
|
||||||
|
// deliberately ignores HopperProvides versions (which advance independently
|
||||||
|
// of the package version) and other packages that merely provide `name`;
|
||||||
|
// the candidate index is keyed by package name, so idx.get(name) is exactly
|
||||||
|
// the builds of that package.
|
||||||
|
function latestInstallableVersion(idx, name) {
|
||||||
|
const arr = idx.get(name)
|
||||||
|
if (!arr || arr.length === 0) return null
|
||||||
|
let best = null
|
||||||
|
arr.forEach(c => { if (best === null || compareVersion(c.version, best) > 0) best = c.version })
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Resolver (snapshot-based backtracking; precursor to a SAT solver)
|
// Resolver (snapshot-based backtracking; precursor to a SAT solver)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -399,7 +418,7 @@ function sortCandidates(cands) {
|
|||||||
// recursive resolve() call over each requirement. Replacing this with
|
// recursive resolve() call over each requirement. Replacing this with
|
||||||
// clause learning / a watched-literals scheme later would be local.
|
// clause learning / a watched-literals scheme later would be local.
|
||||||
|
|
||||||
function resolveAll(idx, requirements) {
|
function resolveAll(idx, requirements, upgradeSet, pkgVersionPins) {
|
||||||
const chosen = new Map()
|
const chosen = new Map()
|
||||||
const issues = []
|
const issues = []
|
||||||
|
|
||||||
@@ -407,10 +426,16 @@ function resolveAll(idx, requirements) {
|
|||||||
function restore(snap) { chosen.clear(); snap.forEach((v, k) => chosen.set(k, v)) }
|
function restore(snap) { chosen.clear(); snap.forEach((v, k) => chosen.set(k, v)) }
|
||||||
|
|
||||||
function _resolve(reqName, constraint, trail) {
|
function _resolve(reqName, constraint, trail) {
|
||||||
|
// A package-version pin (from `install -v`) constrains the chosen
|
||||||
|
// build's HopperPackageVersion -- the version `search` shows -- and is
|
||||||
|
// matched separately from `constraint`, which always works in the
|
||||||
|
// HopperProvides capability space.
|
||||||
|
const pin = (pkgVersionPins && pkgVersionPins.get(reqName)) || null
|
||||||
|
|
||||||
const existing = chosen.get(reqName)
|
const existing = chosen.get(reqName)
|
||||||
if (existing !== undefined) {
|
if (existing !== undefined) {
|
||||||
const v = providedVersionOf(existing, reqName)
|
const v = providedVersionOf(existing, reqName)
|
||||||
return satisfies(v, constraint)
|
return (satisfies(v, constraint) && (!pin || satisfies(existing.version, pin)))
|
||||||
? { ok: true }
|
? { ok: true }
|
||||||
: { ok: false, reason: `${reqName} pinned to ${v}, but ${trail.join(" -> ")} requires ${constraint}` }
|
: { ok: false, reason: `${reqName} pinned to ${v}, but ${trail.join(" -> ")} requires ${constraint}` }
|
||||||
}
|
}
|
||||||
@@ -421,9 +446,19 @@ function resolveAll(idx, requirements) {
|
|||||||
}
|
}
|
||||||
// Satisfaction checks the virtual version the candidate exposes
|
// Satisfaction checks the virtual version the candidate exposes
|
||||||
// for `reqName` (HopperProvides), not necessarily the package's
|
// for `reqName` (HopperProvides), not necessarily the package's
|
||||||
// own HopperPackageVersion.
|
// own HopperPackageVersion. A package-version pin is applied on top,
|
||||||
const matching = sortCandidates(providers.filter(c => satisfies(providedVersionOf(c, reqName), constraint)))
|
// against the build's own version.
|
||||||
|
const preferNewest = !!(upgradeSet && upgradeSet.has(reqName))
|
||||||
|
const provMatched = providers.filter(c => satisfies(providedVersionOf(c, reqName), constraint))
|
||||||
|
const matching = sortCandidates(provMatched.filter(c => !pin || satisfies(c.version, pin)), preferNewest)
|
||||||
if (matching.length === 0) {
|
if (matching.length === 0) {
|
||||||
|
// When the package-version pin is what eliminated the candidates,
|
||||||
|
// report it in package-version space (matching `search`); a plain
|
||||||
|
// capability mismatch stays in HopperProvides space.
|
||||||
|
if (pin && provMatched.length > 0) {
|
||||||
|
const versions = providers.map(p => `${p.version}[${p.source}]`).join(", ")
|
||||||
|
return { ok: false, reason: `no build of "${reqName}" has version ${pin} (available: ${versions})` }
|
||||||
|
}
|
||||||
const versions = providers.map(p => `${providedVersionOf(p, reqName)}[${p.source}]`).join(", ")
|
const versions = providers.map(p => `${providedVersionOf(p, reqName)}[${p.source}]`).join(", ")
|
||||||
return { ok: false, reason: `no version of "${reqName}" satisfies ${constraint} (available: ${versions})` }
|
return { ok: false, reason: `no version of "${reqName}" satisfies ${constraint} (available: ${versions})` }
|
||||||
}
|
}
|
||||||
@@ -728,43 +763,11 @@ function _installOne(action, candidate) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function cmdInstall(args) {
|
// Shared tail for `install` and `upgrade`: turn a resolver result into an
|
||||||
let query = undefined
|
// actual on-disk change. Prints the plan, runs the pre-flight checks
|
||||||
let version = undefined
|
// (system-package and missing-payload blockers, modem availability), asks
|
||||||
for (let i = 0; i < args.length; i++) {
|
// for confirmation, then fetches and writes every changing package.
|
||||||
if (args[i] === "-v") { version = args[i + 1]; i++ }
|
function commitResolution(idx, chosen, issues, planLabel, confirmMsg) {
|
||||||
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) {
|
if (issues.length > 0) {
|
||||||
printerrln("Resolution failed:")
|
printerrln("Resolution failed:")
|
||||||
issues.forEach(reason => printerrln(` - ${reason}`))
|
issues.forEach(reason => printerrln(` - ${reason}`))
|
||||||
@@ -774,7 +777,7 @@ function cmdInstall(args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const plan = classifyPlan(idx, chosen)
|
const plan = classifyPlan(idx, chosen)
|
||||||
printPlan(plan, query)
|
printPlan(plan, planLabel)
|
||||||
|
|
||||||
const changing = plan.filter(a => a.action !== "keep")
|
const changing = plan.filter(a => a.action !== "keep")
|
||||||
if (changing.length === 0) return 0
|
if (changing.length === 0) return 0
|
||||||
@@ -804,7 +807,7 @@ function cmdInstall(args) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
println("")
|
println("")
|
||||||
if (!confirm("Proceed with installation?", true)) {
|
if (!confirm(confirmMsg, true)) {
|
||||||
println("Aborted.")
|
println("Aborted.")
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@@ -835,6 +838,132 @@ function cmdInstall(args) {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 verSuffix = version ? ` (${version})` : ""
|
||||||
|
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).
|
||||||
|
//
|
||||||
|
// A user-supplied `-v` pins the target's PACKAGE version (the version
|
||||||
|
// `hopper search` displays), NOT its HopperProvides capability version
|
||||||
|
// which the resolver otherwise matches against. So the seed constraint
|
||||||
|
// stays "*" (capability space) and the version goes into a package-
|
||||||
|
// version pin; dependencies keep resolving in capability space.
|
||||||
|
const seed = [{ name: query, constraint: "*" }]
|
||||||
|
listInstalledManifests().forEach(m => {
|
||||||
|
if (m.HopperPackageName === query) return
|
||||||
|
seed.push({ name: m.HopperPackageName, constraint: "*" })
|
||||||
|
})
|
||||||
|
|
||||||
|
const pkgVersionPins = new Map()
|
||||||
|
if (version) pkgVersionPins.set(query, version)
|
||||||
|
|
||||||
|
const { chosen, issues } = resolveAll(idx, seed, null, pkgVersionPins)
|
||||||
|
return commitResolution(idx, chosen, issues, query, "Proceed with installation?")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Upgrade
|
||||||
|
// ============================================================
|
||||||
|
//
|
||||||
|
// `upgrade` is `install` without the "keep what's installed" bias. For
|
||||||
|
// every named package -- or, with no names, every user-installed package
|
||||||
|
// -- it forces the resolver to pick a version strictly newer than what is
|
||||||
|
// installed, which makes it choose the latest build the mirrors offer
|
||||||
|
// (the resolver still backtracks to a lower-but-newer version if the
|
||||||
|
// newest one would break a dependency). Packages that are already at
|
||||||
|
// their newest version are skipped, and system packages are read-only.
|
||||||
|
|
||||||
|
function cmdUpgrade(args) {
|
||||||
|
const names = []
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
if (args[i].startsWith("-")) { printerrln(`Unknown option: ${args[i]}`); return 1 }
|
||||||
|
names.push(args[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = buildCandidateIndex()
|
||||||
|
|
||||||
|
// Target set: the named packages, or -- when none are named -- every
|
||||||
|
// user-installed package. System packages cannot be upgraded.
|
||||||
|
const targets = []
|
||||||
|
if (names.length === 0) {
|
||||||
|
listInstalledManifests().forEach(m => {
|
||||||
|
if (m._origin === "system") return
|
||||||
|
targets.push({ name: m.HopperPackageName, installed: m.HopperPackageVersion || "0.0.0" })
|
||||||
|
})
|
||||||
|
if (targets.length === 0) {
|
||||||
|
println("No user-installed packages to upgrade.")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < names.length; i++) {
|
||||||
|
const m = findInstalledManifest(names[i])
|
||||||
|
if (m === undefined) { printerrln(`Package not installed: ${names[i]}`); return 2 }
|
||||||
|
if (m._origin === "system") { printerrln(`Cannot upgrade ${names[i]}: it is a system package.`); return 6 }
|
||||||
|
targets.push({ name: m.HopperPackageName, installed: m.HopperPackageVersion || "0.0.0" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep only the targets that actually have a newer *package* version
|
||||||
|
// available. Compare HopperPackageVersion (the real build), NOT the
|
||||||
|
// HopperProvides capability version, which can advance independently.
|
||||||
|
const upgradeNames = new Set()
|
||||||
|
targets.forEach(t => {
|
||||||
|
const latest = latestInstallableVersion(idx, t.name)
|
||||||
|
if (latest !== null && compareVersion(latest, t.installed) > 0) upgradeNames.add(t.name)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (upgradeNames.size === 0) {
|
||||||
|
println("Everything is up to date.")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed the resolver. Upgrade targets stay at "*" but join the upgrade
|
||||||
|
// set, which flips their candidate preference from "keep what is
|
||||||
|
// installed" to "pick the newest build". A version constraint cannot do
|
||||||
|
// this: satisfaction is tested against the HopperProvides version, which
|
||||||
|
// is decoupled from the package version, so an installed copy whose
|
||||||
|
// provided version is already high would wrongly satisfy ">installed".
|
||||||
|
// Everything else stays at "*" so it is kept unless a dependency drags
|
||||||
|
// it along.
|
||||||
|
const seed = []
|
||||||
|
upgradeNames.forEach(n => seed.push({ name: n, constraint: "*" }))
|
||||||
|
listInstalledManifests().forEach(m => {
|
||||||
|
if (upgradeNames.has(m.HopperPackageName)) return
|
||||||
|
seed.push({ name: m.HopperPackageName, constraint: "*" })
|
||||||
|
})
|
||||||
|
|
||||||
|
const label = (names.length === 1) ? names[0] : "the selected packages"
|
||||||
|
println(`Resolving upgrade for ${label} ...`)
|
||||||
|
|
||||||
|
const { chosen, issues } = resolveAll(idx, seed, upgradeNames)
|
||||||
|
return commitResolution(idx, chosen, issues, label, "Proceed with upgrade?")
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Remove
|
// Remove
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -929,6 +1058,7 @@ function printUsage() {
|
|||||||
println("Usage:")
|
println("Usage:")
|
||||||
println(" hopper {search,se} [--provides|--requires|--description|--author] <query>")
|
println(" hopper {search,se} [--provides|--requires|--description|--author] <query>")
|
||||||
println(" hopper {install,in} <package> [-v <version>]")
|
println(" hopper {install,in} <package> [-v <version>]")
|
||||||
|
println(" hopper {upgrade,up} [<package>...]")
|
||||||
println(" hopper {remove,rm} <package>")
|
println(" hopper {remove,rm} <package>")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -943,6 +1073,9 @@ switch (_hopperCmd) {
|
|||||||
case "install":
|
case "install":
|
||||||
case "in":
|
case "in":
|
||||||
return cmdInstall(_hopperRest)
|
return cmdInstall(_hopperRest)
|
||||||
|
case "upgrade":
|
||||||
|
case "up":
|
||||||
|
return cmdUpgrade(_hopperRest)
|
||||||
case "remove":
|
case "remove":
|
||||||
case "rm":
|
case "rm":
|
||||||
return cmdRemove(_hopperRest)
|
return cmdRemove(_hopperRest)
|
||||||
|
|||||||
81
assets/disk0/tvdos/bin/hopper.js.synopsis
Normal file
81
assets/disk0/tvdos/bin/hopper.js.synopsis
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "hopper",
|
||||||
|
"summary": "Package manager for TVDOS",
|
||||||
|
"description": "Hopper resolves package dependencies across the installed set (system packages shipped with TVDOS plus user packages under A:/hopper) and any remote mirrors listed in A:/tvdos/hopper/mirrors.list, then installs, upgrades, downgrades or removes user packages. System packages are read-only: install and remove refuse to touch them. Versions are strict SemVer (MAJOR.MINOR.PATCH); constraints support *, X.*, X.Y.*, exact, ^, ~ and >=/>/<=/< operators, comma-separated for AND.",
|
||||||
|
"symbols": {
|
||||||
|
"search": { "kind": "subcommand", "name": "search", "summary": "Search installed packages and remote mirrors (alias: se)" },
|
||||||
|
"install": { "kind": "subcommand", "name": "install", "summary": "Resolve dependencies and install a package (alias: in)" },
|
||||||
|
"upgrade": { "kind": "subcommand", "name": "upgrade", "summary": "Upgrade packages to the latest available version; all user packages when none named (alias: up)" },
|
||||||
|
"remove": { "kind": "subcommand", "name": "remove", "summary": "Remove a user-installed package (alias: rm)" },
|
||||||
|
|
||||||
|
"provides": { "kind": "option", "long": "--provides", "summary": "Match against the HopperProvides field instead of the name" },
|
||||||
|
"requires": { "kind": "option", "long": "--requires", "summary": "Match against the HopperRequires field instead of the name" },
|
||||||
|
"description": { "kind": "option", "long": "--description", "summary": "Match against the package description instead of the name" },
|
||||||
|
"author": { "kind": "option", "long": "--author", "summary": "Match against the package author instead of the name" },
|
||||||
|
"searchFields": {
|
||||||
|
"kind": "group",
|
||||||
|
"summary": "Search-field selectors",
|
||||||
|
"members": ["provides", "requires", "description", "author"]
|
||||||
|
},
|
||||||
|
|
||||||
|
"version": {
|
||||||
|
"kind": "option",
|
||||||
|
"short": "-v",
|
||||||
|
"summary": "Install a specific package version or range",
|
||||||
|
"value": {
|
||||||
|
"name": "VERSION",
|
||||||
|
"type": "string",
|
||||||
|
"required": true,
|
||||||
|
"summary": "Package version as shown by search, or a constraint, e.g. 1.2.0, ^1.2.0, ~1.2, 2.*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"query": { "kind": "positional", "type": "string", "name": "QUERY", "summary": "Substring matched against the package name (or the selected field)" },
|
||||||
|
"pkgInstall": { "kind": "positional", "type": "string", "name": "PACKAGE", "summary": "Name of the package (or virtual capability) to install" },
|
||||||
|
"pkgUpgrade": { "kind": "positional", "type": "string", "name": "PACKAGE", "summary": "Package(s) to upgrade; upgrades every user package when omitted" },
|
||||||
|
"pkgRemove": { "kind": "positional", "type": "string", "name": "PACKAGE", "summary": "Name of the user-installed package to remove" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "choice",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "sequence",
|
||||||
|
"children": [
|
||||||
|
{ "type": "reference", "symbol": "search" },
|
||||||
|
{ "type": "repeat", "child": { "type": "reference", "symbol": "searchFields" } },
|
||||||
|
{ "type": "reference", "symbol": "query" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "sequence",
|
||||||
|
"children": [
|
||||||
|
{ "type": "reference", "symbol": "install" },
|
||||||
|
{ "type": "reference", "symbol": "pkgInstall" },
|
||||||
|
{ "type": "optional", "child": { "type": "reference", "symbol": "version" } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "sequence",
|
||||||
|
"children": [
|
||||||
|
{ "type": "reference", "symbol": "upgrade" },
|
||||||
|
{ "type": "repeat", "child": { "type": "reference", "symbol": "pkgUpgrade" } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "sequence",
|
||||||
|
"children": [
|
||||||
|
{ "type": "reference", "symbol": "remove" },
|
||||||
|
{ "type": "reference", "symbol": "pkgRemove" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"constraints": [
|
||||||
|
{
|
||||||
|
"type": "cardinality",
|
||||||
|
"symbols": ["provides", "requires", "description", "author"],
|
||||||
|
"maximum": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
12
assets/disk0/tvdos/bin/less.js.synopsis
Normal file
12
assets/disk0/tvdos/bin/less.js.synopsis
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "less",
|
||||||
|
"summary": "View text a screen at a time",
|
||||||
|
"symbols": {
|
||||||
|
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "File to view; reads from the pipe when omitted" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "optional",
|
||||||
|
"child": { "type": "reference", "symbol": "file" }
|
||||||
|
}
|
||||||
|
}
|
||||||
34
assets/disk0/tvdos/bin/lfs.js.synopsis
Normal file
34
assets/disk0/tvdos/bin/lfs.js.synopsis
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "lfs",
|
||||||
|
"summary": "Create, extract or list a Linear File Strip (.lfs) archive",
|
||||||
|
"description": "Bundles a directory tree into a single TVDOS Linear File Strip archive, or unpacks one. Exactly one mode must be given: -c creates ARCHIVE from PATH, -x extracts ARCHIVE into PATH, and -t lists the files in ARCHIVE (PATH is not used). Individual files are stored uncompressed; gzip the whole .lfs to compress it.",
|
||||||
|
"symbols": {
|
||||||
|
"create": { "kind": "option", "short": "-c", "summary": "Create an archive from a directory" },
|
||||||
|
"extract": { "kind": "option", "short": "-x", "summary": "Extract an archive into a directory" },
|
||||||
|
"list": { "kind": "option", "short": "-t", "summary": "List the files stored in an archive" },
|
||||||
|
"relative": { "kind": "option", "short": "-r", "summary": "Store paths relative to the source directory (with -c)" },
|
||||||
|
"archive": { "kind": "positional", "type": "file", "name": "ARCHIVE", "summary": "The .lfs archive file" },
|
||||||
|
"path": { "kind": "positional", "type": "directory", "name": "PATH", "summary": "Source directory (-c) or destination directory (-x); unused for -t" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "sequence",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "choice",
|
||||||
|
"children": [
|
||||||
|
{ "type": "reference", "symbol": "create" },
|
||||||
|
{ "type": "reference", "symbol": "extract" },
|
||||||
|
{ "type": "reference", "symbol": "list" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ "type": "optional", "child": { "type": "reference", "symbol": "relative" } },
|
||||||
|
{ "type": "reference", "symbol": "archive" },
|
||||||
|
{ "type": "optional", "child": { "type": "reference", "symbol": "path" } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"constraints": [
|
||||||
|
{ "type": "cardinality", "symbols": ["create", "extract", "list"], "minimum": 1, "maximum": 1 },
|
||||||
|
{ "type": "requires", "subject": "relative", "targets": ["create"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
9
assets/disk0/tvdos/bin/movprobe.js.synopsis
Normal file
9
assets/disk0/tvdos/bin/movprobe.js.synopsis
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "movprobe",
|
||||||
|
"summary": "Print metadata about a movie file",
|
||||||
|
"symbols": {
|
||||||
|
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "Movie file to inspect" }
|
||||||
|
},
|
||||||
|
"synopsis": { "type": "reference", "symbol": "file" }
|
||||||
|
}
|
||||||
360
assets/disk0/tvdos/bin/playmov.js
Normal file
360
assets/disk0/tvdos/bin/playmov.js
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
// playmov — all-in-one movie player (MOV/iPF, TEV, TAV, TAP).
|
||||||
|
//
|
||||||
|
// Consolidates playmv1 / playtev / playtav behind one decode library
|
||||||
|
// (mediadec.mjs) and one simple pipeline:
|
||||||
|
//
|
||||||
|
// loop:
|
||||||
|
// read input (quit / pause / seek / volume / cue / ASCII-toggle)
|
||||||
|
// [backend] dec.step() -> decode the next due frame into a RAM RGB888 frame
|
||||||
|
// [player] hold the frame
|
||||||
|
// [postprocessor] subtitle state resolved by the library
|
||||||
|
// [draw] graphics: dec.blit() (upload RAM frame to adapter) + dec.bias()
|
||||||
|
// ASCII: dec.sampleGray + aa.mjs straight off the RAM frame (no upload)
|
||||||
|
// then subtitle overlay + playgui chrome
|
||||||
|
//
|
||||||
|
// Usage: playmov FILE [-i] [-ascii] [-colour] [-deblock] [-boundaryaware]
|
||||||
|
// [-deinterlace=yadif|bwdif] [-debug-mv]
|
||||||
|
// -i interactive (controls + on-screen chrome)
|
||||||
|
// -ascii start in ASCII-render mode (proves the framebuffer flow; aa.mjs)
|
||||||
|
// -colour colourise ASCII glyphs from the video (implies -ascii); -color alias
|
||||||
|
// (others forwarded to the TEV backend, matching playtev)
|
||||||
|
// Controls: Bksp quit | Space pause | Left/Right seek | Up/Down volume
|
||||||
|
// PgUp/PgDn cue prev/next | A toggle ASCII | C toggle colour
|
||||||
|
|
||||||
|
const mediadec = require("mediadec")
|
||||||
|
const gui = require("playgui")
|
||||||
|
const K = require("keysym")
|
||||||
|
|
||||||
|
// aa.mjs (the ASCII renderer) is OPTIONAL. If it isn't installed, playmov still
|
||||||
|
// plays everything normally; ASCII mode just isn't available (-ascii is ignored
|
||||||
|
// and the A key is inert). require() throws when the module is missing, so guard it.
|
||||||
|
let aa = null
|
||||||
|
try { aa = require("aa") } catch (e) { aa = null } // hopper/include/aa.mjs
|
||||||
|
|
||||||
|
const AA_FONT_PATH = "A:/tvdos/tsvm.chr"
|
||||||
|
const VOL_STEP = 16
|
||||||
|
|
||||||
|
// Text-plane palette indices: 0 = GUI background (translucent black), 240 = pure
|
||||||
|
// opaque black, 255 = transparent (GraphicsAdapter: "palette 255 is always
|
||||||
|
// transparent"). aa.mjs paints cell backgrounds with 255, so over live graphics
|
||||||
|
// the picture bleeds through the ASCII; we force opaque 240 instead.
|
||||||
|
const COL_TRANSPARENT = 255
|
||||||
|
const COL_PURE_BLACK = 240
|
||||||
|
const GUI_BG = 0
|
||||||
|
|
||||||
|
// Text fore/back-plane addressing (mirrors aa.mjs _TA_FORE / _TA_BACK / _TA_BASE),
|
||||||
|
// VT-aware.
|
||||||
|
const TXT_FORE_OFF = 2
|
||||||
|
const TXT_BACK_OFF = 2562
|
||||||
|
const TXT_AREA_BASE = 253950
|
||||||
|
const AA_W = 80, AA_H = 32
|
||||||
|
const asciiBackFill = new Uint8Array(AA_W * AA_H).fill(COL_PURE_BLACK)
|
||||||
|
|
||||||
|
// Resolve the address of text-area byte `off` for the current environment
|
||||||
|
// (VT pane: forward from VT_TEXT_PLANE; physical: backward from the GPU base),
|
||||||
|
// exactly as aa.mjs's _va() does, so writes land in the same plane aa.flush uses.
|
||||||
|
function txtAddr(off) {
|
||||||
|
if (typeof globalThis.VT_TEXT_PLANE !== 'undefined')
|
||||||
|
return globalThis.VT_TEXT_PLANE + off
|
||||||
|
return graphics.getGpuMemBase() - TXT_AREA_BASE - off
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overwrite every text cell's background with opaque pure-black (240), so ASCII
|
||||||
|
// glyphs sit on solid black instead of aa.mjs's transparent (255) cells.
|
||||||
|
function paintAsciiBgOpaque() {
|
||||||
|
sys.pokeBytes(txtAddr(TXT_BACK_OFF), asciiBackFill, asciiBackFill.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Colour postprocessor (-colour) ───────────────────────────────────────────
|
||||||
|
// AAlib chooses each glyph from brightness; colour mode additionally tints the
|
||||||
|
// glyph's FOREGROUND (never the background) with the nearest opaque colour of
|
||||||
|
// the TSVM 256-palette, sampled from the video's RGB plane.
|
||||||
|
//
|
||||||
|
// That palette is a *separable* 6×8×5 RGB cube (indices 0–239, white corner at
|
||||||
|
// 239) plus a 15-step grey ramp (indices 240–254 = 0,17,…,238; index 255 is
|
||||||
|
// always transparent and cube index 0 is translucent, so both are excluded as
|
||||||
|
// ink). Because the cube is separable, its nearest entry is just the independent
|
||||||
|
// nearest level per channel; the global nearest opaque colour is then whichever
|
||||||
|
// of {best cube, best grey} is closer — all via small precomputed LUTs, O(1)/cell.
|
||||||
|
const CUBE_R = [0, 51, 102, 153, 204, 255]
|
||||||
|
const CUBE_G = [0, 34, 68, 102, 153, 187, 221, 255]
|
||||||
|
const CUBE_B = [0, 68, 136, 187, 255]
|
||||||
|
|
||||||
|
let _rNear = null, _gNear = null, _bNear = null // 0–255 value → cube level index
|
||||||
|
let _greyIdx = null, _greyVal = null // 0–255 mean → grey palette idx / value
|
||||||
|
const colourBuf = new Uint8Array(AA_W * AA_H * 3) // sampled R,G,B per cell
|
||||||
|
const foreBuf = new Uint8Array(AA_W * AA_H) // resolved palette ink per cell
|
||||||
|
|
||||||
|
function _nearestLevel(levels) {
|
||||||
|
const lut = new Uint8Array(256)
|
||||||
|
for (let v = 0; v < 256; v++) {
|
||||||
|
let best = 0, bestD = 1e9
|
||||||
|
for (let k = 0; k < levels.length; k++) {
|
||||||
|
const d = Math.abs(v - levels[k])
|
||||||
|
if (d < bestD) { bestD = d; best = k }
|
||||||
|
}
|
||||||
|
lut[v] = best
|
||||||
|
}
|
||||||
|
return lut
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureColourLuts() {
|
||||||
|
if (_rNear) return
|
||||||
|
_rNear = _nearestLevel(CUBE_R)
|
||||||
|
_gNear = _nearestLevel(CUBE_G)
|
||||||
|
_bNear = _nearestLevel(CUBE_B)
|
||||||
|
// Grey-ramp candidates: palette idx 240+k holds grey value 17·k, k = 0..14
|
||||||
|
// (idx 240 = black … 254 = 238; idx 255 is transparent, so it is excluded).
|
||||||
|
const gv = [], gi = []
|
||||||
|
for (let k = 0; k < 15; k++) { gv.push(17 * k); gi.push(240 + k) }
|
||||||
|
_greyIdx = new Uint8Array(256)
|
||||||
|
_greyVal = new Uint8Array(256)
|
||||||
|
for (let m = 0; m < 256; m++) {
|
||||||
|
let best = 0, bestD = 1e9
|
||||||
|
for (let k = 0; k < gv.length; k++) {
|
||||||
|
const d = Math.abs(m - gv[k])
|
||||||
|
if (d < bestD) { bestD = d; best = k }
|
||||||
|
}
|
||||||
|
_greyIdx[m] = gi[best]; _greyVal[m] = gv[best]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestPaletteIndex(r, g, b) {
|
||||||
|
const ri = _rNear[r], gi = _gNear[g], bi = _bNear[b]
|
||||||
|
const cr = CUBE_R[ri], cg = CUBE_G[gi], cb = CUBE_B[bi]
|
||||||
|
const dCube = (r - cr) * (r - cr) + (g - cg) * (g - cg) + (b - cb) * (b - cb)
|
||||||
|
// Nearest grey level sits at the rounded mean of the channels (the vertex of
|
||||||
|
// the achromatic-distance parabola); rounding — not flooring — makes the
|
||||||
|
// {cube vs grey} pick the exact global nearest opaque palette entry.
|
||||||
|
const m = ((r + g + b) / 3 + 0.5) | 0
|
||||||
|
const gvv = _greyVal[m]
|
||||||
|
const dGrey = (r - gvv) * (r - gvv) + (g - gvv) * (g - gvv) + (b - gvv) * (b - gvv)
|
||||||
|
// Prefer grey on ties (so near-black resolves to opaque grey idx 240, not the
|
||||||
|
// translucent cube corner); `|| 240` is a belt-and-braces guard for idx 0.
|
||||||
|
const cubeIdx = ri * 40 + gi * 5 + bi
|
||||||
|
return (dGrey <= dCube) ? _greyIdx[m] : (cubeIdx || 240)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample the frame's colour per cell, map to nearest palette ink, and write the
|
||||||
|
// foreground plane (over what aa.flush wrote). Background is left to
|
||||||
|
// paintAsciiBgOpaque(); only the FG is colourised, per spec.
|
||||||
|
function applyColourFore(dec) {
|
||||||
|
dec.sampleColour(colourBuf, AA_W, AA_H)
|
||||||
|
for (let i = 0, n = AA_W * AA_H; i < n; i++)
|
||||||
|
foreBuf[i] = nearestPaletteIndex(colourBuf[i * 3], colourBuf[i * 3 + 1], colourBuf[i * 3 + 2])
|
||||||
|
sys.pokeBytes(txtAddr(TXT_FORE_OFF), foreBuf, foreBuf.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Parse args ───────────────────────────────────────────────────────────────
|
||||||
|
let interactive = false
|
||||||
|
let asciiMode = false
|
||||||
|
let colourMode = false
|
||||||
|
const decOpts = { interactive: false, deinterlaceAlgorithm: "yadif" }
|
||||||
|
|
||||||
|
for (let i = 2; i < exec_args.length; i++) {
|
||||||
|
const arg = ("" + exec_args[i]).toLowerCase()
|
||||||
|
if (arg === "-i") { interactive = true; decOpts.interactive = true }
|
||||||
|
else if (arg === "-ascii") asciiMode = true
|
||||||
|
else if (arg === "-colour" || arg === "-color") { asciiMode = true; colourMode = true }
|
||||||
|
else if (arg === "-debug-mv") decOpts.debugMotionVectors = true
|
||||||
|
else if (arg === "-deblock") decOpts.enableDeblocking = true
|
||||||
|
else if (arg === "-boundaryaware") decOpts.enableBoundaryAwareDecoding = true
|
||||||
|
else if (arg.startsWith("-deinterlace=")) decOpts.deinterlaceAlgorithm = arg.substring(13)
|
||||||
|
else if (arg.startsWith("--filter-film-grain")) {
|
||||||
|
const parts = arg.split(/[=\s]/)
|
||||||
|
if (parts.length > 1) { const lv = parseInt(parts[1]); if (!isNaN(lv)) decOpts.filmGrainLevel = lv }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graceful degradation: ASCII (and therefore colour) mode needs aa.mjs.
|
||||||
|
if (asciiMode && !aa) {
|
||||||
|
serial.println("playmov: aa.mjs not found; ASCII mode unavailable, -ascii/-colour ignored")
|
||||||
|
asciiMode = false
|
||||||
|
colourMode = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!exec_args[1]) { printerrln("usage: playmov FILE [-i] [-ascii] [-colour] [options]"); return 1 }
|
||||||
|
const fullPath = _G.shell.resolvePathInput(exec_args[1]).full
|
||||||
|
|
||||||
|
// ── ASCII-render state (aa.mjs) — lazily initialised on first use ────────────
|
||||||
|
let aaCtx = null
|
||||||
|
let aaParams = null
|
||||||
|
function ensureAscii() {
|
||||||
|
if (aaCtx) return
|
||||||
|
const font = aa.loadChrFontROM(AA_FONT_PATH)
|
||||||
|
aaCtx = aa.init(AA_W, AA_H, { font: font })
|
||||||
|
aaParams = aa.getrenderparams()
|
||||||
|
aaParams.dither = aa.AA_FLOYD_S
|
||||||
|
ensureColourLuts() // cheap; keeps the C-key colour toggle ready
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Open ─────────────────────────────────────────────────────────────────────
|
||||||
|
let [cy, cx] = con.getyx()
|
||||||
|
let errorlevel = 0
|
||||||
|
let dec = null
|
||||||
|
let stage = "open" // breadcrumb for the error log
|
||||||
|
|
||||||
|
try {
|
||||||
|
dec = mediadec.open(fullPath, decOpts)
|
||||||
|
const info = dec.info
|
||||||
|
|
||||||
|
// NB: palette 0 is translucent black by default — exactly what the playgui
|
||||||
|
// chrome (bg colour 0) wants — so we never redefine it. (Backends must not
|
||||||
|
// either, or the chrome turns opaque for the next file played.)
|
||||||
|
|
||||||
|
if (info.isStill) { con.move(1, 1); println("Push and hold Backspace to exit") }
|
||||||
|
|
||||||
|
let startNs = 0
|
||||||
|
let lastKey = 0
|
||||||
|
let quit = false
|
||||||
|
|
||||||
|
// Build the playgui status object for the on-screen chrome.
|
||||||
|
function status() {
|
||||||
|
const usingCues = dec.cues && dec.cues.length > 0
|
||||||
|
const akku = startNs ? (sys.nanoTime() - startNs) / 1000000000.0 : 0.0001
|
||||||
|
return {
|
||||||
|
fps: info.fps,
|
||||||
|
videoRate: dec.videoRate | 0,
|
||||||
|
frameCount: dec.frameCount,
|
||||||
|
totalFrames: info.totalFrames,
|
||||||
|
frameMode: dec.frameMode,
|
||||||
|
qY: dec.qY || 0, qCo: dec.qCo || 0, qCg: dec.qCg || 0,
|
||||||
|
akku: akku,
|
||||||
|
fileName: usingCues ? dec.cues[dec.currentCueIndex].name : fullPath,
|
||||||
|
fileOrd: usingCues ? (dec.currentCueIndex + 1) : (dec.currentFileIndex || 1),
|
||||||
|
resolution: `${info.width}x${info.height}${info.isInterlaced ? 'i' : ''}`,
|
||||||
|
colourSpace: info.colourSpace,
|
||||||
|
currentStatus: dec.isPaused() ? 2 : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entering ASCII: clear the text plane; the pixel framebuffer is left as-is and
|
||||||
|
// simply covered each frame by solid-black (240) text cells (see draw()).
|
||||||
|
// Bias lighting is pinned to pure black ONCE here and not updated again while
|
||||||
|
// in ASCII (draw() skips the bias stage), so the backdrop stays steady.
|
||||||
|
function enterAsciiVisual() {
|
||||||
|
ensureAscii()
|
||||||
|
graphics.setBackground(0, 0, 0)
|
||||||
|
graphics.clearPixelsAll(0, 0, 0, 0)
|
||||||
|
con.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leaving ASCII: fill the viewing area with transparency (255), NOT the GUI's
|
||||||
|
// translucent-black (colour 0), so the resumed video shows through cleanly.
|
||||||
|
function exitAsciiVisual() {
|
||||||
|
con.color_pair(COL_TRANSPARENT, COL_TRANSPARENT)
|
||||||
|
con.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAscii() {
|
||||||
|
asciiMode = !asciiMode
|
||||||
|
if (asciiMode) enterAsciiVisual()
|
||||||
|
else exitAsciiVisual()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Colour only affects the foreground plane and is re-applied every drawn
|
||||||
|
// frame, so toggling it just flips the flag; the next flush+draw reverts the
|
||||||
|
// ink to aa.mjs's grey when off. Ensure the LUTs exist if A was never pressed.
|
||||||
|
function toggleColour() {
|
||||||
|
if (!aaCtx) ensureColourLuts()
|
||||||
|
colourMode = !colourMode
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Input ─────────────────────────────────────────────────────────────────
|
||||||
|
// Bksp is hold-to-quit (like the old players); everything else is edge-
|
||||||
|
// triggered so a held key fires once. Quit + ASCII/colour toggles work even
|
||||||
|
// without -i; the rest of the transport is interactive-only.
|
||||||
|
function readInput() {
|
||||||
|
sys.poke(-40, 1)
|
||||||
|
const key = sys.peek(-41)
|
||||||
|
if (key == K.BACKSPACE) { quit = true; return }
|
||||||
|
if (key && key !== lastKey) {
|
||||||
|
if (key == K.A) { if (aa) toggleAscii() } // inert when aa.mjs is absent
|
||||||
|
else if (key == K.C) { if (aa) toggleColour() } // colour shows only while in ASCII
|
||||||
|
else if (interactive) {
|
||||||
|
switch (key) {
|
||||||
|
case K.SPACE: dec.pause(!dec.isPaused()); break
|
||||||
|
case K.LEFT: dec.seekSeconds(-5.5); break
|
||||||
|
case K.RIGHT: dec.seekSeconds(5.0); break
|
||||||
|
case K.UP: dec.setVolume(dec.getVolume() + VOL_STEP); break
|
||||||
|
case K.DOWN: dec.setVolume(dec.getVolume() - VOL_STEP); break
|
||||||
|
case K.PAGE_UP: dec.cue(-1); break
|
||||||
|
case K.PAGE_DOWN: dec.cue(1); break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastKey = key
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Draw a decoded frame: RAM frame -> screen / ASCII -> overlays -> chrome ─
|
||||||
|
function draw() {
|
||||||
|
if (asciiMode) {
|
||||||
|
// The decoded frame already sits in RAM (TEV/TAV) or on the display
|
||||||
|
// planes (iPF), so sample it WITHOUT uploading to the video adapter,
|
||||||
|
// then cover the picture with solid-black (240) text cells (cheaper
|
||||||
|
// than clearing the pixel planes).
|
||||||
|
dec.sampleGray(aaCtx.imagebuffer, aaCtx.imgW, aaCtx.imgH)
|
||||||
|
aa.render(aaCtx, aaParams)
|
||||||
|
aa.flush(aaCtx)
|
||||||
|
if (colourMode) applyColourFore(dec) // recolour the FG plane from the video's RGB
|
||||||
|
paintAsciiBgOpaque() // cover with opaque 240 (not transparent 255)
|
||||||
|
} else {
|
||||||
|
dec.blit() // upload the RAM frame to the video adapter
|
||||||
|
dec.bias() // bias lighting (player-owned stage; graphics only)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Postprocessor output: subtitle overlay (text plane, on top of the frame).
|
||||||
|
if (asciiMode) {
|
||||||
|
// aa.flush rewrote the whole text plane, so redraw the subtitle each frame.
|
||||||
|
if (dec.subtitle.visible) gui.displaySubtitle(dec.subtitle.text, dec.subtitle.useUnicode, dec.subtitle.position)
|
||||||
|
dec.subtitle.dirty = false
|
||||||
|
} else if (dec.subtitle.dirty) {
|
||||||
|
gui.clearSubtitleArea()
|
||||||
|
if (dec.subtitle.visible) gui.displaySubtitle(dec.subtitle.text, dec.subtitle.useUnicode, dec.subtitle.position)
|
||||||
|
dec.subtitle.dirty = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (interactive) { gui.printBottomBar(status()); gui.printTopBar(status(), 1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start in ASCII if requested (-ascii). Done here, after the helpers above are
|
||||||
|
// defined, since they are block-scoped function declarations.
|
||||||
|
if (asciiMode) enterAsciiVisual()
|
||||||
|
|
||||||
|
// ── Main loop ───────────────────────────────────────────────────────────
|
||||||
|
while (!quit) {
|
||||||
|
stage = "input"; readInput()
|
||||||
|
if (quit) break
|
||||||
|
|
||||||
|
stage = "step"
|
||||||
|
const ev = dec.step()
|
||||||
|
if (ev.type === 'eof') break
|
||||||
|
if (ev.type === 'error') { errorlevel = 1; break }
|
||||||
|
if (ev.type === 'frame') {
|
||||||
|
if (!startNs) startNs = sys.nanoTime()
|
||||||
|
stage = "draw"; draw()
|
||||||
|
} else {
|
||||||
|
// 'idle' or 'newfile' — nothing to draw this turn.
|
||||||
|
sys.sleep(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
// Log to serial too (persists in the console log next to errorlevel) and
|
||||||
|
// keep it on screen — con.clear() in finally only runs on success.
|
||||||
|
serial.printerr("playmov failed at stage [" + stage + "]: " + e)
|
||||||
|
if (e && e.message) serial.println(" message: " + e.message)
|
||||||
|
if (e && e.stack) serial.println(" stack: " + e.stack)
|
||||||
|
if (e && e.printStackTrace) e.printStackTrace()
|
||||||
|
printerrln(e)
|
||||||
|
errorlevel = 1
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
if (dec) dec.close()
|
||||||
|
if (aa && aaCtx) aa.close(aaCtx)
|
||||||
|
if (errorlevel === 0) con.clear()
|
||||||
|
con.curs_set(1)
|
||||||
|
con.move(cy, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return errorlevel
|
||||||
38
assets/disk0/tvdos/bin/playmov.js.synopsis
Normal file
38
assets/disk0/tvdos/bin/playmov.js.synopsis
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "playmov",
|
||||||
|
"summary": "Play a movie file (MOV/iPF, TEV, TAV or TAP)",
|
||||||
|
"symbols": {
|
||||||
|
"interactive": { "kind": "option", "short": "-i", "summary": "Interactive mode (controls + on-screen info)" },
|
||||||
|
"ascii": { "kind": "option", "long": "-ascii", "summary": "Start in ASCII-render mode" },
|
||||||
|
"colour": { "kind": "option", "long": "-colour", "summary": "Colourise ASCII glyphs from the video (implies -ascii); -color alias" },
|
||||||
|
"deblock": { "kind": "option", "long": "-deblock", "summary": "TEV: enable deblocking filter" },
|
||||||
|
"boundaryAware": { "kind": "option", "long": "-boundaryaware", "summary": "TEV: boundary-aware decoding" },
|
||||||
|
"debugMv": { "kind": "option", "long": "-debug-mv", "summary": "TEV: show motion-vector debug overlay" },
|
||||||
|
"deinterlace": {
|
||||||
|
"kind": "option",
|
||||||
|
"long": "-deinterlace",
|
||||||
|
"summary": "TEV: deinterlacing algorithm",
|
||||||
|
"value": { "name": "ALGO", "type": "enum", "values": ["yadif", "bwdif"], "required": true, "summary": "Deinterlacer" }
|
||||||
|
},
|
||||||
|
"filmGrain": {
|
||||||
|
"kind": "option",
|
||||||
|
"long": "--filter-film-grain",
|
||||||
|
"summary": "TAV: apply a film-grain filter",
|
||||||
|
"value": { "name": "LEVEL", "type": "integer", "required": false, "summary": "Grain intensity" }
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"kind": "group",
|
||||||
|
"summary": "Options",
|
||||||
|
"members": ["interactive", "ascii", "colour", "deblock", "boundaryAware", "debugMv", "deinterlace", "filmGrain"]
|
||||||
|
},
|
||||||
|
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "Movie file to play" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "sequence",
|
||||||
|
"children": [
|
||||||
|
{ "type": "reference", "symbol": "file" },
|
||||||
|
{ "type": "repeat", "child": { "type": "reference", "symbol": "options" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,13 +57,17 @@ let decodedLength = 0
|
|||||||
|
|
||||||
const bufRealTimeLen = 36 // one MP2 frame at 32 kHz ≈ 36 ms
|
const bufRealTimeLen = 36 // one MP2 frame at 32 kHz ≈ 36 ms
|
||||||
|
|
||||||
audio.resetParams(0)
|
// Occupy the first idle playhead rather than always grabbing #0, so playback
|
||||||
audio.purgeQueue(0)
|
// doesn't cut off audio already running on another playhead. Falls back to #0
|
||||||
audio.setPcmMode(0)
|
// when all four are busy.
|
||||||
audio.setPcmQueueCapacityIndex(0, 2)
|
const PLAYHEAD = audio.getFreePlayhead(0)
|
||||||
const QUEUE_MAX = audio.getPcmQueueCapacity(0)
|
audio.resetParams(PLAYHEAD)
|
||||||
audio.setMasterVolume(0, 255)
|
audio.purgeQueue(PLAYHEAD)
|
||||||
audio.play(0)
|
audio.setPcmMode(PLAYHEAD)
|
||||||
|
audio.setPcmQueueCapacityIndex(PLAYHEAD, 2)
|
||||||
|
const QUEUE_MAX = audio.getPcmQueueCapacity(PLAYHEAD)
|
||||||
|
audio.setMasterVolume(PLAYHEAD, 255)
|
||||||
|
audio.play(PLAYHEAD)
|
||||||
audio.mp2Init()
|
audio.mp2Init()
|
||||||
|
|
||||||
function bytesToSec(i) { return i / (FRAME_SIZE * 1000 / bufRealTimeLen) }
|
function bytesToSec(i) { return i / (FRAME_SIZE * 1000 / bufRealTimeLen) }
|
||||||
@@ -91,8 +95,8 @@ try {
|
|||||||
gui.audioFeedPcm(mp2VisScratch, MP2_VIS_SAMPLE_COUNT)
|
gui.audioFeedPcm(mp2VisScratch, MP2_VIS_SAMPLE_COUNT)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (audio.getPosition(0) >= QUEUE_MAX) {
|
if (audio.getPosition(PLAYHEAD) >= QUEUE_MAX) {
|
||||||
while (audio.getPosition(0) >= (QUEUE_MAX >>> 1)) {
|
while (audio.getPosition(PLAYHEAD) >= (QUEUE_MAX >>> 1)) {
|
||||||
if (interactive) gui.audioRender()
|
if (interactive) gui.audioRender()
|
||||||
sys.sleep(bufRealTimeLen)
|
sys.sleep(bufRealTimeLen)
|
||||||
}
|
}
|
||||||
|
|||||||
17
assets/disk0/tvdos/bin/playmp2.js.synopsis
Normal file
17
assets/disk0/tvdos/bin/playmp2.js.synopsis
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "playmp2",
|
||||||
|
"summary": "Play an MP2 audio file",
|
||||||
|
"symbols": {
|
||||||
|
"interactive": { "kind": "option", "short": "-i", "summary": "Interactive mode (visualiser)" },
|
||||||
|
"options": { "kind": "group", "summary": "Options", "members": ["interactive"] },
|
||||||
|
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "MP2 file to play" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "sequence",
|
||||||
|
"children": [
|
||||||
|
{ "type": "reference", "symbol": "file" },
|
||||||
|
{ "type": "repeat", "child": { "type": "reference", "symbol": "options" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,10 +97,14 @@ let startTime = sys.nanoTime()
|
|||||||
let framesRead = 0
|
let framesRead = 0
|
||||||
let audioFired = false
|
let audioFired = false
|
||||||
|
|
||||||
audio.resetParams(0)
|
// Occupy the first idle playhead rather than always grabbing #0, so playback
|
||||||
audio.purgeQueue(0)
|
// doesn't cut off audio already running on another playhead. Falls back to #0
|
||||||
audio.setPcmMode(0)
|
// when all four are busy.
|
||||||
audio.setMasterVolume(0, 255)
|
const PLAYHEAD = audio.getFreePlayhead(0)
|
||||||
|
audio.resetParams(PLAYHEAD)
|
||||||
|
audio.purgeQueue(PLAYHEAD)
|
||||||
|
audio.setPcmMode(PLAYHEAD)
|
||||||
|
audio.setMasterVolume(PLAYHEAD, 255)
|
||||||
|
|
||||||
function s16StTou8St(inPtrL, inPtrR, outPtr, length) {
|
function s16StTou8St(inPtrL, inPtrR, outPtr, length) {
|
||||||
for (let k = 0; k < length; k+=2) {
|
for (let k = 0; k < length; k+=2) {
|
||||||
@@ -204,7 +208,7 @@ while (!stopPlay && seqread.getReadCount() < FILE_LENGTH) {
|
|||||||
|
|
||||||
// defer audio playback until a first frame is sent
|
// defer audio playback until a first frame is sent
|
||||||
if (!audioFired) {
|
if (!audioFired) {
|
||||||
audio.play(0)
|
audio.play(PLAYHEAD)
|
||||||
audioFired = true
|
audioFired = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,7 +267,7 @@ while (!stopPlay && seqread.getReadCount() < FILE_LENGTH) {
|
|||||||
|
|
||||||
// defer audio playback until a first frame is sent
|
// defer audio playback until a first frame is sent
|
||||||
if (!audioFired) {
|
if (!audioFired) {
|
||||||
audio.play(0)
|
audio.play(PLAYHEAD)
|
||||||
audioFired = true
|
audioFired = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,9 +330,9 @@ while (!stopPlay && seqread.getReadCount() < FILE_LENGTH) {
|
|||||||
// RAW PCM packets (decode on the fly)
|
// RAW PCM packets (decode on the fly)
|
||||||
else if (packetType == 0x1000 || packetType == 0x1001) {
|
else if (packetType == 0x1000 || packetType == 0x1001) {
|
||||||
let frame = seqread.readBytes(readLength)
|
let frame = seqread.readBytes(readLength)
|
||||||
audio.putPcmDataByPtr(0, frame, readLength, 0)
|
audio.putPcmDataByPtr(PLAYHEAD, frame, readLength, 0)
|
||||||
audio.setSampleUploadLength(0, readLength)
|
audio.setSampleUploadLength(PLAYHEAD, readLength)
|
||||||
audio.startSampleUpload(0)
|
audio.startSampleUpload(PLAYHEAD)
|
||||||
sys.free(frame)
|
sys.free(frame)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@@ -382,14 +386,14 @@ finally {
|
|||||||
if (AUDIO_QUEUE_BYTES > 0 && AUDIO_QUEUE_LENGTH > 1) {
|
if (AUDIO_QUEUE_BYTES > 0 && AUDIO_QUEUE_LENGTH > 1) {
|
||||||
|
|
||||||
}
|
}
|
||||||
//audio.stop(0)
|
//audio.stop(PLAYHEAD)
|
||||||
|
|
||||||
let timeTook = (endTime - startTime) / 1000000000.0
|
let timeTook = (endTime - startTime) / 1000000000.0
|
||||||
|
|
||||||
//println(`Actual FPS: ${framesRendered / timeTook}`)
|
//println(`Actual FPS: ${framesRendered / timeTook}`)
|
||||||
|
|
||||||
audio.stop(0)
|
audio.stop(PLAYHEAD)
|
||||||
audio.purgeQueue(0)
|
audio.purgeQueue(PLAYHEAD)
|
||||||
|
|
||||||
if (interactive) {
|
if (interactive) {
|
||||||
con.clear()
|
con.clear()
|
||||||
|
|||||||
@@ -23,10 +23,14 @@ function bytesToSec(i) { return i / byterate }
|
|||||||
seqread.prepare(filePath)
|
seqread.prepare(filePath)
|
||||||
|
|
||||||
const readPtr = sys.malloc(BLOCK_SIZE)
|
const readPtr = sys.malloc(BLOCK_SIZE)
|
||||||
audio.resetParams(0)
|
// Occupy the first idle playhead rather than always grabbing #0, so playback
|
||||||
audio.purgeQueue(0)
|
// doesn't cut off audio already running on another playhead. Falls back to #0
|
||||||
audio.setPcmMode(0)
|
// when all four are busy.
|
||||||
audio.setMasterVolume(0, 255)
|
const PLAYHEAD = audio.getFreePlayhead(0)
|
||||||
|
audio.resetParams(PLAYHEAD)
|
||||||
|
audio.purgeQueue(PLAYHEAD)
|
||||||
|
audio.setPcmMode(PLAYHEAD)
|
||||||
|
audio.setMasterVolume(PLAYHEAD, 255)
|
||||||
|
|
||||||
if (interactive) {
|
if (interactive) {
|
||||||
gui.audioInit({
|
gui.audioInit({
|
||||||
@@ -42,7 +46,7 @@ try {
|
|||||||
while (!stopPlay && seqread.getReadCount() < FILE_SIZE && readLength > 0) {
|
while (!stopPlay && seqread.getReadCount() < FILE_SIZE && readLength > 0) {
|
||||||
if (interactive && gui.audioIsExitRequested()) { stopPlay = true; break }
|
if (interactive && gui.audioIsExitRequested()) { stopPlay = true; break }
|
||||||
|
|
||||||
const queueSize = audio.getPosition(0)
|
const queueSize = audio.getPosition(PLAYHEAD)
|
||||||
if (queueSize <= 1) {
|
if (queueSize <= 1) {
|
||||||
for (let repeat = QUEUE_MAX - queueSize; repeat > 0; repeat--) {
|
for (let repeat = QUEUE_MAX - queueSize; repeat > 0; repeat--) {
|
||||||
const remainingBytes = FILE_SIZE - seqread.getReadCount()
|
const remainingBytes = FILE_SIZE - seqread.getReadCount()
|
||||||
@@ -54,13 +58,13 @@ try {
|
|||||||
// Raw PCMu8 stereo — sampleCount = bytes / 2.
|
// Raw PCMu8 stereo — sampleCount = bytes / 2.
|
||||||
if (interactive) gui.audioFeedPcm(readPtr, readLength >> 1)
|
if (interactive) gui.audioFeedPcm(readPtr, readLength >> 1)
|
||||||
|
|
||||||
audio.putPcmDataByPtr(0, readPtr, readLength, 0)
|
audio.putPcmDataByPtr(PLAYHEAD, readPtr, readLength, 0)
|
||||||
audio.setSampleUploadLength(0, readLength)
|
audio.setSampleUploadLength(PLAYHEAD, readLength)
|
||||||
audio.startSampleUpload(0)
|
audio.startSampleUpload(PLAYHEAD)
|
||||||
|
|
||||||
if (repeat > 1) sys.sleep(10)
|
if (repeat > 1) sys.sleep(10)
|
||||||
}
|
}
|
||||||
audio.play(0)
|
audio.play(PLAYHEAD)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interactive) {
|
if (interactive) {
|
||||||
|
|||||||
@@ -109,13 +109,17 @@ function bytesToSec(i) {
|
|||||||
return Math.round((i / FILE_SIZE) * (FILE_SIZE / AVG_CHUNK_SIZE) * (bufRealTimeLen / 1000))
|
return Math.round((i / FILE_SIZE) * (FILE_SIZE / AVG_CHUNK_SIZE) * (bufRealTimeLen / 1000))
|
||||||
}
|
}
|
||||||
|
|
||||||
audio.resetParams(0)
|
// Occupy the first idle playhead rather than always grabbing #0, so playback
|
||||||
audio.purgeQueue(0)
|
// doesn't cut off audio already running on another playhead. Falls back to #0
|
||||||
audio.setPcmMode(0)
|
// when all four are busy.
|
||||||
audio.setPcmQueueCapacityIndex(0, 2)
|
const PLAYHEAD = audio.getFreePlayhead(0)
|
||||||
const QUEUE_MAX = audio.getPcmQueueCapacity(0)
|
audio.resetParams(PLAYHEAD)
|
||||||
audio.setMasterVolume(0, 255)
|
audio.purgeQueue(PLAYHEAD)
|
||||||
audio.play(0)
|
audio.setPcmMode(PLAYHEAD)
|
||||||
|
audio.setPcmQueueCapacityIndex(PLAYHEAD, 2)
|
||||||
|
const QUEUE_MAX = audio.getPcmQueueCapacity(PLAYHEAD)
|
||||||
|
audio.setMasterVolume(PLAYHEAD, 255)
|
||||||
|
audio.play(PLAYHEAD)
|
||||||
|
|
||||||
if (interactive) {
|
if (interactive) {
|
||||||
gui.audioInit({
|
gui.audioInit({
|
||||||
@@ -163,7 +167,7 @@ try {
|
|||||||
filebuf.readBytes(7 + payloadSize, TAD_INPUT_ADDR)
|
filebuf.readBytes(7 + payloadSize, TAD_INPUT_ADDR)
|
||||||
|
|
||||||
audio.tadDecode()
|
audio.tadDecode()
|
||||||
audio.tadUploadDecoded(0, sampleCount)
|
audio.tadUploadDecoded(PLAYHEAD, sampleCount)
|
||||||
// After upload tadDecodedBin still holds the chunk until the next
|
// After upload tadDecodedBin still holds the chunk until the next
|
||||||
// tadDecode call, so it's safe to keep slicing samples out of it
|
// tadDecode call, so it's safe to keep slicing samples out of it
|
||||||
// during the playback wait below.
|
// during the playback wait below.
|
||||||
|
|||||||
21
assets/disk0/tvdos/bin/playtad.js.synopsis
Normal file
21
assets/disk0/tvdos/bin/playtad.js.synopsis
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "playtad",
|
||||||
|
"summary": "Play a TAD audio file",
|
||||||
|
"symbols": {
|
||||||
|
"interactive": { "kind": "option", "short": "-i", "summary": "Interactive mode (visualiser and progress bar)" },
|
||||||
|
"dump": { "kind": "option", "short": "-d", "summary": "Dump coefficients (diagnostic)" },
|
||||||
|
"options": { "kind": "group", "summary": "Options", "members": ["interactive", "dump"] },
|
||||||
|
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "TAD file to play" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "sequence",
|
||||||
|
"children": [
|
||||||
|
{ "type": "reference", "symbol": "file" },
|
||||||
|
{ "type": "repeat", "child": { "type": "reference", "symbol": "options" } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"constraints": [
|
||||||
|
{ "type": "conflicts", "symbols": ["interactive", "dump"] }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -87,8 +87,8 @@ const COL_LABEL = 220 // amber panel label
|
|||||||
const COL_DIM = 235 // muted text
|
const COL_DIM = 235 // muted text
|
||||||
const COL_TITLE = 230 // bright white-yellow song title
|
const COL_TITLE = 230 // bright white-yellow song title
|
||||||
const COL_VALUE = 254 // bright white numeric values
|
const COL_VALUE = 254 // bright white numeric values
|
||||||
const COL_TICK_LIVE = 46 // green tick light
|
const COL_TICK_LIVE = 76 // green tick light
|
||||||
const COL_TICK_DEAD = 22 // dim green
|
const COL_TICK_DEAD = 20 // dim green
|
||||||
const COL_ORDER_PAST = 235
|
const COL_ORDER_PAST = 235
|
||||||
const COL_ORDER_CUR = 226 // bright yellow active cue
|
const COL_ORDER_CUR = 226 // bright yellow active cue
|
||||||
const COL_ORDER_FUT = 250
|
const COL_ORDER_FUT = 250
|
||||||
@@ -304,9 +304,13 @@ function parseTaud(path, songIndex) {
|
|||||||
const song = parseTaud(filePath, songArg)
|
const song = parseTaud(filePath, songArg)
|
||||||
|
|
||||||
// ── Hand the file to the audio adapter ─────────────────────────────────────
|
// ── Hand the file to the audio adapter ─────────────────────────────────────
|
||||||
audio.resetParams(0)
|
// Occupy the first idle playhead rather than always grabbing #0, so launching
|
||||||
audio.purgeQueue(0)
|
// playtaud doesn't cut off music already playing on another playhead. Falls
|
||||||
taud.uploadTaudFile(filePath, songArg, 0)
|
// back to #0 when all four are busy.
|
||||||
|
const PLAYHEAD = audio.getFreePlayhead(0)
|
||||||
|
audio.resetParams(PLAYHEAD)
|
||||||
|
audio.purgeQueue(PLAYHEAD)
|
||||||
|
taud.uploadTaudFile(filePath, songArg, PLAYHEAD)
|
||||||
|
|
||||||
// ── Instrument archetype classification ─────────────────────────────────────
|
// ── Instrument archetype classification ─────────────────────────────────────
|
||||||
//
|
//
|
||||||
@@ -565,8 +569,8 @@ function pad(n, w) {
|
|||||||
|
|
||||||
let lastStatus = ''
|
let lastStatus = ''
|
||||||
function drawStatus(curCue) {
|
function drawStatus(curCue) {
|
||||||
const bpm = audio.getBPM(0) || song.bpm
|
const bpm = audio.getBPM(PLAYHEAD) || song.bpm
|
||||||
const tick = audio.getTickRate(0) || song.tickRate
|
const tick = audio.getTickRate(PLAYHEAD) || song.tickRate
|
||||||
const cueStr = pad(curCue, 3) + '/' + pad(song.lastCue, 3)
|
const cueStr = pad(curCue, 3) + '/' + pad(song.lastCue, 3)
|
||||||
const s = 'BPM ' + pad(bpm,3) + ' Tick ' + pad(tick,2) +
|
const s = 'BPM ' + pad(bpm,3) + ' Tick ' + pad(tick,2) +
|
||||||
' Voices ' + pad(song.numVoices,2) + ' Cue ' + cueStr
|
' Voices ' + pad(song.numVoices,2) + ' Cue ' + cueStr
|
||||||
@@ -714,7 +718,7 @@ function spawnEventsForRow(cueIdx, rowIdx) {
|
|||||||
const arch = archByInst[effInst]
|
const arch = archByInst[effInst]
|
||||||
let pan = 128
|
let pan = 128
|
||||||
if (panSel === 0) pan = (panVal / 63 * 255) | 0
|
if (panSel === 0) pan = (panVal / 63 * 255) | 0
|
||||||
const livePan = audio.getVoiceEffectivePan(0, v)
|
const livePan = audio.getVoiceEffectivePan(PLAYHEAD, v)
|
||||||
if (typeof livePan === 'number' && livePan !== 128) pan = livePan
|
if (typeof livePan === 'number' && livePan !== 128) pan = livePan
|
||||||
// Replace whatever was in voice v's slot. peakVol seeds at 0 and is
|
// Replace whatever was in voice v's slot. peakVol seeds at 0 and is
|
||||||
// tracked per-frame so the colour ramp normalises by attack peak,
|
// tracked per-frame so the colour ramp normalises by attack peak,
|
||||||
@@ -731,20 +735,188 @@ function spawnEventsForRow(cueIdx, rowIdx) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Per-lane rendering ──────────────────────────────────────────────────────
|
// ── Dynamic matrix background ────────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// The renderer is structured as: each frame, blank the visualiser rows of all
|
// Behind the event lanes runs a "terminal matrix" of the raw tracker data,
|
||||||
// five lanes, walk the active events, and draw each into its lane. Painting
|
// re-spelled as pseudo-opcodes and streamed one row's worth at a time in
|
||||||
// is reasonably cheap because (a) the bordered side columns stay untouched,
|
// lock-step with the playhead's row cadence. Each tracker cell on the current
|
||||||
// and (b) blanking inside is two cells per row × ~14 rows = 30 cells.
|
// row contributes up to four 7-char tokens (only for the sub-fields it carries):
|
||||||
|
//
|
||||||
|
// NT:nnnn note (4-hex noteVal)
|
||||||
|
// VO:i.jj volume column (i = selector 0..3, jj = 2-digit value 00..63)
|
||||||
|
// PN:k.ll pan column (k = selector 0..3, ll = 2-digit value 00..63)
|
||||||
|
// Fs:eeee effect (s = base-36 opcode symbol, eeee = 4-hex argument)
|
||||||
|
//
|
||||||
|
// Tokens flow left-to-right and wrap at the canvas edge; when the print head
|
||||||
|
// runs off the bottom the whole matrix scrolls up one row so the head stays on
|
||||||
|
// the bottom line — the oldest line rolls off the top, like a terminal. A cue
|
||||||
|
// change instead wraps the print head straight back to the top, so each cue
|
||||||
|
// opens a fresh page over the ageing tail of the last. Column
|
||||||
|
// wrapping only ever breaks between a token's three 2-char atoms AA / bb / cc —
|
||||||
|
// never mid-atom — and a colon that would land at a line edge is dropped, so a
|
||||||
|
// line never starts or ends with ':' (it may start with a single separator
|
||||||
|
// space). Each freshly printed cell is brightest and decays one palette step
|
||||||
|
// per row, trailing a comet tail behind the head.
|
||||||
|
const BG_TOP = ROW_TONAL_TOP // matrix shares the whole visuals canvas
|
||||||
|
const BG_BOT = ROW_DRUMS_BOT
|
||||||
|
const BG_ROWS = BG_BOT - BG_TOP + 1
|
||||||
|
const BG_L = COL_INSIDE_L
|
||||||
|
const BG_COLS = LANE_W
|
||||||
|
const BG_BLANK = ' '.repeat(BG_COLS)
|
||||||
|
|
||||||
function blankLanes() {
|
// Palette runs dim → bright per the spec; fresh text takes the bright end.
|
||||||
colour(COL_DIM, COL_BG)
|
const BG_PALETTE = [244,243,242,241] // index 0 = freshest .. last = oldest
|
||||||
const blank = ' '.repeat(LANE_W)
|
const BG_LIFE = 32 // rows a cell stays lit before going dark
|
||||||
for (let r = ROW_TONAL_TOP; r <= ROW_TONAL_BOT; r++)
|
|
||||||
mvtext(r, COL_INSIDE_L, blank)
|
const bgChar = new Uint8Array(BG_ROWS * BG_COLS)
|
||||||
for (let r = ROW_DRUMS_TOP; r <= ROW_DRUMS_BOT; r++)
|
const bgLvl = new Int8Array(BG_ROWS * BG_COLS) // 0 = dark, BG_LIFE = freshest
|
||||||
mvtext(r, COL_INSIDE_L, blank)
|
const bgDith = new Uint8Array(BG_ROWS * BG_COLS) // per-cell ordered-dither threshold 0..15
|
||||||
|
|
||||||
|
// Ordered colour dithering. Each opcode atom (the AA / bb / cc of an "AA:bbcc"
|
||||||
|
// token) is stamped with ONE 4×4 Bayer threshold taken from its start cell, so
|
||||||
|
// the atom dithers as a coherent unit while neighbouring atoms differ — this
|
||||||
|
// stipples the otherwise-flat palette bands of the ageing tail into a smooth
|
||||||
|
// gradient. The threshold biases the floor() that picks between the two palette
|
||||||
|
// entries bracketing a cell's fractional colour index.
|
||||||
|
const BG_BAYER = [
|
||||||
|
0, 8, 2, 10,
|
||||||
|
12, 4, 14, 6,
|
||||||
|
3, 11, 1, 9,
|
||||||
|
15, 7, 13, 5
|
||||||
|
]
|
||||||
|
const BG_DITHER_N = 16
|
||||||
|
function bgBayerAt(gr, gc) { return BG_BAYER[(gr & 3) * 4 + (gc & 3)] }
|
||||||
|
|
||||||
|
// BG_PALETTE[0] is reserved for the freshest row — the cells appended this very
|
||||||
|
// row (lvl == BG_LIFE) — no matter how large BG_LIFE is. Its continuous index
|
||||||
|
// is pinned to exactly 0, which no dither bias can lift, so it stays solid.
|
||||||
|
// Ageing levels carry a *fractional* palette index in [1, BG_LAST]; the dither
|
||||||
|
// resolves that fraction into a spatial mix of the two bracketing entries.
|
||||||
|
const BG_LAST = BG_PALETTE.length - 1
|
||||||
|
const bgContLut = new Float32Array(BG_LIFE + 1)
|
||||||
|
bgContLut[BG_LIFE] = 0
|
||||||
|
for (let lvl = 1; lvl < BG_LIFE; lvl++) {
|
||||||
|
const span = BG_LIFE - 2 // ageing steps between the endpoints
|
||||||
|
const age = (BG_LIFE - 1) - lvl // 0 = freshest aged .. span = oldest
|
||||||
|
const t = span > 0 ? age / span : 0
|
||||||
|
let f = 1 + t * (BG_LAST - 1) // continuous index in [1, BG_LAST]
|
||||||
|
if (f > BG_LAST) f = BG_LAST
|
||||||
|
if (f < 1) f = 1
|
||||||
|
bgContLut[lvl] = f
|
||||||
|
}
|
||||||
|
|
||||||
|
let bgHeadR = 0, bgHeadC = 0
|
||||||
|
|
||||||
|
// Scroll the whole matrix up one row: every row inherits the one below it, the
|
||||||
|
// top line rolls off, and the freed bottom line is cleared. Levels and dither
|
||||||
|
// travel with their cells, so the comet tail stays intact and the decay reads
|
||||||
|
// as a continuous upward drift rather than a wrap-around jump.
|
||||||
|
function bgScrollUp() {
|
||||||
|
bgChar.copyWithin(0, BG_COLS)
|
||||||
|
bgLvl.copyWithin(0, BG_COLS)
|
||||||
|
bgDith.copyWithin(0, BG_COLS)
|
||||||
|
const last = (BG_ROWS - 1) * BG_COLS
|
||||||
|
bgChar.fill(0, last)
|
||||||
|
bgLvl.fill(0, last)
|
||||||
|
bgDith.fill(0, last)
|
||||||
|
}
|
||||||
|
|
||||||
|
function bgNewline() {
|
||||||
|
if (bgHeadR + 1 >= BG_ROWS) bgScrollUp() // at the bottom: scroll instead of wrapping to the top
|
||||||
|
else bgHeadR++
|
||||||
|
bgHeadC = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function bgPut(code) { // single glue char; caller guarantees room
|
||||||
|
const idx = bgHeadR * BG_COLS + bgHeadC
|
||||||
|
bgChar[idx] = code; bgLvl[idx] = BG_LIFE; bgDith[idx] = bgBayerAt(bgHeadR, bgHeadC)
|
||||||
|
bgHeadC++
|
||||||
|
}
|
||||||
|
|
||||||
|
function bgPutAtom(c0, c1) { // 2-char atom; wraps as a unit, dithers as a unit
|
||||||
|
if (bgHeadC + 2 > BG_COLS) bgNewline()
|
||||||
|
const base = bgHeadR * BG_COLS
|
||||||
|
const d = bgBayerAt(bgHeadR, bgHeadC) // one threshold for the whole atom
|
||||||
|
bgChar[base + bgHeadC] = c0; bgLvl[base + bgHeadC] = BG_LIFE; bgDith[base + bgHeadC] = d; bgHeadC++
|
||||||
|
bgChar[base + bgHeadC] = c1; bgLvl[base + bgHeadC] = BG_LIFE; bgDith[base + bgHeadC] = d; bgHeadC++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lay out one "AA:bbcc" token (prefix2 = 2 chars, val4 = 4 chars) with the
|
||||||
|
// break rules above.
|
||||||
|
function bgEmitToken(prefix2, val4) {
|
||||||
|
if (bgHeadC > 0) { // separator space between tokens
|
||||||
|
if (bgHeadC + 3 > BG_COLS) bgNewline() // ...carried to the next line if needed
|
||||||
|
bgPut(0x20)
|
||||||
|
}
|
||||||
|
bgPutAtom(prefix2.charCodeAt(0), prefix2.charCodeAt(1)) // AA
|
||||||
|
if (bgHeadC + 3 <= BG_COLS) { // colon + bb both fit on this line
|
||||||
|
bgPut(0x3A) // ':'
|
||||||
|
bgPutAtom(val4.charCodeAt(0), val4.charCodeAt(1)) // bb
|
||||||
|
} else { // drop the colon, bb opens the next line
|
||||||
|
bgNewline()
|
||||||
|
bgPutAtom(val4.charCodeAt(0), val4.charCodeAt(1)) // bb
|
||||||
|
}
|
||||||
|
bgPutAtom(val4.charCodeAt(2), val4.charCodeAt(3)) // cc (may wrap)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance the matrix by one tracker row: decay every lit cell one step, then
|
||||||
|
// stream the pseudo-opcodes for whatever the row's cells carry. Within a cue
|
||||||
|
// the head marches down and the matrix scrolls under it (see bgNewline); a cue
|
||||||
|
// change wraps the head back to the top to open a fresh page.
|
||||||
|
function bgAdvanceRow(cueIdx, rowIdx, cueChanged) {
|
||||||
|
for (let i = 0; i < bgLvl.length; i++) {
|
||||||
|
if (bgLvl[i] > 0) bgLvl[i]--
|
||||||
|
}
|
||||||
|
if (cueChanged) { bgHeadR = 0; bgHeadC = 0 }
|
||||||
|
const cue = song.cues[cueIdx]
|
||||||
|
if (!cue) return
|
||||||
|
const off = rowIdx * 8
|
||||||
|
for (let v = 0; v < song.numVoices; v++) {
|
||||||
|
const patIdx = cue.ptns[v]
|
||||||
|
if (patIdx === CUE_EMPTY || patIdx >= song.numPats) continue
|
||||||
|
const pat = song.patterns[patIdx]
|
||||||
|
if (!pat) continue
|
||||||
|
const note = pat[off] | (pat[off + 1] << 8)
|
||||||
|
const voleff = pat[off + 3]
|
||||||
|
const paneff = pat[off + 4]
|
||||||
|
const effop = pat[off + 5]
|
||||||
|
const effarg = pat[off + 6] | (pat[off + 7] << 8)
|
||||||
|
if (note !== 0)
|
||||||
|
bgEmitToken('NT', note.toString(16).toUpperCase().padStart(4, '0'))
|
||||||
|
if (voleff !== 0 && voleff !== 0xC0)
|
||||||
|
bgEmitToken('VO', (voleff >>> 6) + '.' + (voleff & 63).toString(10).padStart(2, '0'))
|
||||||
|
if (paneff !== 0 && paneff !== 0xC0)
|
||||||
|
bgEmitToken('PN', (paneff >>> 6) + '.' + (paneff & 63).toString(10).padStart(2, '0'))
|
||||||
|
if (effop !== 0)
|
||||||
|
bgEmitToken('F' + effop.toString(36).toUpperCase()[0],
|
||||||
|
effarg.toString(16).toUpperCase().padStart(4, '0'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paint the matrix as the canvas backdrop; the event lanes draw over it. Each
|
||||||
|
// strip is blanked in one shot, then its lit cells are overlaid (spaces and dark
|
||||||
|
// cells skipped), batching colour switches so same-age runs share one call.
|
||||||
|
function drawBackground() {
|
||||||
|
let curFg = -1
|
||||||
|
for (let gr = 0; gr < BG_ROWS; gr++) {
|
||||||
|
const sr = BG_TOP + gr
|
||||||
|
colour(COL_DIM, COL_BG); curFg = COL_DIM
|
||||||
|
con.move(sr, BG_L)
|
||||||
|
print(BG_BLANK)
|
||||||
|
const base = gr * BG_COLS
|
||||||
|
for (let gc = 0; gc < BG_COLS; gc++) {
|
||||||
|
const lvl = bgLvl[base + gc]
|
||||||
|
if (lvl <= 0) continue
|
||||||
|
const ch = bgChar[base + gc]
|
||||||
|
if (ch === 0x20) continue
|
||||||
|
let idx = Math.floor(bgContLut[lvl] + (bgDith[base + gc] + 0.5) / BG_DITHER_N)
|
||||||
|
if (idx > BG_LAST) idx = BG_LAST
|
||||||
|
if (idx < 0) idx = 0
|
||||||
|
const fg = BG_PALETTE[idx]
|
||||||
|
if (fg !== curFg) { colour(fg, COL_BG); curFg = fg }
|
||||||
|
mvprn(sr, BG_L + gc, ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function envColour(arch, volFrac) {
|
function envColour(arch, volFrac) {
|
||||||
@@ -849,9 +1021,12 @@ function drawEventLead(ev, stage, volFrac, livePan, liveNote) {
|
|||||||
: stage === STAGE_RELEASE ? 0xF9 /*·*/
|
: stage === STAGE_RELEASE ? 0xF9 /*·*/
|
||||||
: 0xC4 /*─*/
|
: 0xC4 /*─*/
|
||||||
for (let i = 1; i <= tailLen; i++) {
|
for (let i = 1; i <= tailLen; i++) {
|
||||||
const x = cx - i
|
const xl = cx - i
|
||||||
if (x >= COL_INSIDE_L && x <= COL_INSIDE_R)
|
if (xl >= COL_INSIDE_L && xl <= COL_INSIDE_R)
|
||||||
mvprn(y, x, trailChar)
|
mvprn(y, xl, trailChar)
|
||||||
|
const xr = cx + i
|
||||||
|
if (xr >= COL_INSIDE_L && xr <= COL_INSIDE_R)
|
||||||
|
mvprn(y, xr, trailChar)
|
||||||
}
|
}
|
||||||
const head = stage === STAGE_ATTACK ? 0xFE /*■*/
|
const head = stage === STAGE_ATTACK ? 0xFE /*■*/
|
||||||
: stage === STAGE_RELEASE ? 0x09 /*°*/
|
: stage === STAGE_RELEASE ? 0x09 /*°*/
|
||||||
@@ -880,18 +1055,18 @@ function drawEventMetal(ev, stage, volFrac, liveNote) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderEvents() {
|
function renderEvents() {
|
||||||
blankLanes()
|
drawBackground()
|
||||||
for (let v = 0; v < song.numVoices; v++) {
|
for (let v = 0; v < song.numVoices; v++) {
|
||||||
const ev = events[v]
|
const ev = events[v]
|
||||||
if (!ev) continue
|
if (!ev) continue
|
||||||
// The engine's `active` flag is the source of truth — set by note-on,
|
// The engine's `active` flag is the source of truth — set by note-on,
|
||||||
// cleared by note-cut, sample-end, envelope-end-of-decay, or NNA cut.
|
// cleared by note-cut, sample-end, envelope-end-of-decay, or NNA cut.
|
||||||
// Once it drops, the voice is genuinely silent so the visual goes too.
|
// Once it drops, the voice is genuinely silent so the visual goes too.
|
||||||
if (!audio.getVoiceActive(0, v)) { events[v] = null; continue }
|
if (!audio.getVoiceActive(PLAYHEAD, v)) { events[v] = null; continue }
|
||||||
|
|
||||||
const liveVol = audio.getVoiceEffectiveVolume(0, v) || 0
|
const liveVol = audio.getVoiceEffectiveVolume(PLAYHEAD, v) || 0
|
||||||
const livePan = audio.getVoiceEffectivePan(0, v)
|
const livePan = audio.getVoiceEffectivePan(PLAYHEAD, v)
|
||||||
const liveNote = audio.getVoiceNote(0, v)
|
const liveNote = audio.getVoiceNote(PLAYHEAD, v)
|
||||||
|
|
||||||
if (liveVol > ev.peakVol) ev.peakVol = liveVol
|
if (liveVol > ev.peakVol) ev.peakVol = liveVol
|
||||||
ev.ageFrames++
|
ev.ageFrames++
|
||||||
@@ -923,10 +1098,10 @@ function drawStereo() {
|
|||||||
const W = LANE_W
|
const W = LANE_W
|
||||||
const bins = new Float32Array(W)
|
const bins = new Float32Array(W)
|
||||||
for (let v = 0; v < song.numVoices; v++) {
|
for (let v = 0; v < song.numVoices; v++) {
|
||||||
if (!audio.getVoiceActive(0, v)) continue
|
if (!audio.getVoiceActive(PLAYHEAD, v)) continue
|
||||||
const vol = Math.pow(audio.getVoiceEffectiveVolume(0, v) || 0, 0.125)
|
const vol = Math.pow(audio.getVoiceEffectiveVolume(PLAYHEAD, v) || 0, 0.125)
|
||||||
if (vol <= 0) continue
|
if (vol <= 0) continue
|
||||||
const pan = audio.getVoiceEffectivePan(0, v)
|
const pan = audio.getVoiceEffectivePan(PLAYHEAD, v)
|
||||||
let col = Math.round((pan / 255) * (W - 1))
|
let col = Math.round((pan / 255) * (W - 1))
|
||||||
if (col < 0) col = 0
|
if (col < 0) col = 0
|
||||||
if (col >= W) col = W - 1
|
if (col >= W) col = W - 1
|
||||||
@@ -972,7 +1147,7 @@ function drawTickLights(tickInRow, tickRate) {
|
|||||||
// Voice activity counter on the right.
|
// Voice activity counter on the right.
|
||||||
let nActive = 0
|
let nActive = 0
|
||||||
for (let v = 0; v < song.numVoices; v++) {
|
for (let v = 0; v < song.numVoices; v++) {
|
||||||
if (audio.getVoiceActive(0, v)) nActive++
|
if (audio.getVoiceActive(PLAYHEAD, v)) nActive++
|
||||||
}
|
}
|
||||||
colour(COL_DIM, COL_BG)
|
colour(COL_DIM, COL_BG)
|
||||||
const s = 'ACTIVE ' + pad(nActive, 2) + '/' + pad(song.numVoices, 2)
|
const s = 'ACTIVE ' + pad(nActive, 2) + '/' + pad(song.numVoices, 2)
|
||||||
@@ -986,10 +1161,10 @@ drawStatus(0)
|
|||||||
drawOrderStrip(0)
|
drawOrderStrip(0)
|
||||||
|
|
||||||
// ── Playback ────────────────────────────────────────────────────────────────
|
// ── Playback ────────────────────────────────────────────────────────────────
|
||||||
audio.setCuePosition(0, 0)
|
audio.setCuePosition(PLAYHEAD, 0)
|
||||||
audio.setTrackerRow(0, 0)
|
audio.setTrackerRow(PLAYHEAD, 0)
|
||||||
audio.setMasterVolume(0, 255)
|
audio.setMasterVolume(PLAYHEAD, 255)
|
||||||
audio.play(0)
|
audio.play(PLAYHEAD)
|
||||||
|
|
||||||
let stopReq = false
|
let stopReq = false
|
||||||
let errorlevel = 0
|
let errorlevel = 0
|
||||||
@@ -1003,22 +1178,25 @@ let errorlevel = 0
|
|||||||
let ticksPerRow = Math.max(1, song.tickRate)
|
let ticksPerRow = Math.max(1, song.tickRate)
|
||||||
let synthTick = 0 // tick within current row, 0..ticksPerRow-1
|
let synthTick = 0 // tick within current row, 0..ticksPerRow-1
|
||||||
try {
|
try {
|
||||||
while (audio.isPlaying(0) && !stopReq) {
|
while (audio.isPlaying(PLAYHEAD) && !stopReq) {
|
||||||
// Backspace polling (mirrors playtad).
|
// Backspace polling (mirrors playtad).
|
||||||
sys.poke(-40, 1)
|
sys.poke(-40, 1)
|
||||||
if (sys.peek(-41) === 67) stopReq = true
|
if (sys.peek(-41) === 67) stopReq = true
|
||||||
|
|
||||||
const curCue = audio.getCuePosition(0)
|
const curCue = audio.getCuePosition(PLAYHEAD)
|
||||||
const curRow = audio.getTrackerRow(0)
|
const curRow = audio.getTrackerRow(PLAYHEAD)
|
||||||
if (curCue !== lastSeenCue || curRow !== lastSeenRow) {
|
if (curCue !== lastSeenCue || curRow !== lastSeenRow) {
|
||||||
// Row boundary — spawn new events, reset synthetic tick counter.
|
// Row boundary — spawn new events, advance the matrix background
|
||||||
|
// (scrolls within a cue, wraps to the top on a cue change), reset
|
||||||
|
// tick count.
|
||||||
spawnEventsForRow(curCue, curRow)
|
spawnEventsForRow(curCue, curRow)
|
||||||
|
bgAdvanceRow(curCue, curRow, curCue !== lastSeenCue)
|
||||||
lastSeenCue = curCue
|
lastSeenCue = curCue
|
||||||
lastSeenRow = curRow
|
lastSeenRow = curRow
|
||||||
synthTick = 0
|
synthTick = 0
|
||||||
// Pull a fresh tickRate read here in case a T effect changed it
|
// Pull a fresh tickRate read here in case a T effect changed it
|
||||||
// mid-song.
|
// mid-song.
|
||||||
ticksPerRow = Math.max(1, audio.getTickRate(0) || song.tickRate)
|
ticksPerRow = Math.max(1, audio.getTickRate(PLAYHEAD) || song.tickRate)
|
||||||
} else {
|
} else {
|
||||||
// Same row — advance the synthetic tick counter against wall time.
|
// Same row — advance the synthetic tick counter against wall time.
|
||||||
// Tick period (ms) = (60000 / BPM) / 24 ... but the spec is
|
// Tick period (ms) = (60000 / BPM) / 24 ... but the spec is
|
||||||
@@ -1038,7 +1216,7 @@ try {
|
|||||||
drawStereo()
|
drawStereo()
|
||||||
drawTickLights(synthTick, ticksPerRow)
|
drawTickLights(synthTick, ticksPerRow)
|
||||||
|
|
||||||
sys.sleep((2500 / audio.getBPM(0))|0) // one visual frame = one tick
|
sys.sleep((2500 / audio.getBPM(PLAYHEAD))|0) // one visual frame = one tick
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (e) {
|
catch (e) {
|
||||||
@@ -1046,7 +1224,7 @@ catch (e) {
|
|||||||
errorlevel = 1
|
errorlevel = 1
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
audio.stop(0)
|
audio.stop(PLAYHEAD)
|
||||||
con.move(ROW_BOT_BORDER + 1, 1)
|
con.move(ROW_BOT_BORDER + 1, 1)
|
||||||
con.curs_set(1)
|
con.curs_set(1)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ const ADDRESSING_INTERNAL = 0x02
|
|||||||
const SND_BASE_ADDR = audio.getBaseAddr()
|
const SND_BASE_ADDR = audio.getBaseAddr()
|
||||||
const SND_MEM_ADDR = audio.getMemAddr()
|
const SND_MEM_ADDR = audio.getMemAddr()
|
||||||
const pcm = require("pcm")
|
const pcm = require("pcm")
|
||||||
const AUDIO_DEVICE = 0
|
const AUDIO_DEVICE = audio.getFreePlayhead(0)
|
||||||
const MP2_FRAME_SIZE = [144,216,252,288,360,432,504,576,720,864,1008,1152,1440,1728]
|
const MP2_FRAME_SIZE = [144,216,252,288,360,432,504,576,720,864,1008,1152,1440,1728]
|
||||||
const TAV_TEMPORAL_LEVELS = 2
|
const TAV_TEMPORAL_LEVELS = 2
|
||||||
|
|
||||||
|
|||||||
23
assets/disk0/tvdos/bin/playtav.js.synopsis
Normal file
23
assets/disk0/tvdos/bin/playtav.js.synopsis
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "playtav",
|
||||||
|
"summary": "Play a TAV video file",
|
||||||
|
"symbols": {
|
||||||
|
"interactive": { "kind": "option", "short": "-i", "summary": "Interactive mode" },
|
||||||
|
"filmGrain": {
|
||||||
|
"kind": "option",
|
||||||
|
"long": "--filter-film-grain",
|
||||||
|
"summary": "Apply a film-grain filter",
|
||||||
|
"value": { "name": "LEVEL", "type": "integer", "required": false, "summary": "Grain intensity" }
|
||||||
|
},
|
||||||
|
"options": { "kind": "group", "summary": "Options", "members": ["interactive", "filmGrain"] },
|
||||||
|
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "TAV file to play" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "sequence",
|
||||||
|
"children": [
|
||||||
|
{ "type": "reference", "symbol": "file" },
|
||||||
|
{ "type": "repeat", "child": { "type": "reference", "symbol": "options" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -100,10 +100,14 @@ graphics.clearPixels(0)
|
|||||||
graphics.clearPixels2(0)
|
graphics.clearPixels2(0)
|
||||||
|
|
||||||
// Initialize audio
|
// Initialize audio
|
||||||
audio.resetParams(0)
|
// Occupy the first idle playhead rather than always grabbing #0, so playback
|
||||||
audio.purgeQueue(0)
|
// doesn't cut off audio already running on another playhead. Falls back to #0
|
||||||
audio.setPcmMode(0)
|
// when all four are busy.
|
||||||
audio.setMasterVolume(0, 255)
|
const PLAYHEAD = audio.getFreePlayhead(0)
|
||||||
|
audio.resetParams(PLAYHEAD)
|
||||||
|
audio.purgeQueue(PLAYHEAD)
|
||||||
|
audio.setPcmMode(PLAYHEAD)
|
||||||
|
audio.setMasterVolume(PLAYHEAD, 255)
|
||||||
|
|
||||||
// set colour zero as half-opaque black
|
// set colour zero as half-opaque black
|
||||||
graphics.setPalette(0, 0, 0, 0, 9)
|
graphics.setPalette(0, 0, 0, 0, 9)
|
||||||
@@ -791,14 +795,14 @@ try {
|
|||||||
if (isInterlaced) {
|
if (isInterlaced) {
|
||||||
// fire audio after frame 1
|
// fire audio after frame 1
|
||||||
if (!audioFired && frameCount > 0) {
|
if (!audioFired && frameCount > 0) {
|
||||||
audio.play(0)
|
audio.play(PLAYHEAD)
|
||||||
audioFired = true
|
audioFired = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
// fire audio after frame 0
|
// fire audio after frame 0
|
||||||
if (!audioFired) {
|
if (!audioFired) {
|
||||||
audio.play(0)
|
audio.play(PLAYHEAD)
|
||||||
audioFired = true
|
audioFired = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -900,8 +904,8 @@ finally {
|
|||||||
if (PREV_FIELD_BUFFER > 0) sys.free(PREV_FIELD_BUFFER)
|
if (PREV_FIELD_BUFFER > 0) sys.free(PREV_FIELD_BUFFER)
|
||||||
if (NEXT_FIELD_BUFFER > 0) sys.free(NEXT_FIELD_BUFFER)
|
if (NEXT_FIELD_BUFFER > 0) sys.free(NEXT_FIELD_BUFFER)
|
||||||
|
|
||||||
audio.stop(0)
|
audio.stop(PLAYHEAD)
|
||||||
audio.purgeQueue(0)
|
audio.purgeQueue(PLAYHEAD)
|
||||||
|
|
||||||
if (interactive) {
|
if (interactive) {
|
||||||
//con.clear()
|
//con.clear()
|
||||||
|
|||||||
18
assets/disk0/tvdos/bin/playtev.js.synopsis
Normal file
18
assets/disk0/tvdos/bin/playtev.js.synopsis
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "playtev",
|
||||||
|
"summary": "Play a TEV video file",
|
||||||
|
"symbols": {
|
||||||
|
"interactive": { "kind": "option", "short": "-i", "summary": "Interactive mode" },
|
||||||
|
"debugMv": { "kind": "option", "long": "-debug-mv", "summary": "Show motion-vector debug overlay" },
|
||||||
|
"options": { "kind": "group", "summary": "Options", "members": ["interactive", "debugMv"] },
|
||||||
|
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "TEV file to play" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "sequence",
|
||||||
|
"children": [
|
||||||
|
{ "type": "reference", "symbol": "file" },
|
||||||
|
{ "type": "repeat", "child": { "type": "reference", "symbol": "options" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -131,16 +131,20 @@ try {
|
|||||||
readPtr = sys.malloc(pcmType === 2 ? BLOCK_SIZE : BLOCK_SIZE * bitsPerSample / 8)
|
readPtr = sys.malloc(pcmType === 2 ? BLOCK_SIZE : BLOCK_SIZE * bitsPerSample / 8)
|
||||||
decodePtr = sys.malloc(BLOCK_SIZE * pcm.HW_SAMPLING_RATE / samplingRate)
|
decodePtr = sys.malloc(BLOCK_SIZE * pcm.HW_SAMPLING_RATE / samplingRate)
|
||||||
|
|
||||||
audio.resetParams(0)
|
// Occupy the first idle playhead rather than always grabbing #0, so
|
||||||
audio.purgeQueue(0)
|
// playback doesn't cut off audio already running on another playhead.
|
||||||
audio.setPcmMode(0)
|
// Falls back to #0 when all four are busy.
|
||||||
audio.setMasterVolume(0, 255)
|
const PLAYHEAD = audio.getFreePlayhead(0)
|
||||||
|
audio.resetParams(PLAYHEAD)
|
||||||
|
audio.purgeQueue(PLAYHEAD)
|
||||||
|
audio.setPcmMode(PLAYHEAD)
|
||||||
|
audio.setMasterVolume(PLAYHEAD, 255)
|
||||||
|
|
||||||
let readLength = 1
|
let readLength = 1
|
||||||
while (!stopPlay && seqread.getReadCount() < startOffset + chunkSize && readLength > 0) {
|
while (!stopPlay && seqread.getReadCount() < startOffset + chunkSize && readLength > 0) {
|
||||||
if (interactive && gui.audioIsExitRequested()) { stopPlay = true; break }
|
if (interactive && gui.audioIsExitRequested()) { stopPlay = true; break }
|
||||||
|
|
||||||
if (audio.getPosition(0) <= 1) {
|
if (audio.getPosition(PLAYHEAD) <= 1) {
|
||||||
for (let repeat = 0; repeat < QUEUE_MAX; repeat++) {
|
for (let repeat = 0; repeat < QUEUE_MAX; repeat++) {
|
||||||
const remainingBytes = FILE_SIZE - 8 - seqread.getReadCount()
|
const remainingBytes = FILE_SIZE - 8 - seqread.getReadCount()
|
||||||
readLength = (remainingBytes < INFILE_BLOCK_SIZE) ? remainingBytes : INFILE_BLOCK_SIZE
|
readLength = (remainingBytes < INFILE_BLOCK_SIZE) ? remainingBytes : INFILE_BLOCK_SIZE
|
||||||
@@ -153,13 +157,13 @@ try {
|
|||||||
// before queueing — the buffer is reused next iteration.
|
// before queueing — the buffer is reused next iteration.
|
||||||
if (interactive) gui.audioFeedPcm(decodePtr, decodedSampleLength >> 1)
|
if (interactive) gui.audioFeedPcm(decodePtr, decodedSampleLength >> 1)
|
||||||
|
|
||||||
audio.putPcmDataByPtr(0, decodePtr, decodedSampleLength, 0)
|
audio.putPcmDataByPtr(PLAYHEAD, decodePtr, decodedSampleLength, 0)
|
||||||
audio.setSampleUploadLength(0, decodedSampleLength)
|
audio.setSampleUploadLength(PLAYHEAD, decodedSampleLength)
|
||||||
audio.startSampleUpload(0)
|
audio.startSampleUpload(PLAYHEAD)
|
||||||
|
|
||||||
sys.spin()
|
sys.spin()
|
||||||
}
|
}
|
||||||
audio.play(0)
|
audio.play(PLAYHEAD)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interactive) {
|
if (interactive) {
|
||||||
|
|||||||
17
assets/disk0/tvdos/bin/playwav.js.synopsis
Normal file
17
assets/disk0/tvdos/bin/playwav.js.synopsis
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "playwav",
|
||||||
|
"summary": "Play a WAV audio file",
|
||||||
|
"symbols": {
|
||||||
|
"interactive": { "kind": "option", "short": "-i", "summary": "Interactive mode (visualiser)" },
|
||||||
|
"options": { "kind": "group", "summary": "Options", "members": ["interactive"] },
|
||||||
|
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "WAV file to play" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "sequence",
|
||||||
|
"children": [
|
||||||
|
{ "type": "reference", "symbol": "file" },
|
||||||
|
{ "type": "repeat", "child": { "type": "reference", "symbol": "options" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
9
assets/disk0/tvdos/bin/printfile.js.synopsis
Normal file
9
assets/disk0/tvdos/bin/printfile.js.synopsis
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "printfile",
|
||||||
|
"summary": "Print a text file with line numbers",
|
||||||
|
"symbols": {
|
||||||
|
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "Text file to print" }
|
||||||
|
},
|
||||||
|
"synopsis": { "type": "reference", "symbol": "file" }
|
||||||
|
}
|
||||||
180
assets/disk0/tvdos/bin/synopsis.js
Normal file
180
assets/disk0/tvdos/bin/synopsis.js
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
/*
|
||||||
|
* synopsis.js -- system-wide help / tldr.
|
||||||
|
*
|
||||||
|
* Prints a command's human-targeted one-line summary and an auto-generated
|
||||||
|
* synopsis (usage line, arguments, options and constraints) derived from its
|
||||||
|
* TSF .synopsis document via the `synopsis` library (synopsis.mjs).
|
||||||
|
*
|
||||||
|
* Usage: synopsis PROGRAM
|
||||||
|
* synopsis (describes itself)
|
||||||
|
*/
|
||||||
|
|
||||||
|
let syn
|
||||||
|
try {
|
||||||
|
syn = require("synopsis")
|
||||||
|
} catch (e) {
|
||||||
|
printerrln("synopsis: the 'synopsis' library is not installed")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const termW = (con.getmaxyx()[1]) || 80
|
||||||
|
|
||||||
|
// Word-wrap plain text to `width`, returning an array of lines.
|
||||||
|
function wrap(text, width) {
|
||||||
|
if (!text) return []
|
||||||
|
if (width < 8) width = 8
|
||||||
|
let words = ('' + text).split(/\s+/).filter(function (w) { return w.length })
|
||||||
|
let lines = [], line = ''
|
||||||
|
words.forEach(function (w) {
|
||||||
|
if (line.length === 0) line = w
|
||||||
|
else if (line.length + 1 + w.length <= width) line += ' ' + w
|
||||||
|
else { lines.push(line); line = w }
|
||||||
|
})
|
||||||
|
if (line.length) lines.push(line)
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print a "left summary" row: the summary is wrapped into the right column and
|
||||||
|
// continuation lines are aligned under it. An over-wide `left` spills onto its
|
||||||
|
// own line.
|
||||||
|
function row(left, summary, leftW, indent) {
|
||||||
|
let pad = ' '.repeat(indent)
|
||||||
|
let gap = 2
|
||||||
|
let sumW = Math.max(8, termW - indent - leftW - gap)
|
||||||
|
let wrapped = wrap(summary, sumW)
|
||||||
|
if (left.length > leftW) {
|
||||||
|
println(pad + left)
|
||||||
|
wrapped.forEach(function (l) { println(pad + ' '.repeat(leftW + gap) + l) })
|
||||||
|
} else {
|
||||||
|
let first = wrapped.length ? wrapped[0] : ''
|
||||||
|
println(pad + left + ' '.repeat(leftW - left.length + gap) + first)
|
||||||
|
for (let i = 1; i < wrapped.length; i++)
|
||||||
|
println(pad + ' '.repeat(leftW + gap) + wrapped[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- resolve the target ----------------------------------------------------
|
||||||
|
let token = (exec_args[1] !== undefined && exec_args[1] !== '') ? exec_args[1] : "synopsis"
|
||||||
|
|
||||||
|
let model = syn.getModel(token)
|
||||||
|
if (!model) {
|
||||||
|
printerrln(`synopsis: no synopsis found for '${token}'`)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display name for a referenced symbol id (used by the constraints section).
|
||||||
|
function symDisplay(id) {
|
||||||
|
let s = model.symbols[id]
|
||||||
|
if (!s) return id
|
||||||
|
if (s.kind === 'option') return s.long || s.short || id
|
||||||
|
if (s.kind === 'positional') return s.name || id
|
||||||
|
if (s.kind === 'subcommand') return s.name || id
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append a "{a, b, c}" hint of permitted values to a summary, if any.
|
||||||
|
function withValues(summary, values) {
|
||||||
|
if (!values || !values.length) return summary || ''
|
||||||
|
let vs = values.map(function (v) {
|
||||||
|
return (v && typeof v === 'object' && ('value' in v)) ? v.value : v
|
||||||
|
}).join(', ')
|
||||||
|
return (summary ? summary + ' ' : '') + '{' + vs + '}'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Left-column text for an option, e.g. "-o, --output=FILE".
|
||||||
|
function optionLeft(e) {
|
||||||
|
let forms = []
|
||||||
|
if (e.short) forms.push(e.short)
|
||||||
|
if (e.long) forms.push(e.long)
|
||||||
|
let s = forms.join(', ')
|
||||||
|
if (e.hasValue) {
|
||||||
|
let vn = (e.value && (e.value.name || e.value.type)) || 'VALUE'
|
||||||
|
if (e.long) s += e.valueRequired ? '=' + vn : '[=' + vn + ']'
|
||||||
|
else s += e.valueRequired ? ' ' + vn : ' [' + vn + ']'
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
function optionSummary(e) {
|
||||||
|
let s = e.summary || ''
|
||||||
|
if (e.negatable) s += (s ? ' ' : '') + '(negatable)'
|
||||||
|
if (e.value && e.value.values && e.value.values.length) s = withValues(s, e.value.values)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
function constraintText(c) {
|
||||||
|
let names = (c.symbols || []).map(symDisplay)
|
||||||
|
if (c.type === 'conflicts') return 'Mutually exclusive: ' + names.join(', ')
|
||||||
|
if (c.type === 'requires') return symDisplay(c.subject) + ' requires ' + (c.targets || []).map(symDisplay).join(', ')
|
||||||
|
if (c.type === 'implies') return symDisplay(c.subject) + ' implies ' + (c.targets || []).map(symDisplay).join(', ')
|
||||||
|
if (c.type === 'cardinality') {
|
||||||
|
let mn = c.minimum, mx = c.maximum, q
|
||||||
|
if (mn === 1 && mx === 1) q = 'Exactly one of'
|
||||||
|
else if (mn === 1 && mx === undefined) q = 'At least one of'
|
||||||
|
else if (mn === undefined && mx === 1) q = 'At most one of'
|
||||||
|
else q = `Between ${mn} and ${mx} of`
|
||||||
|
return q + ': ' + names.join(', ')
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- gather rows -----------------------------------------------------------
|
||||||
|
let argEntries = model.positionals.map(function (p) {
|
||||||
|
return { left: (p.name || p.id) + (p.repeatable ? '...' : ''), summary: withValues(p.summary, p.values) }
|
||||||
|
})
|
||||||
|
let optEntries = model.flags.map(function (e) {
|
||||||
|
return { left: optionLeft(e), summary: optionSummary(e) }
|
||||||
|
})
|
||||||
|
let subEntries = model.subcommands.map(function (s) {
|
||||||
|
return { left: s.name, summary: s.summary || '' }
|
||||||
|
})
|
||||||
|
|
||||||
|
// shared left-column width (capped so a long flag does not push everything out)
|
||||||
|
let leftW = 4
|
||||||
|
argEntries.concat(optEntries, subEntries).forEach(function (e) { if (e.left.length > leftW) leftW = e.left.length })
|
||||||
|
if (leftW > 30) leftW = 30
|
||||||
|
|
||||||
|
// ---- render ----------------------------------------------------------------
|
||||||
|
let title = model.name || token
|
||||||
|
println(model.summary ? `${title} - ${model.summary}` : title)
|
||||||
|
println()
|
||||||
|
|
||||||
|
let usage = syn.getUsage(token)
|
||||||
|
if (usage) {
|
||||||
|
println("Usage:")
|
||||||
|
println(" " + usage)
|
||||||
|
println()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model.description) {
|
||||||
|
wrap(model.description, termW).forEach(function (l) { println(l) })
|
||||||
|
println()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subEntries.length) {
|
||||||
|
println("Commands:")
|
||||||
|
subEntries.forEach(function (e) { row(e.left, e.summary, leftW, 4) })
|
||||||
|
println()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (argEntries.length) {
|
||||||
|
println("Arguments:")
|
||||||
|
argEntries.forEach(function (e) { row(e.left, e.summary, leftW, 4) })
|
||||||
|
println()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (optEntries.length) {
|
||||||
|
println("Options:")
|
||||||
|
optEntries.forEach(function (e) { row(e.left, e.summary, leftW, 4) })
|
||||||
|
println()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model.constraints && model.constraints.length) {
|
||||||
|
let lines = model.constraints.map(constraintText).filter(function (t) { return t })
|
||||||
|
if (lines.length) {
|
||||||
|
println("Constraints:")
|
||||||
|
lines.forEach(function (l) { println(" " + l) })
|
||||||
|
println()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
13
assets/disk0/tvdos/bin/synopsis.js.synopsis
Normal file
13
assets/disk0/tvdos/bin/synopsis.js.synopsis
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "synopsis",
|
||||||
|
"summary": "Print a command's summary and auto-generated synopsis",
|
||||||
|
"description": "Prints the one-line summary and an auto-generated usage line for PROGRAM, derived from its TSF .synopsis document, together with its arguments, options and constraints. With no PROGRAM, describes itself.",
|
||||||
|
"symbols": {
|
||||||
|
"program": { "kind": "positional", "type": "command", "name": "PROGRAM", "summary": "Command to describe; describes synopsis itself when omitted" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "optional",
|
||||||
|
"child": { "type": "reference", "symbol": "program" }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,6 @@ const TRACKER_SIGNATURE = "TsvmTaut"+BUILD_DATE // 14-byte string
|
|||||||
const MIDDOT = "\u00FA"
|
const MIDDOT = "\u00FA"
|
||||||
const BIGDOT = "\u00F9"
|
const BIGDOT = "\u00F9"
|
||||||
const BULLET = "\u00847u"
|
const BULLET = "\u00847u"
|
||||||
const DOTHORZ = "\u00B4\u00B5"
|
|
||||||
const VERT = 0xDA
|
const VERT = 0xDA
|
||||||
|
|
||||||
// global var for the app
|
// global var for the app
|
||||||
@@ -1042,6 +1041,7 @@ const colVoiceHdr = 230
|
|||||||
const colVoiceHdrMuted = 249
|
const colVoiceHdrMuted = 249
|
||||||
const colVoiceHdrMutedCursorUp = 180
|
const colVoiceHdrMutedCursorUp = 180
|
||||||
const colSep = 252
|
const colSep = 252
|
||||||
|
const colScrollBar = 249
|
||||||
const colPushBtnBack = 143
|
const colPushBtnBack = 143
|
||||||
const colTabBarBack = 187
|
const colTabBarBack = 187
|
||||||
const colTabBarBack2 = 136
|
const colTabBarBack2 = 136
|
||||||
@@ -1231,7 +1231,7 @@ function drawSeparators(style) {
|
|||||||
for (let x = PTNVIEW_OFFSET_X; x < SCRW - 3; x += COLSIZE_TIMELINE_FULL) {
|
for (let x = PTNVIEW_OFFSET_X; x < SCRW - 3; x += COLSIZE_TIMELINE_FULL) {
|
||||||
for (let y = 0; y < PTNVIEW_HEIGHT+1; y++) {
|
for (let y = 0; y < PTNVIEW_HEIGHT+1; y++) {
|
||||||
let memOffset = (y+PTNVIEW_OFFSET_Y-2) * SCRW + (x-1)
|
let memOffset = (y+PTNVIEW_OFFSET_Y-2) * SCRW + (x-1)
|
||||||
let bgColOffset = GPU_MEM - TEXT_BACK_OFF - memOffset
|
let bgColOffset = vaddr(TEXT_BACK_OFF + memOffset)
|
||||||
let oldBgCol = sys.peek(bgColOffset)
|
let oldBgCol = sys.peek(bgColOffset)
|
||||||
if (oldBgCol == 255) {
|
if (oldBgCol == 255) {
|
||||||
sys.poke(bgColOffset, colColumnSep)
|
sys.poke(bgColOffset, colColumnSep)
|
||||||
@@ -1721,8 +1721,7 @@ function drawVoiceDetail(isVerticalLayout = false, ptn = null, activeRow = -1, c
|
|||||||
const sepY = PTNVIEW_OFFSET_Y + upperHeight
|
const sepY = PTNVIEW_OFFSET_Y + upperHeight
|
||||||
con.move(sepY, dx)
|
con.move(sepY, dx)
|
||||||
con.color_pair(colSep, 255)
|
con.color_pair(colSep, 255)
|
||||||
print(DOTHORZ.repeat(detailW >>> 1))
|
print('\u00B3'.repeat(detailW))
|
||||||
if (detailW % 2 == 1) print(DOTHORZ[0])
|
|
||||||
|
|
||||||
// Lower section: cumulative state.
|
// Lower section: cumulative state.
|
||||||
const lowerY0 = sepY + 1
|
const lowerY0 = sepY + 1
|
||||||
@@ -1806,6 +1805,17 @@ const TEXT_BACK_OFF = 2 + 2560
|
|||||||
const TEXT_CHAR_OFF = 2 + 2560 + 2560
|
const TEXT_CHAR_OFF = 2 + 2560 + 2560
|
||||||
const TEXT_PLANES = [TEXT_CHAR_OFF, TEXT_BACK_OFF, TEXT_FORE_OFF]
|
const TEXT_PLANES = [TEXT_CHAR_OFF, TEXT_BACK_OFF, TEXT_FORE_OFF]
|
||||||
|
|
||||||
|
// Direct text-VRAM addressing. On real hardware the GPU text area is addressed
|
||||||
|
// backward (byte m at GPU_MEM - m). Under vtmgr's virtual consoles the physical
|
||||||
|
// GPU is owned by the compositor, so direct writes must instead target this
|
||||||
|
// pane's forward text-plane buffer (VT_TEXT_PLANE + m), which the compositor
|
||||||
|
// blits to the screen. vaddr(m) returns the address of text-area byte m for the
|
||||||
|
// current environment; the physical branch is identical to the old arithmetic.
|
||||||
|
const _VT_VRAM = (typeof globalThis.VT_TEXT_PLANE !== 'undefined')
|
||||||
|
const VRAM_BASE = _VT_VRAM ? globalThis.VT_TEXT_PLANE : GPU_MEM
|
||||||
|
const VRAM_SGN = _VT_VRAM ? 1 : -1
|
||||||
|
function vaddr(m) { return VRAM_BASE + VRAM_SGN * m }
|
||||||
|
|
||||||
// One scratch strip, reused across shifts
|
// One scratch strip, reused across shifts
|
||||||
const SCRATCH_PTR = sys.malloc(SCRW * PTNVIEW_HEIGHT)
|
const SCRATCH_PTR = sys.malloc(SCRW * PTNVIEW_HEIGHT)
|
||||||
|
|
||||||
@@ -1828,8 +1838,8 @@ function shiftPatternArea(dy) {
|
|||||||
|
|
||||||
for (let p = 0; p < 3; p++) {
|
for (let p = 0; p < 3; p++) {
|
||||||
const chanOff = TEXT_PLANES[p]
|
const chanOff = TEXT_PLANES[p]
|
||||||
const srcAddr = GPU_MEM - chanOff - (srcTopY - 1) * SCRW
|
const srcAddr = vaddr(chanOff + (srcTopY - 1) * SCRW)
|
||||||
const dstAddr = GPU_MEM - chanOff - (dstTopY - 1) * SCRW
|
const dstAddr = vaddr(chanOff + (dstTopY - 1) * SCRW)
|
||||||
sys.memcpy(srcAddr, SCRATCH_PTR, stripBytes)
|
sys.memcpy(srcAddr, SCRATCH_PTR, stripBytes)
|
||||||
sys.memcpy(SCRATCH_PTR, dstAddr, stripBytes)
|
sys.memcpy(SCRATCH_PTR, dstAddr, stripBytes)
|
||||||
}
|
}
|
||||||
@@ -1850,9 +1860,9 @@ function shiftPatternAreaHorizontal(dVoice) {
|
|||||||
for (let p = 0; p < 3; p++) {
|
for (let p = 0; p < 3; p++) {
|
||||||
const chanOff = TEXT_PLANES[p]
|
const chanOff = TEXT_PLANES[p]
|
||||||
for (let vr = 0; vr < PTNVIEW_HEIGHT; vr++) {
|
for (let vr = 0; vr < PTNVIEW_HEIGHT; vr++) {
|
||||||
const rowBase = GPU_MEM - chanOff - (PTNVIEW_OFFSET_Y + vr - 1) * SCRW
|
const idxBase = chanOff + (PTNVIEW_OFFSET_Y + vr - 1) * SCRW
|
||||||
sys.memcpy(rowBase - srcOff, SCRATCH_PTR, SALVAGE_HORIZ_LEN)
|
sys.memcpy(vaddr(idxBase + srcOff), SCRATCH_PTR, SALVAGE_HORIZ_LEN)
|
||||||
sys.memcpy(SCRATCH_PTR, rowBase - dstOff, SALVAGE_HORIZ_LEN)
|
sys.memcpy(SCRATCH_PTR, vaddr(idxBase + dstOff), SALVAGE_HORIZ_LEN)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2165,9 +2175,9 @@ function shiftOrdersAreaHorizontal(dVoice) {
|
|||||||
for (let p = 0; p < 3; p++) {
|
for (let p = 0; p < 3; p++) {
|
||||||
const chanOff = TEXT_PLANES[p]
|
const chanOff = TEXT_PLANES[p]
|
||||||
for (let vr = 0; vr < PTNVIEW_HEIGHT; vr++) {
|
for (let vr = 0; vr < PTNVIEW_HEIGHT; vr++) {
|
||||||
const rowBase = GPU_MEM - chanOff - (PTNVIEW_OFFSET_Y + vr - 1) * SCRW
|
const idxBase = chanOff + (PTNVIEW_OFFSET_Y + vr - 1) * SCRW
|
||||||
sys.memcpy(rowBase - srcOff, SCRATCH_PTR, stripWidth)
|
sys.memcpy(vaddr(idxBase + srcOff), SCRATCH_PTR, stripWidth)
|
||||||
sys.memcpy(SCRATCH_PTR, rowBase - dstOff, stripWidth)
|
sys.memcpy(SCRATCH_PTR, vaddr(idxBase + dstOff), stripWidth)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2915,7 +2925,66 @@ const PROJ_META_ROW_GVOL = 7
|
|||||||
const PROJ_META_ROW_MVOL = 8
|
const PROJ_META_ROW_MVOL = 8
|
||||||
const PROJ_META_VALUE_X = 12
|
const PROJ_META_VALUE_X = 12
|
||||||
|
|
||||||
|
const SLIDER_TW_SMALL = 25
|
||||||
|
const SLIDER_TW_WIDE = 36
|
||||||
|
|
||||||
|
// GlobalVol / MixingVol get the instrument-tab treatment: an editable HEX capsule
|
||||||
|
// (click or Enter → openInlineHexEdit), a visual-only decimal, and a 0..255 slider.
|
||||||
|
const PROJ_VOL_CAP_X = PROJ_META_VALUE_X // hex capsule [▌$FF▐] left-cap col
|
||||||
|
const PROJ_VOL_CAP_W = 5
|
||||||
|
const PROJ_VOL_DEC_X = PROJ_VOL_CAP_X + 6 // visual-only decimal
|
||||||
|
const PROJ_VOL_SLIDER_SX = PROJ_VOL_DEC_X + 8 // slider left-pad col
|
||||||
|
const PROJ_VOL_SLIDER_TW = SLIDER_TW_SMALL//SCRW - 2 - (PROJ_VOL_SLIDER_SX + 1) // trough ends ~2 cols from the edge
|
||||||
|
|
||||||
|
// Rebuilt by drawProjectContents; hit-tested by registerProjectMouse.
|
||||||
|
let projSliders = []
|
||||||
|
|
||||||
|
// Render one volume row (key + hex capsule + decimal + knob) and register its
|
||||||
|
// slider entry. `commit(v)` applies the new value; `metaCursor` is the keyboard
|
||||||
|
// cursor value for the row so a mouse click can sync the selection.
|
||||||
|
function drawProjVolRow(y, selected, key, val0, commit, metaCursor) {
|
||||||
|
const sx = PROJ_VOL_SLIDER_SX, tw = PROJ_VOL_SLIDER_TW
|
||||||
|
const render = (v) => {
|
||||||
|
con.move(y, 2)
|
||||||
|
con.color_pair(selected ? colWHITE : colStatus, selected ? colHighlight : 255)
|
||||||
|
print(key)
|
||||||
|
drawNumCapsule(y, PROJ_VOL_CAP_X, 3, '$' + v.hex02()) // editable hex
|
||||||
|
con.move(y, PROJ_VOL_DEC_X); con.color_pair(colVoiceHdr, colBackPtn)
|
||||||
|
const decW = PROJ_VOL_SLIDER_SX - PROJ_VOL_DEC_X
|
||||||
|
print(('(' + v + ')' + ' '.repeat(decW)).substring(0, decW)) // visual-only decimal
|
||||||
|
drawSlider(y, sx, tw, v / 255)
|
||||||
|
}
|
||||||
|
render(val0)
|
||||||
|
const entry = {
|
||||||
|
y, sx, tw, troughLeftPx: sx * CELL_PW, min: 0, max: 255,
|
||||||
|
numY: y, numX: PROJ_VOL_CAP_X, numW: PROJ_VOL_CAP_W,
|
||||||
|
val: val0, render, commit, repaint: redrawPanel, metaCursor
|
||||||
|
}
|
||||||
|
entry.editHex = () => {
|
||||||
|
const nv = openInlineHexEdit(y, PROJ_VOL_CAP_X, 2, entry.val)
|
||||||
|
if (nv !== null) { entry.val = nv & 0xFF; commit(entry.val) }
|
||||||
|
redrawPanel()
|
||||||
|
}
|
||||||
|
projSliders.push(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
function projTroughAt(cy, cx) {
|
||||||
|
for (let i = 0; i < projSliders.length; i++) {
|
||||||
|
const s = projSliders[i]
|
||||||
|
if (cy === s.y && cx >= s.sx && cx <= s.sx + s.tw + 1) return s
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
function projCapsuleAt(cy, cx) {
|
||||||
|
for (let i = 0; i < projSliders.length; i++) {
|
||||||
|
const s = projSliders[i]
|
||||||
|
if (cy === s.numY && cx >= s.numX && cx < s.numX + s.numW) return s
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function drawProjectContents(wo) {
|
function drawProjectContents(wo) {
|
||||||
|
projSliders.length = 0
|
||||||
fillLine(PTNVIEW_OFFSET_Y - 1, colVoiceHdr, 255)
|
fillLine(PTNVIEW_OFFSET_Y - 1, colVoiceHdr, 255)
|
||||||
for (let y = PTNVIEW_OFFSET_Y; y < SCRH; y++) fillLine(y, colBackPtn, 255)
|
for (let y = PTNVIEW_OFFSET_Y; y < SCRH; y++) fillLine(y, colBackPtn, 255)
|
||||||
|
|
||||||
@@ -2946,9 +3015,22 @@ function drawProjectContents(wo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Object.entries(projMeta).forEach(([key, value], index) => {
|
Object.entries(projMeta).forEach(([key, value], index) => {
|
||||||
con.move(PTNVIEW_OFFSET_Y + index, 2)
|
const rowY = PTNVIEW_OFFSET_Y + index
|
||||||
|
if (index === PROJ_META_ROW_GVOL) {
|
||||||
|
drawProjVolRow(rowY, projectCursor === PROJ_META_GVOL, key, initialGlobalVolume, (v) => {
|
||||||
|
initialGlobalVolume = v & 0xFF; audio.setSongGlobalVolume(PLAYHEAD, initialGlobalVolume); hasUnsavedChanges = true
|
||||||
|
}, PROJ_META_GVOL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (index === PROJ_META_ROW_MVOL) {
|
||||||
|
drawProjVolRow(rowY, projectCursor === PROJ_META_MVOL, key, initialMixingVolume, (v) => {
|
||||||
|
initialMixingVolume = v & 0xFF; audio.setSongMixingVolume(PLAYHEAD, initialMixingVolume); hasUnsavedChanges = true
|
||||||
|
}, PROJ_META_MVOL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
con.move(rowY, 2)
|
||||||
con.color_pair(colStatus, 255); print(key)
|
con.color_pair(colStatus, 255); print(key)
|
||||||
con.move(PTNVIEW_OFFSET_Y + index, PROJ_META_VALUE_X)
|
con.move(rowY, PROJ_META_VALUE_X)
|
||||||
const isEditable = (index in editableMap)
|
const isEditable = (index in editableMap)
|
||||||
const isSelected = isEditable && projectCursor === editableMap[index]
|
const isSelected = isEditable && projectCursor === editableMap[index]
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
@@ -3271,7 +3353,7 @@ function drawSamplesListColumn() {
|
|||||||
const indPos = (maxScroll === 0) ? 0 : ((smpListScroll * (SMP_LIST_H - 1) / maxScroll) | 0)
|
const indPos = (maxScroll === 0) ? 0 : ((smpListScroll * (SMP_LIST_H - 1) / maxScroll) | 0)
|
||||||
for (let r = 0; r < SMP_LIST_H; r++) {
|
for (let r = 0; r < SMP_LIST_H; r++) {
|
||||||
con.move(SMP_LIST_Y + r, SMP_LIST_SCROLL_X)
|
con.move(SMP_LIST_Y + r, SMP_LIST_SCROLL_X)
|
||||||
con.color_pair(colStatus, colSmpListBg)
|
con.color_pair(colScrollBar, colSmpListBg)
|
||||||
|
|
||||||
let scrollChar = (r == 0) ? sym.taut_scrollgutter_top : (r == SMP_LIST_H - 1) ? sym.taut_scrollgutter_bot : sym.taut_scrollgutter_mid
|
let scrollChar = (r == 0) ? sym.taut_scrollgutter_top : (r == SMP_LIST_H - 1) ? sym.taut_scrollgutter_bot : sym.taut_scrollgutter_mid
|
||||||
if (r == indPos) scrollChar += 3;
|
if (r == indPos) scrollChar += 3;
|
||||||
@@ -3888,7 +3970,7 @@ function drawInstrumentsListColumn() {
|
|||||||
const indPos = (maxScroll === 0) ? 0 : ((instListScroll * (INST_LIST_H - 1) / maxScroll) | 0)
|
const indPos = (maxScroll === 0) ? 0 : ((instListScroll * (INST_LIST_H - 1) / maxScroll) | 0)
|
||||||
for (let r = 0; r < INST_LIST_H; r++) {
|
for (let r = 0; r < INST_LIST_H; r++) {
|
||||||
con.move(INST_LIST_Y + r, INST_LIST_SCROLL_X)
|
con.move(INST_LIST_Y + r, INST_LIST_SCROLL_X)
|
||||||
con.color_pair(colStatus, colInstListBg)
|
con.color_pair(colScrollBar, colInstListBg)
|
||||||
|
|
||||||
let scrollChar = (r == 0) ? sym.taut_scrollgutter_top : (r == INST_LIST_H - 1) ? sym.taut_scrollgutter_bot : sym.taut_scrollgutter_mid
|
let scrollChar = (r == 0) ? sym.taut_scrollgutter_top : (r == INST_LIST_H - 1) ? sym.taut_scrollgutter_bot : sym.taut_scrollgutter_mid
|
||||||
if (r == indPos) scrollChar += 3;
|
if (r == indPos) scrollChar += 3;
|
||||||
@@ -3966,13 +4048,16 @@ function _signed(n) { return (n >= 0 ? '+' : '') + n }
|
|||||||
function loopModeNameInst(flags) {
|
function loopModeNameInst(flags) {
|
||||||
const lp = flags & 3
|
const lp = flags & 3
|
||||||
const sus = (flags >>> 2) & 1
|
const sus = (flags >>> 2) & 1
|
||||||
const names = ['none', 'forward', 'pingpong', 'oneshot']
|
const names = ['None', 'Forward', 'Pingpong', 'Oneshot']
|
||||||
return names[lp] + (sus ? ' (sustain)' : '')
|
return names[lp] + (sus ? ' (sustain)' : '')
|
||||||
}
|
}
|
||||||
const NNA_NAMES = ['Cut', 'Off', 'Continue', 'Fade']
|
// Clickable button-group option lists. NNA/DCT use every value; DCA's 4th slot
|
||||||
const DCT_NAMES = ['off', 'note', 'sample', 'instrument']
|
// is reserved (dropped); vibrato exposes the 5 engine-supported waves
|
||||||
const DCA_NAMES = ['Cut', 'Off', 'Fade', 'reserved']
|
// (sine/ramp-dn/square/random/ramp-up — see AudioAdapter.advanceAutoVibrato).
|
||||||
const VIB_WF_NAMES = ['sine', 'ramp-dn', 'square', 'random', 'ramp-up', '?', '?', '?']
|
const NNA_NAMES = ['Cut', 'Off', 'Continue', 'Fade']
|
||||||
|
const DCT_NAMES = ['Off', 'Note', 'Sample', 'Inst.']
|
||||||
|
const DCA_OPTIONS = ['Cut', 'Off', 'Fade']
|
||||||
|
const VIB_WF_OPTIONS = ['\u00D8\u00D9', '\u00A5\u00A6', '\u00B4\u00B4', '\u00F3\u00F3', '\u00B5\u00B6']//['Sine', 'Ramp-dn', 'Square', 'Random', 'Ramp-up']
|
||||||
|
|
||||||
// Place a value at column INST_RIGHT_X + labelW. Labels are colour
|
// Place a value at column INST_RIGHT_X + labelW. Labels are colour
|
||||||
// colInstLabel; values are colInstValue. Truncates to fit INST_RIGHT_W.
|
// colInstLabel; values are colInstValue. Truncates to fit INST_RIGHT_W.
|
||||||
@@ -3990,11 +4075,307 @@ function drawLabelRow(y, label, value, labelW) {
|
|||||||
function drawGroupHeader(y, title) {
|
function drawGroupHeader(y, title) {
|
||||||
con.move(y, INST_RIGHT_X)
|
con.move(y, INST_RIGHT_X)
|
||||||
con.color_pair(colInstGroupHdr, colBackPtn)
|
con.color_pair(colInstGroupHdr, colBackPtn)
|
||||||
const txt = title + ' '
|
const txt = '\u00FB\u00FB ' + title + ' '
|
||||||
const dashes = Math.max(0, INST_RIGHT_W - txt.length)
|
const dashes = Math.max(0, INST_RIGHT_W - txt.length)
|
||||||
print(txt + `\u00FB`.repeat(dashes))
|
print(txt + `\u00FB`.repeat(dashes))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Inline value sliders (Gen.1 / Gen.2 knob editing) ──────────────────────
|
||||||
|
// A horizontal slider painted alongside a numeric field. The knob is one 7-px
|
||||||
|
// cell wide and slides with per-pixel precision via the sym.slider1..7 glyphs
|
||||||
|
// (slider1 = knob snug in one cell; slider2..7 straddle two cells at a 1..6 px
|
||||||
|
// offset). The trough is a flat colBLACK bar capped by inverse-video round pads
|
||||||
|
// (0xAB left, 0xAA right). Two trough widths only: small (10) and wide (20).
|
||||||
|
//
|
||||||
|
// Clicking/dragging a trough drives the knob: the label updates live as the knob
|
||||||
|
// moves, and the instrument byte(s) are written only on mouse release (see
|
||||||
|
// runSliderDrag). instSliders is rebuilt on every Gen.1/Gen.2 body repaint and
|
||||||
|
// hit-tested by the panel's slider mouse region.
|
||||||
|
const SLIDER_LABEL_W = 10
|
||||||
|
const SLIDER_END_COL = SCRW - 1 // common right edge
|
||||||
|
const SLIDER_SMALL_SX = SLIDER_END_COL - (SLIDER_TW_SMALL + 1) // small left-pad col
|
||||||
|
const SLIDER_WIDE_SX = SLIDER_END_COL - (SLIDER_TW_WIDE + 1) // wide left-pad col
|
||||||
|
const SLIDER_VALUE_W = SLIDER_SMALL_SX - (INST_RIGHT_X + SLIDER_LABEL_W)
|
||||||
|
const SLIDER_NUM_X = INST_RIGHT_X + SLIDER_LABEL_W // editable raw-number capsule (left-cap col)
|
||||||
|
|
||||||
|
const sliderGlyphs = [sym.slider1, sym.slider2, sym.slider3, sym.slider4,
|
||||||
|
sym.slider5, sym.slider6, sym.slider7]
|
||||||
|
|
||||||
|
// Rebuilt by drawInstTabGeneral1/2; each entry is
|
||||||
|
// { y, sx, tw, troughLeftPx, min, max, render(val), commit(val) }.
|
||||||
|
let instSliders = []
|
||||||
|
|
||||||
|
// Rebuilt by drawInstTabGeneral2 (radio button groups) and the envelope tabs
|
||||||
|
// (checkboxes); hit-tested by the panel body mouse region. Cleared every redraw,
|
||||||
|
// so they only ever hold the currently-shown tab's widgets.
|
||||||
|
// instButtons: { y, x, w, value, commit(value) }
|
||||||
|
// instCheckboxes: { y, xs, xe, off, bit } (off = instrument byte, bit index)
|
||||||
|
let instButtons = []
|
||||||
|
let instCheckboxes = []
|
||||||
|
|
||||||
|
// Paint the trough + knob for value-fraction `frac` (0..1) at (y, sx).
|
||||||
|
function drawSlider(y, sx, tw, frac) {
|
||||||
|
const pmax = (tw - 1) * CELL_PW
|
||||||
|
const p = Math.round((frac < 0 ? 0 : frac > 1 ? 1 : frac) * pmax)
|
||||||
|
const cell = (p / CELL_PW) | 0
|
||||||
|
const sub = p - cell * CELL_PW
|
||||||
|
const cells = new Array(tw).fill(' ')
|
||||||
|
if (sub === 0) cells[cell] = sliderGlyphs[0]
|
||||||
|
else {
|
||||||
|
const g = sliderGlyphs[sub] // 2-char glyph straddling cell..cell+1
|
||||||
|
cells[cell] = g[0]
|
||||||
|
if (cell + 1 < tw) cells[cell + 1] = g[1]
|
||||||
|
}
|
||||||
|
con.color_pair(colBLACK, colStatus); con.move(y, sx); con.prnch(0xAB)
|
||||||
|
con.color_pair(colStatus, colBLACK); con.move(y, sx + 1); print(cells.join(''))
|
||||||
|
con.color_pair(colBLACK, colStatus); con.move(y, sx + tw + 1); con.prnch(0xAA)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pixel X (mouse) → quantised slider value, knob centred under the cursor.
|
||||||
|
function sliderMouseToVal(s, pxX) {
|
||||||
|
const pmax = (s.tw - 1) * CELL_PW
|
||||||
|
let knob = Math.round((pxX - s.troughLeftPx) - CELL_PW / 2)
|
||||||
|
if (knob < 0) knob = 0
|
||||||
|
if (knob > pmax) knob = pmax
|
||||||
|
const frac = (pmax === 0) ? 0 : knob / pmax
|
||||||
|
let v = Math.round(s.min + frac * (s.max - s.min))
|
||||||
|
if (v < s.min) v = s.min
|
||||||
|
if (v > s.max) v = s.max
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write byte pairs [[offset, value], ...] into instrument `slot`'s peripheral
|
||||||
|
// record. The audio adapter decodes these live, so edits take effect at once.
|
||||||
|
function instWriteBytes(slot, pairs) {
|
||||||
|
const memBase = audio.getMemAddr()
|
||||||
|
const base = TAUT_INST_WINDOW_OFF + slot * TAUT_INST_RECORD_SIZE
|
||||||
|
for (let i = 0; i < pairs.length; i++) {
|
||||||
|
sys.poke(memBase - (base + pairs[i][0]), pairs[i][1] & 0xFF)
|
||||||
|
}
|
||||||
|
hasUnsavedChanges = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag interaction: live label updates while held, commit on release, ESC cancels.
|
||||||
|
function runSliderDrag(s, downEvent) {
|
||||||
|
let val = sliderMouseToVal(s, downEvent[1])
|
||||||
|
let committed = false
|
||||||
|
s.render(val)
|
||||||
|
let dragging = true
|
||||||
|
while (dragging) {
|
||||||
|
input.withEvent(e => {
|
||||||
|
const t = e[0]
|
||||||
|
if (t === 'mouse_move') {
|
||||||
|
const nv = sliderMouseToVal(s, e[1])
|
||||||
|
if (nv !== val) { val = nv; s.render(val) }
|
||||||
|
} else if (t === 'mouse_up') {
|
||||||
|
dragging = false; committed = true
|
||||||
|
} else if (t === 'key_down' && e[1] === '<ESC>') {
|
||||||
|
dragging = false
|
||||||
|
}
|
||||||
|
// mouse_down echo and other events are ignored during a drag
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (committed) s.commit(val)
|
||||||
|
if (s.repaint) s.repaint(); else drawInstrumentsContents()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Annotation helpers — short context shown next to the raw-number capsule
|
||||||
|
// (the capsule itself already shows the decimal value). Kept terse for the
|
||||||
|
// narrow value field.
|
||||||
|
function annHex(v) { return '$' + _hex(v, 2) }
|
||||||
|
function annFilter(v) { return (v === 0xFF) ? 'off' : '$' + _hex(v, 2) }
|
||||||
|
function annFadeout(v) {
|
||||||
|
if (v <= 0) return 'none'
|
||||||
|
if (v >= 1024) return 'cut'
|
||||||
|
return '~' + Math.round(1024 / v) + 't'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw an editable raw-number field: a black (col 240) capsule with CP437
|
||||||
|
// half-block end caps (0xDD left, 0xDE right). The black-bg + cap scheme marks
|
||||||
|
// the field as "type a number here". `x` is the left-cap column; `digits` number
|
||||||
|
// cells follow (left-aligned, space-padded), then the right cap.
|
||||||
|
function drawNumCapsule(y, x, digits, numStr) {
|
||||||
|
con.color_pair(colBackPtn, colBLACK); con.move(y, x); con.prnch(0xDD)
|
||||||
|
con.color_pair(colInstValue, colBLACK); con.move(y, x + 1)
|
||||||
|
print((numStr + ' '.repeat(digits)).substring(0, digits))
|
||||||
|
con.color_pair(colBackPtn, colBLACK); con.move(y, x + 1 + digits); con.prnch(0xDE)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit a small-slider row: label, editable raw-number capsule, annotation, knob.
|
||||||
|
// `ann(val)` returns the short annotation (or null); `encode(val)` returns the
|
||||||
|
// byte pairs to poke on commit.
|
||||||
|
function sliderRow(y, e, label, val0, min, max, ann, encode) {
|
||||||
|
const sx = SLIDER_SMALL_SX, tw = SLIDER_TW_SMALL
|
||||||
|
const digits = Math.max(String(min).length, String(max).length)
|
||||||
|
const nx = SLIDER_NUM_X, nw = digits + 2
|
||||||
|
const annX = nx + nw, annW = sx - annX // fill up to the slider's left pad
|
||||||
|
const render = (val) => {
|
||||||
|
const knob = (val < min) ? min : (val > max) ? max : val // clamp position only
|
||||||
|
con.move(y, INST_RIGHT_X)
|
||||||
|
con.color_pair(colInstLabel, colBackPtn)
|
||||||
|
print((label + ' '.repeat(SLIDER_LABEL_W)).substring(0, SLIDER_LABEL_W))
|
||||||
|
drawNumCapsule(y, nx, digits, String(val))
|
||||||
|
con.move(y, annX); con.color_pair(colInstValue, colBackPtn)
|
||||||
|
const a = ann ? (' ' + ann(val)) : ''
|
||||||
|
print((a + ' '.repeat(annW)).substring(0, annW))
|
||||||
|
drawSlider(y, sx, tw, (max === min) ? 0 : (knob - min) / (max - min))
|
||||||
|
}
|
||||||
|
render(val0)
|
||||||
|
instSliders.push({
|
||||||
|
y, sx, tw, troughLeftPx: sx * CELL_PW, min, max, render,
|
||||||
|
numY: y, numX: nx, numW: nw, ndig: digits, // raw-number capsule geometry
|
||||||
|
val: val0, // base for wheel ±1 / edit prefill (clamped on use)
|
||||||
|
commit: (v) => { instWriteBytes(e.slot, encode(v)); e.decoded = decodeInstFull(readInstRecord(e.slot)) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit the wide two-row Detune slider: knob on `y`, cents readout on `y+1`.
|
||||||
|
// The field is a full signed 16-bit, but the knob's interactive range is the
|
||||||
|
// practical ±4096 (one octave). An out-of-range stored value still displays
|
||||||
|
// truthfully (its true number + cents), with the knob pinned to the nearer end;
|
||||||
|
// it is snapped into range the instant the user drags or wheels the knob.
|
||||||
|
function detuneRow(y, e, val0) {
|
||||||
|
const sx = SLIDER_WIDE_SX, tw = SLIDER_TW_WIDE
|
||||||
|
const min = -4096, max = 4096
|
||||||
|
const digits = 6 // fits a full signed 16-bit display
|
||||||
|
const nx = INST_RIGHT_X + 4, nw = digits + 2
|
||||||
|
const render = (val) => {
|
||||||
|
const knob = (val < min) ? min : (val > max) ? max : val // clamp position only
|
||||||
|
con.move(y, INST_RIGHT_X)
|
||||||
|
con.color_pair(colInstLabel, colBackPtn)
|
||||||
|
print((' Detune:' + ' '.repeat(20)).substring(0, sx - INST_RIGHT_X))
|
||||||
|
drawSlider(y, sx, tw, (knob - min) / (max - min))
|
||||||
|
// Readout row: editable raw-number capsule + cents.
|
||||||
|
con.move(y + 1, INST_RIGHT_X); con.color_pair(colInstValue, colBackPtn); print(' ')
|
||||||
|
drawNumCapsule(y + 1, nx, digits, String(val))
|
||||||
|
const cents = val * 1200 / 4096 // 1 octave = 4096 TET steps = 1200 cents
|
||||||
|
con.move(y + 1, nx + nw); con.color_pair(colInstValue, colBackPtn)
|
||||||
|
const s = ' (' + cents.toFixed(1) + ' cents, 4096-TET)'
|
||||||
|
print((s + ' '.repeat(INST_RIGHT_W)).substring(0, SCRW - (nx + nw) + 1))
|
||||||
|
}
|
||||||
|
render(val0)
|
||||||
|
instSliders.push({
|
||||||
|
y, sx, tw, troughLeftPx: sx * CELL_PW, min, max, render,
|
||||||
|
numY: y + 1, numX: nx, numW: nw, ndig: digits, // capsule on the readout row
|
||||||
|
val: val0, // true value; snapped into range on interact
|
||||||
|
commit: (v) => { instWriteBytes(e.slot, [[184, v & 0xFF], [185, (v >> 8) & 0xFF]]); e.decoded = decodeInstFull(readInstRecord(e.slot)) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hit-test the live instSliders list (Gen.1/Gen.2 only). Separate tests for the
|
||||||
|
// knob trough (drag / wheel) and the raw-number capsule (click-to-edit / wheel).
|
||||||
|
function sliderTroughAt(cy, cx) {
|
||||||
|
if (instSubTab !== INST_TAB_GEN1 && instSubTab !== INST_TAB_GEN2) return null
|
||||||
|
for (let i = 0; i < instSliders.length; i++) {
|
||||||
|
const s = instSliders[i]
|
||||||
|
if (cy === s.y && cx >= s.sx && cx <= s.sx + s.tw + 1) return s
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
function sliderCapsuleAt(cy, cx) {
|
||||||
|
if (instSubTab !== INST_TAB_GEN1 && instSubTab !== INST_TAB_GEN2) return null
|
||||||
|
for (let i = 0; i < instSliders.length; i++) {
|
||||||
|
const s = instSliders[i]
|
||||||
|
if (cy === s.numY && cx >= s.numX && cx < s.numX + s.numW) return s
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hit-test the live instButtons / instCheckboxes lists. Rebuilt every body
|
||||||
|
// redraw, so they only hold the current tab's widgets — no subtab gate needed.
|
||||||
|
function instButtonAt(cy, cx) {
|
||||||
|
for (let i = 0; i < instButtons.length; i++) {
|
||||||
|
const b = instButtons[i]
|
||||||
|
if (cy === b.y && cx >= b.x && cx < b.x + b.w) return b
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
function instCheckboxAt(cy, cx) {
|
||||||
|
for (let i = 0; i < instCheckboxes.length; i++) {
|
||||||
|
const c = instCheckboxes[i]
|
||||||
|
if (cy === c.y && cx >= c.xs && cx <= c.xe) return c
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the inline number editor over a slider's capsule; commit clamps to range.
|
||||||
|
function editSliderNumber(s) {
|
||||||
|
const nv = openInlineNumEdit(s.numY, s.numX + 1, s.ndig, s.val, s.min, s.max)
|
||||||
|
if (nv !== null) { s.val = nv; s.commit(nv) }
|
||||||
|
drawInstrumentsContents() // repaint (restores capsule styling; reflects new value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pill buttons & checkboxes (instrument property toggles) ─────────────────
|
||||||
|
// Reuse the input-field "capsule" look (drawNumCapsule) as a tappable control: a
|
||||||
|
// pill with CP437 half-block end caps that blend the fill colour into the panel
|
||||||
|
// background. Unselected = black fill / white text; selected = white fill / black
|
||||||
|
// text. Used as radio-style enum pickers (NNA/DCT/DCA/vibrato wave) and, in
|
||||||
|
// checkbox form, for the envelope boolean flags.
|
||||||
|
|
||||||
|
// Read-modify-write a `width`-bit field at `shift` of instrument byte `off`,
|
||||||
|
// preserving the surrounding bits. Re-reads first so a concurrent engine write
|
||||||
|
// isn't clobbered, then refreshes the decoded cache.
|
||||||
|
function instWriteField(e, off, shift, width, v) {
|
||||||
|
const mask = ((1 << width) - 1) << shift
|
||||||
|
const rec = readInstRecord(e.slot)
|
||||||
|
const nb = (rec[off] & ~mask) | ((v << shift) & mask)
|
||||||
|
instWriteBytes(e.slot, [[off, nb]])
|
||||||
|
e.decoded = decodeInstFull(readInstRecord(e.slot))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flip a single bit of instrument byte `off` (checkbox click).
|
||||||
|
function toggleInstBit(e, off, bit) {
|
||||||
|
const rec = readInstRecord(e.slot)
|
||||||
|
instWriteBytes(e.slot, [[off, rec[off] ^ (1 << bit)]])
|
||||||
|
e.decoded = decodeInstFull(readInstRecord(e.slot))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw one pill button at (y, x). Cap scheme mirrors drawNumCapsule so it reads
|
||||||
|
// as the same "interactive field" affordance. Returns the pill's total width
|
||||||
|
// (2 caps + a 1-space-padded label).
|
||||||
|
function drawButton(y, x, label, selected) {
|
||||||
|
const fill = selected ? colWHITE : colBLACK
|
||||||
|
const txt = selected ? colBLACK : colInstValue
|
||||||
|
const inner = ' ' + label + ' '
|
||||||
|
con.color_pair(colBackPtn, fill); con.move(y, x); con.prnch(0xDD)
|
||||||
|
con.color_pair(txt, fill); con.move(y, x + 1); print(inner)
|
||||||
|
con.color_pair(colBackPtn, fill); con.move(y, x + 1 + inner.length); con.prnch(0xDE)
|
||||||
|
return inner.length + 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit a labelled radio-button group: a label, then one pill per option (the
|
||||||
|
// active one selected). Pills wrap to the next row when they would overrun the
|
||||||
|
// right pane (vibrato's 5 waves need this). Each pill is registered into
|
||||||
|
// instButtons with commit(optionIndex). Returns the number of rows consumed.
|
||||||
|
const BTN_GROUP_LABEL_W = 8
|
||||||
|
function buttonGroupRow(y, label, options, current, commit) {
|
||||||
|
con.move(y, INST_RIGHT_X); con.color_pair(colInstLabel, colBackPtn)
|
||||||
|
print((label + ' '.repeat(BTN_GROUP_LABEL_W)).substring(0, BTN_GROUP_LABEL_W))
|
||||||
|
const x0 = INST_RIGHT_X + BTN_GROUP_LABEL_W
|
||||||
|
let x = x0, rows = 1
|
||||||
|
for (let i = 0; i < options.length; i++) {
|
||||||
|
const w = options[i].length + 4 // ' ' + label + ' ' + 2 caps
|
||||||
|
if (x !== x0 && x + w - 1 > SCRW) { y++; rows++; x = x0 } // wrap to next row
|
||||||
|
drawButton(y, x, options[i], i === current)
|
||||||
|
instButtons.push({ y, x, w, value: i, commit })
|
||||||
|
x += w + 1 // 1-col gap between pills
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw "label<glyph>" (glyph at column x+labelW) and register the label+glyph
|
||||||
|
// span as a clickable toggle of byte `off` bit `bit`. Returns the column just
|
||||||
|
// past the glyph, so callers can append trailing text there.
|
||||||
|
function drawCheckbox(y, x, label, labelW, checked, off, bit) {
|
||||||
|
con.move(y, x); con.color_pair(colInstLabel, colBackPtn)
|
||||||
|
print((label + ' '.repeat(labelW)).substring(0, labelW))
|
||||||
|
const gx = x + labelW
|
||||||
|
con.move(y, gx); con.color_pair(colInstValue, colBackPtn)
|
||||||
|
print(checked ? sym.ticked : sym.unticked)
|
||||||
|
instCheckboxes.push({ y, xs: x, xe: gx, off, bit })
|
||||||
|
return gx + 1
|
||||||
|
}
|
||||||
|
|
||||||
// ── Tab body: General (page 1 + page 2) ───────────────────────────────────
|
// ── Tab body: General (page 1 + page 2) ───────────────────────────────────
|
||||||
// Page 1 (Gen.1):
|
// Page 1 (Gen.1):
|
||||||
// Sample binding — sample link, length, c4Rate, play/loop positions, loop mode
|
// Sample binding — sample link, length, c4Rate, play/loop positions, loop mode
|
||||||
@@ -4041,25 +4422,25 @@ function drawInstTabGeneral1(e) {
|
|||||||
|
|
||||||
y++
|
y++
|
||||||
drawGroupHeader(y++, 'Volume')
|
drawGroupHeader(y++, 'Volume')
|
||||||
drawLabelRow(y++, ' Inst. GV:', _hex(d.igv, 2) + ' (' + d.igv + '/255)')
|
sliderRow(y++, e, ' Inst.GV:', d.igv, 0, 255, annHex, (v) => [[171, v]])
|
||||||
drawLabelRow(y++, ' DefNote:', _hex(d.defNoteVol, 2) + ' (' + d.defNoteVol + '/255' +
|
sliderRow(y++, e, ' DefNote:', d.defNoteVol, 0, 255, annHex, (v) => [[196, v]])
|
||||||
(d.defNoteVol === 0 ? ' legacy: row default 63' : '') + ')')
|
sliderRow(y++, e, ' Fadeout:', d.fadeout, 0, 1024, annFadeout, (v) => [[172, v & 0xFF], [173, (v >> 8) & 0x0F]])
|
||||||
let fadeStr
|
sliderRow(y++, e, ' Swing:', d.volSwing, 0, 255, annHex, (v) => [[174, v]])
|
||||||
if (d.fadeout === 0) fadeStr = '0 (no fade)'
|
|
||||||
else if (d.fadeout >= 1024) fadeStr = d.fadeout + ' (1-tick cut)'
|
|
||||||
else {
|
|
||||||
const ticks = (1024 / d.fadeout)
|
|
||||||
fadeStr = d.fadeout + ' (~' + ticks.toFixed(1) + ' ticks)'
|
|
||||||
}
|
|
||||||
drawLabelRow(y++, ' Fadeout:', fadeStr)
|
|
||||||
drawLabelRow(y++, ' Swing:', _hex(d.volSwing, 2))
|
|
||||||
|
|
||||||
y++
|
y++
|
||||||
drawGroupHeader(y++, 'Panning')
|
drawGroupHeader(y++, 'Panning')
|
||||||
drawLabelRow(y++, ' Default:', _hex(d.defPan, 2) + ' Use: ' +
|
sliderRow(y++, e, ' Default:', d.defPan, 0, 255, annHex, (v) => [[177, v]])
|
||||||
(d.panEnv.panUseDef ? sym.ticked + ' on' : sym.unticked + ' off'))
|
sliderRow(y++, e, ' Sep:', d.pitchPanSep, -128, 127, null, (v) => [[180, v & 0xFF]])
|
||||||
drawLabelRow(y++, ' PPanCtr:', '$' + _hex(d.pitchPanCenter, 4) + ' Sep: ' + _signed(d.pitchPanSep))
|
sliderRow(y++, e, ' Swing:', d.panSwing, 0, 255, annHex, (v) => [[181, v]])
|
||||||
drawLabelRow(y++, ' Swing:', _hex(d.panSwing, 2))
|
con.move(y, INST_RIGHT_X); con.color_pair(colInstLabel, colBackPtn)
|
||||||
|
print((' PPanCnt:' + ' '.repeat(12)).substring(0, 12))
|
||||||
|
con.move(y, INST_RIGHT_X + 12); con.color_pair(colInstValue, colBackPtn)
|
||||||
|
print('$' + _hex(d.pitchPanCenter, 4) + ' ')
|
||||||
|
// "Use default pan" mirrors the Pan tab's UseDef checkbox (pan loopWord bit 7).
|
||||||
|
const ppx = drawCheckbox(y, INST_RIGHT_X + 21, 'Use:', 5, d.panEnv.panUseDef, 17, 7)
|
||||||
|
con.move(y, ppx); con.color_pair(colInstValue, colBackPtn)
|
||||||
|
print(d.panEnv.panUseDef ? ' on' : ' off')
|
||||||
|
y++
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawInstTabGeneral2(e) {
|
function drawInstTabGeneral2(e) {
|
||||||
@@ -4067,27 +4448,30 @@ function drawInstTabGeneral2(e) {
|
|||||||
let y = INST_BODY_Y
|
let y = INST_BODY_Y
|
||||||
|
|
||||||
drawGroupHeader(y++, 'Filter')
|
drawGroupHeader(y++, 'Filter')
|
||||||
drawLabelRow(y++, ' Cutoff:', (d.defCutoff === 0xFF ? 'off' : ('$' + _hex(d.defCutoff, 2) +
|
sliderRow(y++, e, ' Cutoff:', d.defCutoff, 0, 255, annFilter, (v) => [[182, v]])
|
||||||
' (' + d.defCutoff + '/254)')))
|
sliderRow(y++, e, ' Reso:', d.defReso, 0, 255, annFilter, (v) => [[183, v]])
|
||||||
drawLabelRow(y++, ' Reso:', (d.defReso === 0xFF ? 'off' : ('$' + _hex(d.defReso, 2) +
|
|
||||||
' (' + d.defReso + '/254)')))
|
|
||||||
|
|
||||||
y++
|
y++
|
||||||
drawGroupHeader(y++, 'Vibrato')
|
drawGroupHeader(y++, 'Vibrato')
|
||||||
drawLabelRow(y++, ' Waveform:', VIB_WF_NAMES[d.vibWaveform & 7])
|
// Vibrato waveform — instFlag (byte 186) bits 2..4.
|
||||||
drawLabelRow(y++, ' Speed:', _hex(d.vibSpeed, 2) + ' Depth: ' + _hex(d.vibDepth, 2))
|
y += buttonGroupRow(y, ' Wave:', VIB_WF_OPTIONS, d.vibWaveform & 7,
|
||||||
drawLabelRow(y++, ' Sweep:', _hex(d.vibSweep, 2) + ' Rate: ' + _hex(d.vibRate, 2))
|
(v) => instWriteField(e, 186, 2, 3, v))
|
||||||
|
sliderRow(y++, e, ' Speed:', d.vibSpeed, 0, 255, annHex, (v) => [[175, v]])
|
||||||
|
sliderRow(y++, e, ' Depth:', d.vibDepth, 0, 255, annHex, (v) => [[187, v]])
|
||||||
|
sliderRow(y++, e, ' Sweep:', d.vibSweep, 0, 255, annHex, (v) => [[176, v]])
|
||||||
|
sliderRow(y++, e, ' Rate:', d.vibRate, 0, 255, annHex, (v) => [[188, v]])
|
||||||
|
|
||||||
y++
|
y++
|
||||||
drawGroupHeader(y++, 'Note actions')
|
drawGroupHeader(y++, 'Note actions')
|
||||||
drawLabelRow(y++, ' NNA:', NNA_NAMES[d.nna & 3])
|
// NNA — instFlag (byte 186) bits 0..1; DCT/DCA — dcByte (byte 195) bits 0..1 / 2..3.
|
||||||
drawLabelRow(y++, ' DCT:', DCT_NAMES[d.dct & 3])
|
y += buttonGroupRow(y, ' NNA:', NNA_NAMES, d.nna & 3, (v) => instWriteField(e, 186, 0, 2, v))
|
||||||
drawLabelRow(y++, ' DCA:', DCA_NAMES[d.dca & 3])
|
y += buttonGroupRow(y, ' DCT:', DCT_NAMES, d.dct & 3, (v) => instWriteField(e, 195, 0, 2, v))
|
||||||
|
y += buttonGroupRow(y, ' DCA:', DCA_OPTIONS, d.dca & 3, (v) => instWriteField(e, 195, 2, 2, v))
|
||||||
|
|
||||||
y++
|
y++
|
||||||
drawGroupHeader(y++, 'Tuning')
|
drawGroupHeader(y++, 'Tuning')
|
||||||
const detStr = _signed(d.detune) + ' (' + (d.detune / 0x1000).toFixed(3) + ' octave, 4096-TET)'
|
detuneRow(y, e, d.detune)
|
||||||
drawLabelRow(y++, ' Detune:', detStr)
|
y += 2
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Envelope rendering (shared by Volume/Panning/Pitch tabs) ───────────────
|
// ── Envelope rendering (shared by Volume/Panning/Pitch tabs) ───────────────
|
||||||
@@ -4243,27 +4627,58 @@ function drawEnvelopeGraph(env) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Common envelope-tab body: a few lines of summary text above the graph.
|
// Common envelope-tab body: a few lines of summary text above the graph, then
|
||||||
// `extra` is an array of additional [label, value] rows specific to this kind
|
// the envelope graph. `extraCb`, when given, is a per-kind extra checkbox
|
||||||
// (e.g. pan's "Use default pan" flag).
|
// descriptor { label, checked, onText, offText } (e.g. pan's "Use default pan").
|
||||||
function drawInstTabEnvelope(e, env, kindLabel, extra) {
|
// Present / Carry / Loop / Sustain (+ that extra flag) are clickable checkboxes
|
||||||
|
// wired to their backing bits. Bit map (see
|
||||||
|
// decodeEnvelope): loopWord = rec[loopOff] | rec[loopOff+1]<<8, so Present is
|
||||||
|
// high-byte bit 5 (loopWord bit 13); Carry/Loop/extra are loopOff bits 6/5/7;
|
||||||
|
// Sustain is sustOff bit 5.
|
||||||
|
function drawInstTabEnvelope(e, env, kindLabel, extraCb) {
|
||||||
let y = INST_BODY_Y
|
let y = INST_BODY_Y
|
||||||
|
const loopOff = (env.kind === 'vol') ? 15 : (env.kind === 'pan') ? 17 : 19
|
||||||
|
const sustOff = (env.kind === 'vol') ? 189 : (env.kind === 'pan') ? 191 : 193
|
||||||
|
|
||||||
drawGroupHeader(y++, kindLabel + ' envelope')
|
drawGroupHeader(y++, kindLabel + ' envelope')
|
||||||
drawLabelRow(y++, ' Present:', (env.present ? sym.ticked + ' yes (P=1)' : sym.unticked + ' no (P=0)'))
|
|
||||||
|
// Present (P bit) — loopWord bit 13 lives in the high byte (loopOff+1) bit 5.
|
||||||
|
let px = drawCheckbox(y, INST_RIGHT_X, ' Present:', 12, env.present, loopOff + 1, 5)
|
||||||
|
con.move(y, px); con.color_pair(colInstValue, colBackPtn)
|
||||||
|
print(env.present ? ' yes (P=1)' : ' no (P=0)')
|
||||||
|
y++
|
||||||
|
|
||||||
|
// Node count + Carry checkbox share one row so the text block stays ≤ 7 rows
|
||||||
|
// (the envelope graph below starts at INST_BODY_Y + 7).
|
||||||
const realCount = (env.terminatorIdx >= 0) ? (env.terminatorIdx + 1) : env.nodes.length
|
const realCount = (env.terminatorIdx >= 0) ? (env.terminatorIdx + 1) : env.nodes.length
|
||||||
drawLabelRow(y++, ' Nodes:', realCount + ' / 25' +
|
con.move(y, INST_RIGHT_X); con.color_pair(colInstLabel, colBackPtn)
|
||||||
(env.carry ? ' Carry: ' + sym.ticked : ' Carry: ' + sym.unticked))
|
print((' Nodes:' + ' '.repeat(12)).substring(0, 12))
|
||||||
drawLabelRow(y++, ' Loop:', env.loopEnable
|
con.move(y, INST_RIGHT_X + 12); con.color_pair(colInstValue, colBackPtn)
|
||||||
? (sym.ticked + ' [' + env.loopStart + '..' + env.loopEnd + ']')
|
print((realCount + ' / 25' + ' '.repeat(8)).substring(0, 8))
|
||||||
: (sym.unticked + ' off'))
|
drawCheckbox(y, INST_RIGHT_X + 21, 'Carry:', 7, env.carry, loopOff, 6)
|
||||||
drawLabelRow(y++, ' Sustain:', env.sustEnable
|
y++
|
||||||
? (sym.ticked + ' [' + env.sustStart + '..' + env.sustEnd + ']')
|
|
||||||
: (sym.unticked + ' off'))
|
// Loop enable (+ range when on)
|
||||||
if (extra) {
|
let lx = drawCheckbox(y, INST_RIGHT_X, ' Loop:', 12, env.loopEnable, loopOff, 5)
|
||||||
for (let i = 0; i < extra.length; i++) {
|
con.move(y, lx); con.color_pair(colInstValue, colBackPtn)
|
||||||
drawLabelRow(y++, ' ' + extra[i][0], extra[i][1])
|
print(env.loopEnable ? (' [' + env.loopStart + '..' + env.loopEnd + ']') : ' off')
|
||||||
}
|
y++
|
||||||
|
|
||||||
|
// Sustain enable (+ range when on)
|
||||||
|
let sx = drawCheckbox(y, INST_RIGHT_X, ' Sustain:', 12, env.sustEnable, sustOff, 5)
|
||||||
|
con.move(y, sx); con.color_pair(colInstValue, colBackPtn)
|
||||||
|
print(env.sustEnable ? (' [' + env.sustStart + '..' + env.sustEnd + ']') : ' off')
|
||||||
|
y++
|
||||||
|
|
||||||
|
// Per-kind extra flag (Pan: use-default-pan; Pitch: filter-vs-pitch mode) —
|
||||||
|
// both ride loopWord bit 7 (loopOff bit 7).
|
||||||
|
if (extraCb) {
|
||||||
|
let ex = drawCheckbox(y, INST_RIGHT_X, extraCb.label, 12, extraCb.checked, loopOff, 7)
|
||||||
|
con.move(y, ex); con.color_pair(colInstValue, colBackPtn)
|
||||||
|
print(' ' + (extraCb.checked ? extraCb.onText : extraCb.offText))
|
||||||
|
y++
|
||||||
}
|
}
|
||||||
|
|
||||||
// Total envelope length + the time-grid step the graph below uses, so the
|
// Total envelope length + the time-grid step the graph below uses, so the
|
||||||
// dashed vertical hairlines have a readable scale.
|
// dashed vertical hairlines have a readable scale.
|
||||||
const lastIdx = (env.terminatorIdx >= 0) ? env.terminatorIdx : (env.nodes.length - 1)
|
const lastIdx = (env.terminatorIdx >= 0) ? env.terminatorIdx : (env.nodes.length - 1)
|
||||||
@@ -4275,18 +4690,21 @@ function drawInstTabEnvelope(e, env, kindLabel, extra) {
|
|||||||
drawEnvelopeGraph(env)
|
drawEnvelopeGraph(env)
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawInstTabVolume(e) { drawInstTabEnvelope(e, e.decoded.volEnv, 'Volume') }
|
function drawInstTabVolume(e) { drawInstTabEnvelope(e, e.decoded.volEnv, 'Volume', null) }
|
||||||
function drawInstTabPanning(e) {
|
function drawInstTabPanning(e) {
|
||||||
drawInstTabEnvelope(e, e.decoded.panEnv, 'Panning', [
|
drawInstTabEnvelope(e, e.decoded.panEnv, 'Panning', {
|
||||||
['UseDef:', (e.decoded.panEnv.panUseDef ? sym.ticked + ' on' : sym.unticked + ' off') +
|
label: ' UseDef:', checked: e.decoded.panEnv.panUseDef,
|
||||||
' (chan-pan source: byte $B1)']
|
onText: 'on (chan-pan source: byte $B1)',
|
||||||
])
|
offText: 'off (chan-pan source: byte $B1)'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
function drawInstTabPitch(e) {
|
function drawInstTabPitch(e) {
|
||||||
const env = e.decoded.pfEnv
|
const env = e.decoded.pfEnv
|
||||||
drawInstTabEnvelope(e, env, env.pfFilter ? 'Filter' : 'Pitch', [
|
drawInstTabEnvelope(e, env, env.pfFilter ? 'Filter' : 'Pitch', {
|
||||||
['Mode:', env.pfFilter ? 'filter cutoff' : 'pitch']
|
label: ' Filter:', checked: env.pfFilter,
|
||||||
])
|
onText: 'on (envelope targets filter cutoff)',
|
||||||
|
offText: 'off (envelope targets pitch)'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Edit button (bottom row) ───────────────────────────────────────────────
|
// ── Edit button (bottom row) ───────────────────────────────────────────────
|
||||||
@@ -4308,6 +4726,9 @@ function clearInstrumentsPanel() {
|
|||||||
function drawInstrumentsContents(wo) {
|
function drawInstrumentsContents(wo) {
|
||||||
if (instrumentsCache === null) refreshInstrumentsCache()
|
if (instrumentsCache === null) refreshInstrumentsCache()
|
||||||
clampInstrumentsCursor()
|
clampInstrumentsCursor()
|
||||||
|
instSliders.length = 0 // rebuilt by the Gen.1/Gen.2 body drawers below
|
||||||
|
instButtons.length = 0 // rebuilt by Gen.2 button groups
|
||||||
|
instCheckboxes.length = 0 // rebuilt by Gen.1 / envelope-tab checkboxes
|
||||||
clearInstrumentsPanel()
|
clearInstrumentsPanel()
|
||||||
drawInstrumentsListColumn()
|
drawInstrumentsListColumn()
|
||||||
drawInstrumentsSeparator()
|
drawInstrumentsSeparator()
|
||||||
@@ -4419,6 +4840,32 @@ function registerInstrumentsMouse() {
|
|||||||
requestEditorLaunch('taut_instredit', [fullPathObj.full, VIEW_INSTRMNT, slot])
|
requestEditorLaunch('taut_instredit', [fullPathObj.full, VIEW_INSTRMNT, slot])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
// Slider body (Gen.1 / Gen.2): one region that hit-tests the live instSliders
|
||||||
|
// list. Click the raw-number capsule to type a value; click/drag the knob to
|
||||||
|
// slide; wheel over either nudges by ±1 (wheel up = +1) and commits each notch.
|
||||||
|
addPanelMouseRegion(INST_RIGHT_X, INST_BODY_Y, INST_RIGHT_W, INST_BODY_H, {
|
||||||
|
onClick: (cy, cx, btn, ev) => {
|
||||||
|
if (btn !== 1) return
|
||||||
|
const e = instrumentsCache ? instrumentsCache[instListCursor] : null
|
||||||
|
const cb = instCheckboxAt(cy, cx)
|
||||||
|
if (cb) { if (e) { toggleInstBit(e, cb.off, cb.bit); drawInstrumentsContents() } return }
|
||||||
|
const b = instButtonAt(cy, cx)
|
||||||
|
if (b) { b.commit(b.value); drawInstrumentsContents(); return }
|
||||||
|
const c = sliderCapsuleAt(cy, cx)
|
||||||
|
if (c) { editSliderNumber(c); return }
|
||||||
|
const s = sliderTroughAt(cy, cx)
|
||||||
|
if (s) runSliderDrag(s, ev)
|
||||||
|
},
|
||||||
|
onWheel: (cy, cx, dy) => {
|
||||||
|
const s = sliderTroughAt(cy, cx) || sliderCapsuleAt(cy, cx)
|
||||||
|
if (!s) return
|
||||||
|
const nv = Math.max(s.min, Math.min(s.max, s.val + (dy < 0 ? 1 : -1)))
|
||||||
|
if (nv === s.val) return
|
||||||
|
s.val = nv
|
||||||
|
s.render(nv)
|
||||||
|
s.commit(nv)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
@@ -4794,7 +5241,10 @@ const panels = [panelTimeline, panelOrders, panelPatterns, panelSamples, panelIn
|
|||||||
// PLAYBACK STATE
|
// PLAYBACK STATE
|
||||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
const PLAYHEAD = 0
|
// Occupy the first idle playhead rather than always grabbing #0, so launching
|
||||||
|
// taut doesn't cut off music already playing on another playhead. Falls back to
|
||||||
|
// #0 when all four are busy.
|
||||||
|
const PLAYHEAD = audio.getFreePlayhead(0)
|
||||||
|
|
||||||
// Scratch cue slot used for pattern-only preview; beyond any real cue the song uses
|
// Scratch cue slot used for pattern-only preview; beyond any real cue the song uses
|
||||||
const PREVIEW_CUE_IDX = NUM_CUES - 1
|
const PREVIEW_CUE_IDX = NUM_CUES - 1
|
||||||
@@ -5133,6 +5583,7 @@ function openHelpPopup() {
|
|||||||
bg: colPopupBack,
|
bg: colPopupBack,
|
||||||
height: HELP_CONTENT_H,
|
height: HELP_CONTENT_H,
|
||||||
width: HELP_CONTENT_W+4,
|
width: HELP_CONTENT_W+4,
|
||||||
|
scrollbarChars: popupScrollbarChars,
|
||||||
selectable: () => false,
|
selectable: () => false,
|
||||||
renderItem: (ctx) => {
|
renderItem: (ctx) => {
|
||||||
con.color_pair(colText, ctx.listBg)
|
con.color_pair(colText, ctx.listBg)
|
||||||
@@ -5177,6 +5628,12 @@ const popupDrawFrame = (wo) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Taut's charset carries dedicated scrollbar glyphs at 0xBA..0xBF (empty
|
||||||
|
// top/mid/bottom caps 0xBA..0xBC, filled top/mid/bottom thumb 0xBD..0xBF).
|
||||||
|
// wintex defaults to the CP437-safe 0xBA/0xDB pair, so pass these to every
|
||||||
|
// list popup to render the scrollbar in taut's style.
|
||||||
|
const popupScrollbarChars = [0xBA, 0xBB, 0xBC, 0xBD, 0xBE, 0xBF]
|
||||||
|
|
||||||
// Standard colour palette shared by every taut popup so wintex's defaults blend
|
// Standard colour palette shared by every taut popup so wintex's defaults blend
|
||||||
// with taut's popup chrome.
|
// with taut's popup chrome.
|
||||||
const popupColours = {
|
const popupColours = {
|
||||||
@@ -5291,6 +5748,7 @@ function openRetunePopup() {
|
|||||||
height: listH,
|
height: listH,
|
||||||
width: 36,
|
width: 36,
|
||||||
cursor: selIdx,
|
cursor: selIdx,
|
||||||
|
scrollbarChars: popupScrollbarChars,
|
||||||
renderItem: (ctx) => {
|
renderItem: (ctx) => {
|
||||||
const e = ctx.item.preset
|
const e = ctx.item.preset
|
||||||
const isCur = (e.index === PITCH_PRESET_IDX)
|
const isCur = (e.index === PITCH_PRESET_IDX)
|
||||||
@@ -5362,6 +5820,7 @@ function openFlagsPopup() {
|
|||||||
width: 22,
|
width: 22,
|
||||||
drawWell: false,
|
drawWell: false,
|
||||||
showScrollbar: false,
|
showScrollbar: false,
|
||||||
|
scrollbarChars: popupScrollbarChars,
|
||||||
selectable: (it) => it.kind === 'tone' || it.kind === 'intp',
|
selectable: (it) => it.kind === 'tone' || it.kind === 'intp',
|
||||||
renderItem: (ctx) => {
|
renderItem: (ctx) => {
|
||||||
const it = ctx.item
|
const it = ctx.item
|
||||||
@@ -5495,6 +5954,77 @@ function openInlineHexEdit(y, x, digits, initialValue) {
|
|||||||
return cancelled ? null : parseInt(buf, 16)
|
return cancelled ? null : parseInt(buf, 16)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inline DECIMAL number editor over a raw-number capsule. `x` is the first digit
|
||||||
|
// cell (the half-block caps painted by drawNumCapsule stay put either side).
|
||||||
|
// Type digits (and '-' when min < 0); Backspace edits; Enter / click-away commits
|
||||||
|
// (clamped to [min,max]); Esc / right-click cancels. Returns the value or null.
|
||||||
|
function openInlineNumEdit(y, x, digits, initialValue, min, max) {
|
||||||
|
let buf = String(initialValue)
|
||||||
|
if (buf.length > digits) buf = buf.substring(0, digits)
|
||||||
|
const allowNeg = (min < 0)
|
||||||
|
let cancelled = false
|
||||||
|
let done = false
|
||||||
|
|
||||||
|
const repaint = () => {
|
||||||
|
const shown = (buf + ' '.repeat(digits)).substring(0, digits)
|
||||||
|
con.move(y, x)
|
||||||
|
con.color_pair(colInstValue, colBLACK) // white digits on the black field
|
||||||
|
print(shown)
|
||||||
|
const cpos = Math.min(buf.length, digits - 1) // inverse block cursor
|
||||||
|
con.move(y, x + cpos)
|
||||||
|
con.color_pair(colBLACK, colInstValue)
|
||||||
|
print(shown[cpos])
|
||||||
|
con.color_pair(colStatus, 255)
|
||||||
|
}
|
||||||
|
|
||||||
|
repaint()
|
||||||
|
let eventJustReceived = true
|
||||||
|
|
||||||
|
// Click-away commits; clicks on the digit cells are swallowed (field stays open).
|
||||||
|
pushMousePopup([
|
||||||
|
{ x: 1, y: 1, w: SCRW, h: SCRH, onClick: (cy, cx, btn) => {
|
||||||
|
if (btn === 1) done = true
|
||||||
|
else if (btn === 2) { cancelled = true; done = true }
|
||||||
|
}},
|
||||||
|
{ x, y, w: digits, h: 1, onClick: () => {} },
|
||||||
|
])
|
||||||
|
|
||||||
|
while (!done) {
|
||||||
|
input.withEvent(ev => {
|
||||||
|
if (eventJustReceived && (ev[0] === 'key_down' || ev[0] === 'mouse_down')) {
|
||||||
|
eventJustReceived = false; return
|
||||||
|
}
|
||||||
|
if (dispatchMouseEvent(ev)) return
|
||||||
|
if (ev[0] !== 'key_down') return
|
||||||
|
if (1 !== ev[2]) return
|
||||||
|
const ks = ev[1]
|
||||||
|
|
||||||
|
if (ks === '<ESC>') { cancelled = true; done = true; return }
|
||||||
|
if (ks === '\n') { done = true; return }
|
||||||
|
if (ks === '\x08') { if (buf.length) buf = buf.substring(0, buf.length - 1); repaint(); return }
|
||||||
|
if (ks === '-' && allowNeg) {
|
||||||
|
buf = (buf[0] === '-') ? buf.substring(1) : ('-' + buf)
|
||||||
|
if (buf.length > digits) buf = buf.substring(0, digits)
|
||||||
|
repaint(); return
|
||||||
|
}
|
||||||
|
if (ks.length === 1 && ks >= '0' && ks <= '9') {
|
||||||
|
if (buf === '0') buf = '' // a fresh digit replaces a lone 0
|
||||||
|
if (buf === '-0') buf = '-'
|
||||||
|
if (buf.length < digits) buf += ks
|
||||||
|
repaint(); return
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
popMousePopup()
|
||||||
|
if (cancelled) return null
|
||||||
|
let v = parseInt(buf, 10)
|
||||||
|
if (isNaN(v)) return null
|
||||||
|
if (v < min) v = min
|
||||||
|
if (v > max) v = max
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
clampCursor(); clampVoice(); clampCue(); clampOrdersHoriz(); clampPatternIdx(); clampPatternGrid()
|
clampCursor(); clampVoice(); clampCue(); clampOrdersHoriz(); clampPatternIdx(); clampPatternGrid()
|
||||||
drawAll()
|
drawAll()
|
||||||
|
|
||||||
@@ -5880,15 +6410,27 @@ function registerPatternsMouse() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Display-row offset (cy - PTNVIEW_OFFSET_Y) of each editable meta field → its
|
||||||
|
// keyboard cursor value. The editable rows render at offsets 6/7/8.
|
||||||
|
const PROJ_META_ROW_TO_CURSOR = {
|
||||||
|
[PROJ_META_ROW_FLAGS]: PROJ_META_FLAGS,
|
||||||
|
[PROJ_META_ROW_GVOL] : PROJ_META_GVOL,
|
||||||
|
[PROJ_META_ROW_MVOL] : PROJ_META_MVOL,
|
||||||
|
}
|
||||||
|
|
||||||
function registerProjectMouse() {
|
function registerProjectMouse() {
|
||||||
addPanelMouseRegion(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, {
|
addPanelMouseRegion(1, PTNVIEW_OFFSET_Y, SCRW, PTNVIEW_HEIGHT, {
|
||||||
onClick: (cy, cx, btn) => {
|
onClick: (cy, cx, btn, ev) => {
|
||||||
if (btn !== 1 || playbackMode !== PLAYMODE_NONE) return
|
if (btn !== 1 || playbackMode !== PLAYMODE_NONE) return
|
||||||
// Meta rows occupy PTNVIEW_OFFSET_Y .. PTNVIEW_OFFSET_Y + PROJ_META_ROWS_COUNT - 1.
|
// Volume rows: click the hex capsule to type, the knob to slide.
|
||||||
// The song list starts at PROJ_SONGLIST_Y + 1.
|
const cap = projCapsuleAt(cy, cx)
|
||||||
const metaRow = cy - PTNVIEW_OFFSET_Y
|
if (cap) { projectCursor = cap.metaCursor; cap.editHex(); return }
|
||||||
if (metaRow >= 0 && metaRow < PROJ_META_ROWS_COUNT) {
|
const tr = projTroughAt(cy, cx)
|
||||||
projectCursor = metaRow
|
if (tr) { projectCursor = tr.metaCursor; runSliderDrag(tr, ev); return }
|
||||||
|
// Otherwise: select an editable meta field, or a song in the list.
|
||||||
|
const metaCursor = PROJ_META_ROW_TO_CURSOR[cy - PTNVIEW_OFFSET_Y]
|
||||||
|
if (metaCursor !== undefined) {
|
||||||
|
projectCursor = metaCursor
|
||||||
clampProjectCursor(); redrawPanel()
|
clampProjectCursor(); redrawPanel()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -5902,6 +6444,15 @@ function registerProjectMouse() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onWheel: (cy, cx, dy) => {
|
onWheel: (cy, cx, dy) => {
|
||||||
|
// Wheel over a volume knob/capsule nudges ±1 (when stopped); else scroll.
|
||||||
|
if (playbackMode === PLAYMODE_NONE) {
|
||||||
|
const s = projTroughAt(cy, cx) || projCapsuleAt(cy, cx)
|
||||||
|
if (s) {
|
||||||
|
const nv = Math.max(s.min, Math.min(s.max, s.val + (dy < 0 ? 1 : -1)))
|
||||||
|
if (nv !== s.val) { s.val = nv; s.render(nv); s.commit(nv) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
const rowsVis = projectSongListRowsVisible()
|
const rowsVis = projectSongListRowsVisible()
|
||||||
const maxScroll = Math.max(0, songsMeta.numSongs - rowsVis)
|
const maxScroll = Math.max(0, songsMeta.numSongs - rowsVis)
|
||||||
projectSongScroll += dy * 3
|
projectSongScroll += dy * 3
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ let ts = require("typesetter")
|
|||||||
/*
|
/*
|
||||||
Tags:
|
Tags:
|
||||||
<b> - print the text in emphasis colour (colVoiceHdr aka 230)
|
<b> - print the text in emphasis colour (colVoiceHdr aka 230)
|
||||||
|
<s> - print the text in deemphasis colour (248)
|
||||||
<c> - centre the line. If the line spans multiple lines, centre each line
|
<c> - centre the line. If the line spans multiple lines, centre each line
|
||||||
<r> - align right
|
<r> - align right
|
||||||
<l> - align left
|
<l> - align left
|
||||||
@@ -145,7 +146,7 @@ Mixer flags define how should the mixer behave.
|
|||||||
|
|
||||||
// assemble help text pieces to complete help message
|
// assemble help text pieces to complete help message
|
||||||
|
|
||||||
const HRULE = '\u00B4\u00B5'.repeat((_G.TAUT.HELPMSG_WIDTH) >>> 1) + '\n'
|
const HRULE = '<s>' + '\u00B3'.repeat(_G.TAUT.HELPMSG_WIDTH) + '</s>\n'
|
||||||
|
|
||||||
// taut.js's popup uses (HELP_COL_TEXT on background) as the default colour pair.
|
// 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.
|
// The shared typesetter module owns the palette and the markup expander.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
9
assets/disk0/tvdos/bin/tee.js.synopsis
Normal file
9
assets/disk0/tvdos/bin/tee.js.synopsis
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "tee",
|
||||||
|
"summary": "Copy a pipe's stream to a file and pass it on",
|
||||||
|
"symbols": {
|
||||||
|
"file": { "kind": "positional", "type": "path", "name": "FILE", "summary": "File to write the stream to" }
|
||||||
|
},
|
||||||
|
"synopsis": { "type": "reference", "symbol": "file" }
|
||||||
|
}
|
||||||
17
assets/disk0/tvdos/bin/touch.js.synopsis
Normal file
17
assets/disk0/tvdos/bin/touch.js.synopsis
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "touch",
|
||||||
|
"summary": "Update a file's modification time, creating it if absent",
|
||||||
|
"symbols": {
|
||||||
|
"noCreate": { "kind": "option", "short": "-c", "summary": "Do not create the file if it does not exist" },
|
||||||
|
"options": { "kind": "group", "summary": "Options", "members": ["noCreate"] },
|
||||||
|
"file": { "kind": "positional", "type": "path", "name": "FILE", "summary": "File to touch" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "sequence",
|
||||||
|
"children": [
|
||||||
|
{ "type": "repeat", "child": { "type": "reference", "symbol": "options" } },
|
||||||
|
{ "type": "reference", "symbol": "file" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
9
assets/disk0/tvdos/bin/writeto.js.synopsis
Normal file
9
assets/disk0/tvdos/bin/writeto.js.synopsis
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "writeto",
|
||||||
|
"summary": "Write a pipe's stream to a file",
|
||||||
|
"symbols": {
|
||||||
|
"file": { "kind": "positional", "type": "path", "name": "FILE", "summary": "File to write the stream to" }
|
||||||
|
},
|
||||||
|
"synopsis": { "type": "reference", "symbol": "file" }
|
||||||
|
}
|
||||||
@@ -54,12 +54,12 @@ const EXEC_FUNS = {
|
|||||||
"adpcm": (f) => _G.shell.execute(`playwav "${f}" -i`),
|
"adpcm": (f) => _G.shell.execute(`playwav "${f}" -i`),
|
||||||
// "mp3": (f) => _G.shell.execute(`playmp3 "${f}" -i`),
|
// "mp3": (f) => _G.shell.execute(`playmp3 "${f}" -i`),
|
||||||
"mp2": (f) => _G.shell.execute(`playmp2 "${f}" -i`),
|
"mp2": (f) => _G.shell.execute(`playmp2 "${f}" -i`),
|
||||||
"mv1": (f) => _G.shell.execute(`playmv1 "${f}" -i`),
|
"mv1": (f) => _G.shell.execute(`playmov "${f}" -i`),
|
||||||
"mv2": (f) => _G.shell.execute(`playtev "${f}" -i`),
|
"mv2": (f) => _G.shell.execute(`playmov "${f}" -i`),
|
||||||
"mv3": (f) => _G.shell.execute(`playtav "${f}" -i`),
|
"mv3": (f) => _G.shell.execute(`playmov "${f}" -i`),
|
||||||
"tav": (f) => _G.shell.execute(`playtav "${f}" -i`),
|
"tav": (f) => _G.shell.execute(`playmov "${f}" -i`),
|
||||||
"im3": (f) => _G.shell.execute(`playtav "${f}" -i`),
|
"im3": (f) => _G.shell.execute(`playmov "${f}" -i`),
|
||||||
"tap": (f) => _G.shell.execute(`playtav "${f}" -i`),
|
"tap": (f) => _G.shell.execute(`playmov "${f}" -i`),
|
||||||
"tad": (f) => _G.shell.execute(`playtad "${f}" -i`),
|
"tad": (f) => _G.shell.execute(`playtad "${f}" -i`),
|
||||||
"pcm": (f) => _G.shell.execute(`playpcm "${f}" -i`),
|
"pcm": (f) => _G.shell.execute(`playpcm "${f}" -i`),
|
||||||
"ipf": (f) => _G.shell.execute(`decodeipf "${f}" -i`),
|
"ipf": (f) => _G.shell.execute(`decodeipf "${f}" -i`),
|
||||||
|
|||||||
12
assets/disk0/tvdos/hopper/libmediadec.hop.per
Normal file
12
assets/disk0/tvdos/hopper/libmediadec.hop.per
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
HopperManifestVersion:1
|
||||||
|
HopperPackageName:libmediadec
|
||||||
|
HopperPackageVersion:1.0.0
|
||||||
|
HopperPackageMaintainer:CuriousTorvald
|
||||||
|
HopperProvides:libmediadec
|
||||||
|
HopperRequires:libseqread 1.*
|
||||||
|
ProperName:LibMediaDec
|
||||||
|
ProperAuthor:CuriousTorvald
|
||||||
|
ProperDescription:Video decoding library for TSVM
|
||||||
|
Licence:MIT
|
||||||
|
SupportMe:https://github.com/sponsors/curioustorvald/
|
||||||
|
SystemPackagePath:/tvdos/include/mediadec.mjs;/tvdos/include/mediadec_common.mjs;/tvdos/include/mediadec_ipf.mjs;/tvdos/include/mediadec_tav.mjs;/tvdos/include/mediadec_tev.mjs
|
||||||
86
assets/disk0/tvdos/include/mediadec.mjs
Normal file
86
assets/disk0/tvdos/include/mediadec.mjs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/*
|
||||||
|
* mediadec.mjs — the all-in-one media-decoding library for TVDOS movie players.
|
||||||
|
*
|
||||||
|
* One simple public API, three internal backends (iPF/MOV, TEV, TAV/TAP),
|
||||||
|
* sharing the front-end utilities in mediadec_common.mjs. Used by playmov.js.
|
||||||
|
*
|
||||||
|
* const mediadec = require("mediadec")
|
||||||
|
* const dec = mediadec.open("A:\\film.tav", { interactive: true })
|
||||||
|
* while (true) {
|
||||||
|
* const ev = dec.step() // [backend] decode the next due frame to RAM
|
||||||
|
* if (ev.type === 'eof') break
|
||||||
|
* if (ev.type !== 'frame') { sys.sleep(1); continue }
|
||||||
|
* dec.blit() // [draw] upload the RAM frame to the screen
|
||||||
|
* // ...or in ASCII mode (no upload): dec.sampleGray(buf,w,h); aa.render/flush
|
||||||
|
* // ...or grab the frame yourself: sys.peek(dec.frameBuffer + ...)
|
||||||
|
* }
|
||||||
|
* dec.close()
|
||||||
|
*
|
||||||
|
* step() decodes the next due frame into a generic RAM RGB888 buffer (exposed as
|
||||||
|
* .frameBuffer); the caller decides what to do with it — upload it with .blit(),
|
||||||
|
* sample it for ASCII, or read it directly. (iPF is the exception: it decodes
|
||||||
|
* straight to the 4bpp display planes, so .frameBuffer is 0 and .sampleGray/.blit
|
||||||
|
* operate on the planes — see mediadec_ipf.mjs.)
|
||||||
|
*
|
||||||
|
* The decoder object every backend returns exposes a uniform interface:
|
||||||
|
* .info {format,width,height,fps,totalFrames,hasAudio,hasSubtitles,
|
||||||
|
* isInterlaced,colourSpace,graphicsMode,isStill}
|
||||||
|
* .step() -> { type:'frame'|'idle'|'eof'|'newfile'|'error', frameCount }
|
||||||
|
* .frameBuffer RAM RGB888 address of the current frame (0 for iPF; see above)
|
||||||
|
* .frameWidth/.frameHeight dimensions of the frame in .frameBuffer
|
||||||
|
* .blit() upload the current RAM frame to the screen (adapter)
|
||||||
|
* .sampleGray(dst,w,h) fill an ASCII brightness buffer from the RAM frame
|
||||||
|
* .sampleColour(dst,w,h) fill a per-cell RGB buffer (w*h*3) from the RAM frame
|
||||||
|
* .subtitle {visible,text,position,useUnicode,dirty} (resolved by the lib)
|
||||||
|
* .pause(b)/.isPaused() .setVolume(v)/.getVolume()
|
||||||
|
* .seekSeconds(n) .cue(d) .cues
|
||||||
|
* .frameCount .currentTimecodeNs .videoRate .frameMode [.qY/.qCo/.qCg]
|
||||||
|
* .close()
|
||||||
|
*/
|
||||||
|
|
||||||
|
// NOTE: every require() below is deliberately made at call time (inside open()),
|
||||||
|
// never at module top level. TVDOS's require() loads a module by eval()-ing it,
|
||||||
|
// and requiring one module *while another module is still being eval()-ed* nests
|
||||||
|
// the evals — which can collide on the loader's `let exports` binding and throw
|
||||||
|
// "Identifier 'exports' has already been declared" at load, breaking every file.
|
||||||
|
// Keeping requires at runtime means each is a single, non-nested eval.
|
||||||
|
|
||||||
|
// Open a movie file: sniff the magic, then hand off to the matching backend.
|
||||||
|
// `opts` (all optional): interactive, debugMotionVectors, enableDeblocking,
|
||||||
|
// enableBoundaryAwareDecoding, deinterlaceAlgorithm, filmGrainLevel.
|
||||||
|
function open(fullPathStr, opts) {
|
||||||
|
opts = opts || {}
|
||||||
|
|
||||||
|
const common = require("mediadec_common")
|
||||||
|
|
||||||
|
// IMPORTANT: query the file size via files.open() BEFORE preparing seqread.
|
||||||
|
// On the real disk driver both share the drive's serial port, so a files.open()
|
||||||
|
// *after* seqread.prepare() clobbers the read position and the first readBytes()
|
||||||
|
// returns driver leftovers (the size as an ASCII string) instead of the file's
|
||||||
|
// bytes — which made every file fail the magic check. Every original player
|
||||||
|
// reads the size first, then prepares seqread.
|
||||||
|
const fileLength = files.open(fullPathStr).size
|
||||||
|
const sr = common.openSeqread(fullPathStr)
|
||||||
|
const magic = common.readMagic(sr)
|
||||||
|
const fmt = common.detectFormat(magic)
|
||||||
|
|
||||||
|
con.clear()
|
||||||
|
con.curs_set(0)
|
||||||
|
|
||||||
|
switch (fmt) {
|
||||||
|
case 'mov': return require("mediadec_ipf").create(magic, sr, fileLength, opts, common)
|
||||||
|
case 'tev': return require("mediadec_tev").create(magic, sr, fileLength, opts, common)
|
||||||
|
case 'tav': return require("mediadec_tav").create(magic, sr, fileLength, opts, common, false)
|
||||||
|
case 'tap': return require("mediadec_tav").create(magic, sr, fileLength, opts, common, true)
|
||||||
|
case 'ucf':
|
||||||
|
throw Error("UCF cue files are not directly playable; play the TAV stream they index")
|
||||||
|
default:
|
||||||
|
throw Error("Unrecognised movie file (magic: " + magic.map(b => b.toString(16)).join(' ') + ")")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exports = {
|
||||||
|
open: open,
|
||||||
|
// Lazy require so this module never requires another at load time (see note above).
|
||||||
|
detectFormat: function (magic) { return require("mediadec_common").detectFormat(magic) }
|
||||||
|
}
|
||||||
448
assets/disk0/tvdos/include/mediadec_common.mjs
Normal file
448
assets/disk0/tvdos/include/mediadec_common.mjs
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
/*
|
||||||
|
* mediadec_common.mjs — shared front-end utilities for the mediadec library.
|
||||||
|
*
|
||||||
|
* Holds everything the three movie backends (iPF/MOV, TEV, TAV) duplicated in
|
||||||
|
* the old standalone players: magic constants, packet-type / SSF-opcode tables,
|
||||||
|
* the TAV quality LUT, seqread selection, the audio router, the subtitle
|
||||||
|
* engine, bias lighting, and the `sampleGray` / `sampleColour` source samplers
|
||||||
|
* used by the player's ASCII-render path — both a *Screen pair (read the GPU
|
||||||
|
* display planes, for iPF) and a *RGB pair (read a RAM RGB888 frame, for the
|
||||||
|
* decode-into-RAM backends TEV / TAV).
|
||||||
|
*
|
||||||
|
* Runs in the same GraalVM context as the player, so the host globals
|
||||||
|
* (sys/graphics/audio/con/serial/files/gzip) are visible directly, exactly as
|
||||||
|
* in seqread.mjs / playgui.mjs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── Magic numbers ───────────────────────────────────────────────────────────
|
||||||
|
const MAGIC_MOV = [0x1F, 0x54, 0x53, 0x56, 0x4D, 0x4D, 0x4F, 0x56] // "\x1FTSVMMOV"
|
||||||
|
const MAGIC_TEV = [0x1F, 0x54, 0x53, 0x56, 0x4D, 0x54, 0x45, 0x56] // "\x1FTSVMTEV"
|
||||||
|
const MAGIC_TAV = [0x1F, 0x54, 0x53, 0x56, 0x4D, 0x54, 0x41, 0x56] // "\x1FTSVMTAV"
|
||||||
|
const MAGIC_TAP = [0x1F, 0x54, 0x53, 0x56, 0x4D, 0x54, 0x41, 0x50] // "\x1FTSVMTAP"
|
||||||
|
const MAGIC_UCF = [0x1F, 0x54, 0x53, 0x56, 0x4D, 0x55, 0x43, 0x46] // "\x1FTSVMUCF"
|
||||||
|
|
||||||
|
// ── MP2 frame-size table (shared by iPF/TEV/TAV) ────────────────────────────
|
||||||
|
const MP2_FRAME_SIZE = [144,216,252,288,360,432,504,576,720,864,1008,1152,1440,1728]
|
||||||
|
|
||||||
|
// ── SSF subtitle opcodes (shared) ───────────────────────────────────────────
|
||||||
|
const SSF_OP_NOP = 0x00
|
||||||
|
const SSF_OP_SHOW = 0x01
|
||||||
|
const SSF_OP_HIDE = 0x02
|
||||||
|
const SSF_OP_MOVE = 0x03
|
||||||
|
const SSF_OP_UPLOAD_LOW_FONT = 0x80
|
||||||
|
const SSF_OP_UPLOAD_HIGH_FONT = 0x81
|
||||||
|
|
||||||
|
// ── TAV quality LUT (index → quantiser) ─────────────────────────────────────
|
||||||
|
const QLUT = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,66,68,70,72,74,76,78,80,82,84,86,88,90,92,94,96,98,100,102,104,106,108,110,112,114,116,118,120,122,124,126,128,132,136,140,144,148,152,156,160,164,168,172,176,180,184,188,192,196,200,204,208,212,216,220,224,228,232,236,240,244,248,252,256,264,272,280,288,296,304,312,320,328,336,344,352,360,368,376,384,392,400,408,416,424,432,440,448,456,464,472,480,488,496,504,512,528,544,560,576,592,608,624,640,656,672,688,704,720,736,752,768,784,800,816,832,848,864,880,896,912,928,944,960,976,992,1008,1024,1056,1088,1120,1152,1184,1216,1248,1280,1312,1344,1376,1408,1440,1472,1504,1536,1568,1600,1632,1664,1696,1728,1760,1792,1824,1856,1888,1920,1952,1984,2016,2048,2112,2176,2240,2304,2368,2432,2496,2560,2624,2688,2752,2816,2880,2944,3008,3072,3136,3200,3264,3328,3392,3456,3520,3584,3648,3712,3776,3840,3904,3968,4032,4096]
|
||||||
|
|
||||||
|
// ── Display-plane addresses (4bpp / mode 4) ─────────────────────────────────
|
||||||
|
const DISP_RG = -1048577
|
||||||
|
const DISP_BA = -1310721
|
||||||
|
const DISP_PLANE3 = -1310721 - 262144 // mode-8 third plane base (for getRGBfromScr)
|
||||||
|
|
||||||
|
// ── seqread selection ───────────────────────────────────────────────────────
|
||||||
|
// Mirrors the tape-vs-disk branch every old player carried. Returns a prepared
|
||||||
|
// seqread module instance (a stateful singleton — only one decoder at a time).
|
||||||
|
function openSeqread(fullPathStr) {
|
||||||
|
let sr
|
||||||
|
if (fullPathStr.startsWith('$:/TAPE') || fullPathStr.startsWith('$:\\TAPE')) {
|
||||||
|
sr = require("seqreadtape")
|
||||||
|
sr.prepare(fullPathStr)
|
||||||
|
sr.seek(0)
|
||||||
|
} else {
|
||||||
|
sr = require("seqread")
|
||||||
|
sr.prepare(fullPathStr)
|
||||||
|
}
|
||||||
|
return sr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the 8-byte magic into a JS array (frees the scratch buffer).
|
||||||
|
function readMagic(sr) {
|
||||||
|
let p = sr.readBytes(8)
|
||||||
|
let out = []
|
||||||
|
for (let i = 0; i < 8; i++) out.push(sys.peek(p + i) & 255)
|
||||||
|
sys.free(p)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function magicEquals(got, want) {
|
||||||
|
for (let i = 0; i < 8; i++) if (got[i] !== want[i]) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect container format from the 8-byte magic. Returns 'mov'|'tev'|'tav'|'tap'|'ucf'|null.
|
||||||
|
function detectFormat(magic) {
|
||||||
|
if (magicEquals(magic, MAGIC_MOV)) return 'mov'
|
||||||
|
if (magicEquals(magic, MAGIC_TEV)) return 'tev'
|
||||||
|
if (magicEquals(magic, MAGIC_TAV)) return 'tav'
|
||||||
|
if (magicEquals(magic, MAGIC_TAP)) return 'tap'
|
||||||
|
if (magicEquals(magic, MAGIC_UCF)) return 'ucf'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Luma ─────────────────────────────────────────────────────────────────────
|
||||||
|
// BT.601 integer luma from 8-bit RGB.
|
||||||
|
function luma8(r, g, b) { return (r * 77 + g * 150 + b * 29) >> 8 }
|
||||||
|
|
||||||
|
// ── Audio router ─────────────────────────────────────────────────────────────
|
||||||
|
// One playhead, deferred play(). Handles the per-packet audio codecs shared by
|
||||||
|
// the backends. TAV's bundled-MP2 (0x40) pre-decode/streaming stays in the TAV
|
||||||
|
// backend because it interleaves with the GOP display loop.
|
||||||
|
function makeAudioRouter(sr) {
|
||||||
|
const playhead = audio.getFreePlayhead(0)
|
||||||
|
const SND_BASE = audio.getBaseAddr()
|
||||||
|
const SND_MEM = audio.getMemAddr()
|
||||||
|
audio.resetParams(playhead)
|
||||||
|
audio.purgeQueue(playhead)
|
||||||
|
audio.setPcmMode(playhead)
|
||||||
|
let volume = 255
|
||||||
|
audio.setMasterVolume(playhead, volume)
|
||||||
|
|
||||||
|
let mp2Init = false
|
||||||
|
let fired = false
|
||||||
|
|
||||||
|
return {
|
||||||
|
playhead: playhead,
|
||||||
|
sndBase: SND_BASE,
|
||||||
|
sndMem: SND_MEM,
|
||||||
|
|
||||||
|
// Fire playback once, on the first displayed frame.
|
||||||
|
fire() { if (!fired) { audio.play(playhead); fired = true } },
|
||||||
|
isFired() { return fired },
|
||||||
|
|
||||||
|
stop() { audio.stop(playhead) },
|
||||||
|
resume() { audio.play(playhead) },
|
||||||
|
purge() { audio.purgeQueue(playhead); fired = false },
|
||||||
|
|
||||||
|
setVolume(v) { volume = (v < 0) ? 0 : (v > 255) ? 255 : v; audio.setMasterVolume(playhead, volume) },
|
||||||
|
getVolume() { return volume },
|
||||||
|
|
||||||
|
// MP2 packet: payload already length-known by caller; reads `len` bytes.
|
||||||
|
mp2(len) {
|
||||||
|
if (!mp2Init) { mp2Init = true; audio.mp2Init() }
|
||||||
|
sr.readBytes(len, SND_BASE - 2368)
|
||||||
|
audio.mp2Decode()
|
||||||
|
audio.mp2UploadDecoded(playhead)
|
||||||
|
},
|
||||||
|
// MP2 frame whose size is implicit in the iPF packet type.
|
||||||
|
ensureMp2() { if (!mp2Init) { mp2Init = true; audio.mp2Init() } },
|
||||||
|
|
||||||
|
// TAD packet.
|
||||||
|
tad(sampleLen, payloadLen) {
|
||||||
|
sr.readBytes(payloadLen, SND_MEM - 917504)
|
||||||
|
audio.tadDecode()
|
||||||
|
audio.tadUploadDecoded(playhead, sampleLen)
|
||||||
|
},
|
||||||
|
// Native (zstd PCMu8) packet.
|
||||||
|
nativePcm(zstdLen) {
|
||||||
|
let zstdPtr = sys.malloc(zstdLen)
|
||||||
|
sr.readBytes(zstdLen, zstdPtr)
|
||||||
|
let pcmPtr = sys.malloc(65536)
|
||||||
|
let pcmLen = gzip.decompFromTo(zstdPtr, zstdLen, pcmPtr)
|
||||||
|
if (pcmLen > 65536) { sys.free(zstdPtr); sys.free(pcmPtr); throw Error(`PCM data too long -- got ${pcmLen} bytes`) }
|
||||||
|
audio.putPcmDataByPtr(playhead, pcmPtr, pcmLen, 0)
|
||||||
|
audio.setSampleUploadLength(playhead, pcmLen)
|
||||||
|
audio.startSampleUpload(playhead)
|
||||||
|
sys.free(zstdPtr)
|
||||||
|
sys.free(pcmPtr)
|
||||||
|
},
|
||||||
|
// Raw PCM (iPF 0x1000/0x1001): payload bytes streamed directly.
|
||||||
|
rawPcm(len) {
|
||||||
|
let frame = sr.readBytes(len)
|
||||||
|
audio.putPcmDataByPtr(playhead, frame, len, 0)
|
||||||
|
audio.setSampleUploadLength(playhead, len)
|
||||||
|
audio.startSampleUpload(playhead)
|
||||||
|
sys.free(frame)
|
||||||
|
},
|
||||||
|
|
||||||
|
close() { audio.stop(playhead); audio.purgeQueue(playhead) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Subtitle engine ──────────────────────────────────────────────────────────
|
||||||
|
// Parses SSF (frame-locked 0x30) and SSF-TC (timecode 0x31) packets and exposes
|
||||||
|
// the *active* subtitle as state; the player renders it (the "postprocessor"
|
||||||
|
// stage). Font-ROM uploads are hardware writes, so the engine performs them.
|
||||||
|
// fontUploadBase: -1300607 (TEV) or -133121 (TAV) — kept per-format for parity.
|
||||||
|
function makeSubtitleEngine(sr, fontUploadBase) {
|
||||||
|
const subtitle = { visible: false, text: "", position: 0, useUnicode: false, dirty: false }
|
||||||
|
let events = []
|
||||||
|
let nextIndex = 0
|
||||||
|
let fontUploaded = false
|
||||||
|
|
||||||
|
function uploadFont(opcode, remainingBytes) {
|
||||||
|
if (remainingBytes >= 3) {
|
||||||
|
let payloadLen = sr.readShort()
|
||||||
|
if (remainingBytes >= payloadLen + 2) {
|
||||||
|
let fontData = sr.readBytes(payloadLen)
|
||||||
|
for (let i = 0; i < Math.min(payloadLen, 1920); i++) sys.poke(fontUploadBase - i, sys.peek(fontData + i))
|
||||||
|
sys.poke(-1299460, (opcode == SSF_OP_UPLOAD_LOW_FONT) ? 18 : 19)
|
||||||
|
sys.free(fontData)
|
||||||
|
}
|
||||||
|
fontUploaded = true
|
||||||
|
subtitle.useUnicode = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
subtitle: subtitle,
|
||||||
|
get fontUploaded() { return fontUploaded },
|
||||||
|
|
||||||
|
// Frame-locked subtitle packet (0x30): applies immediately.
|
||||||
|
parseLegacy(packetSize) {
|
||||||
|
sr.readOneByte(); sr.readOneByte(); sr.readOneByte() // 24-bit index
|
||||||
|
let opcode = sr.readOneByte()
|
||||||
|
let remainingBytes = packetSize - 4
|
||||||
|
switch (opcode) {
|
||||||
|
case SSF_OP_SHOW: {
|
||||||
|
if (remainingBytes > 1) {
|
||||||
|
let tb = sr.readBytes(remainingBytes)
|
||||||
|
let s = ""
|
||||||
|
for (let i = 0; i < remainingBytes - 1; i++) { let b = sys.peek(tb + i); if (b === 0) break; s += String.fromCharCode(b) }
|
||||||
|
sys.free(tb)
|
||||||
|
subtitle.text = s; subtitle.visible = true; subtitle.useUnicode = fontUploaded; subtitle.dirty = true
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case SSF_OP_HIDE: { subtitle.visible = false; subtitle.text = ""; subtitle.dirty = true; break }
|
||||||
|
case SSF_OP_MOVE: {
|
||||||
|
if (remainingBytes >= 2) {
|
||||||
|
let pos = sr.readOneByte(); sr.readOneByte()
|
||||||
|
if (pos >= 0 && pos <= 8) { subtitle.position = pos; subtitle.dirty = true }
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case SSF_OP_UPLOAD_LOW_FONT:
|
||||||
|
case SSF_OP_UPLOAD_HIGH_FONT: { uploadFont(opcode, remainingBytes); break }
|
||||||
|
default: { if (remainingBytes > 0) { let s = sr.readBytes(remainingBytes); sys.free(s) } break }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Timecode subtitle packet (0x31): buffered, applied by poll().
|
||||||
|
parseTC(packetSize) {
|
||||||
|
let i0 = sr.readOneByte(), i1 = sr.readOneByte(), i2 = sr.readOneByte()
|
||||||
|
let index = i0 | (i1 << 8) | (i2 << 16)
|
||||||
|
let tc = 0
|
||||||
|
for (let i = 0; i < 8; i++) { tc += sr.readOneByte() * Math.pow(2, i * 8) }
|
||||||
|
let opcode = sr.readOneByte()
|
||||||
|
let remainingBytes = packetSize - 12
|
||||||
|
let text = null
|
||||||
|
if (remainingBytes > 1 && (opcode === SSF_OP_SHOW || (opcode >= 0x10 && opcode <= 0x2F))) {
|
||||||
|
let tb = sr.readBytes(remainingBytes)
|
||||||
|
text = ""
|
||||||
|
for (let i = 0; i < remainingBytes - 1; i++) { let b = sys.peek(tb + i); if (b === 0) break; text += String.fromCharCode(b) }
|
||||||
|
sys.free(tb)
|
||||||
|
} else if (remainingBytes > 0) {
|
||||||
|
let s = sr.readBytes(remainingBytes); sys.free(s)
|
||||||
|
}
|
||||||
|
events.push({ timecode_ns: tc, index: index, opcode: opcode, text: text })
|
||||||
|
},
|
||||||
|
|
||||||
|
// Advance through timecode events whose time has been reached.
|
||||||
|
poll(currentTimeNs) {
|
||||||
|
while (nextIndex < events.length) {
|
||||||
|
let ev = events[nextIndex]
|
||||||
|
if (ev.timecode_ns > currentTimeNs) break
|
||||||
|
switch (ev.opcode) {
|
||||||
|
case SSF_OP_SHOW: subtitle.text = ev.text || ""; subtitle.visible = true; subtitle.useUnicode = fontUploaded; subtitle.dirty = true; break
|
||||||
|
case SSF_OP_HIDE: subtitle.visible = false; subtitle.text = ""; subtitle.dirty = true; break
|
||||||
|
case SSF_OP_MOVE:
|
||||||
|
if (ev.text && ev.text.length > 0) {
|
||||||
|
let pos = ev.text.charCodeAt(0)
|
||||||
|
if (pos >= 0 && pos <= 8) { subtitle.position = pos; subtitle.dirty = true }
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
nextIndex++
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// After a seek: jump the event cursor to the first event at/after `tc`.
|
||||||
|
resetTo(tc) {
|
||||||
|
nextIndex = 0
|
||||||
|
for (let i = 0; i < events.length; i++) { if (events[i].timecode_ns >= tc) { nextIndex = i; break } }
|
||||||
|
subtitle.visible = false; subtitle.text = ""; subtitle.dirty = true
|
||||||
|
},
|
||||||
|
|
||||||
|
hasEvents() { return events.length > 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bias lighting ────────────────────────────────────────────────────────────
|
||||||
|
// Samples the screen borders and drifts the background colour toward them —
|
||||||
|
// the "ambilight" the old players ran after each frame upload. Mode-aware
|
||||||
|
// (4/5/8 bpp) read-back, matching playtav's getRGBfromScr.
|
||||||
|
function makeBias(width, height, graphicsMode) {
|
||||||
|
const BIAS_MIN = 1.0 / 16.0
|
||||||
|
let old = [BIAS_MIN, BIAS_MIN, BIAS_MIN]
|
||||||
|
const nativeWidth = graphics.getPixelDimension()[0]
|
||||||
|
const nativeHeight = graphics.getPixelDimension()[1]
|
||||||
|
const STRIDE = 560
|
||||||
|
|
||||||
|
function rgbFromScr(x, y) {
|
||||||
|
let off = y * STRIDE + x
|
||||||
|
let fb1 = sys.peek(DISP_RG - off)
|
||||||
|
let fb2 = sys.peek(DISP_BA - off)
|
||||||
|
if (graphicsMode == 5) {
|
||||||
|
let fb3 = sys.peek(DISP_PLANE3 - off)
|
||||||
|
return [((fb1 >>> 2) & 31) / 31.0, (((fb1 & 3) << 3) | ((fb2 >>> 5) & 7)) / 31.0, (fb2 & 31) / 31.0]
|
||||||
|
} else if (graphicsMode == 4) {
|
||||||
|
return [(fb1 >>> 4) / 15.0, (fb1 & 15) / 15.0, (fb2 >>> 4) / 15.0]
|
||||||
|
} else {
|
||||||
|
let fb3 = sys.peek(DISP_PLANE3 - off)
|
||||||
|
return [fb1 / 255.0, fb2 / 255.0, fb3 / 255.0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return function setBiasLighting() {
|
||||||
|
let samples = []
|
||||||
|
let offsetX = Math.floor((nativeWidth - width) / 2)
|
||||||
|
let offsetY = Math.floor((nativeHeight - height) / 2)
|
||||||
|
let stepX = Math.max(8, Math.floor(width / 18))
|
||||||
|
let stepY = Math.max(8, Math.floor(height / 17))
|
||||||
|
let margin = Math.min(8, Math.floor(width / 70))
|
||||||
|
|
||||||
|
for (let x = margin; x < width - margin; x += stepX) {
|
||||||
|
samples.push(rgbFromScr(x + offsetX, margin + offsetY))
|
||||||
|
samples.push(rgbFromScr(x + offsetX, height - margin - 1 + offsetY))
|
||||||
|
}
|
||||||
|
for (let y = margin; y < height - margin; y += stepY) {
|
||||||
|
samples.push(rgbFromScr(margin + offsetX, y + offsetY))
|
||||||
|
samples.push(rgbFromScr(width - margin - 1 + offsetX, y + offsetY))
|
||||||
|
}
|
||||||
|
|
||||||
|
let out = [0.0, 0.0, 0.0]
|
||||||
|
samples.forEach(rgb => { out[0] += rgb[0]; out[1] += rgb[1]; out[2] += rgb[2] })
|
||||||
|
out[0] = BIAS_MIN + (out[0] / samples.length / 2.0)
|
||||||
|
out[1] = BIAS_MIN + (out[1] / samples.length / 2.0)
|
||||||
|
out[2] = BIAS_MIN + (out[2] / samples.length / 2.0)
|
||||||
|
|
||||||
|
let bgr = (old[0] * 5 + out[0]) / 6.0
|
||||||
|
let bgg = (old[1] * 5 + out[1]) / 6.0
|
||||||
|
let bgb = (old[2] * 5 + out[2]) / 6.0
|
||||||
|
old = [bgr, bgg, bgb]
|
||||||
|
graphics.setBackground(Math.round(bgr * 255), Math.round(bgg * 255), Math.round(bgb * 255))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── sampleGray source ────────────────────────────────────────────────────────
|
||||||
|
// Fill an ASCII brightness buffer (dst, dstW×dstH) by nearest-sampling the GPU
|
||||||
|
// framebuffer (the shared "player framebuffer" the backend has just blit()ted
|
||||||
|
// to). Reading the screen — rather than each backend's private frame store —
|
||||||
|
// keeps one sampler for every format/kind (TAV's GOP videoBuffer is Java-heap
|
||||||
|
// and has no JS-addressable VM address, so reading it directly is impossible).
|
||||||
|
//
|
||||||
|
// Only ~dstW·dstH peeks per call, so it is cheap regardless of frame size.
|
||||||
|
// Pixel `off` is backward-addressed (DISP_RG-off / DISP_BA-off), matching how
|
||||||
|
// every decoder writes the framebuffer. `mode` selects 4/5/8-bpp unpacking
|
||||||
|
// (mirrors playtav's getRGBfromScr).
|
||||||
|
function sampleGrayScreen(width, height, dst, dstW, dstH, mode) {
|
||||||
|
for (let y = 0; y < dstH; y++) {
|
||||||
|
let sy = (y * height / dstH) | 0
|
||||||
|
let dstRow = y * dstW
|
||||||
|
for (let x = 0; x < dstW; x++) {
|
||||||
|
let sx = (x * width / dstW) | 0
|
||||||
|
let off = sy * 560 + sx
|
||||||
|
let fb1 = sys.peek(DISP_RG - off) & 255
|
||||||
|
let fb2 = sys.peek(DISP_BA - off) & 255
|
||||||
|
let r, g, b
|
||||||
|
if (mode == 5) {
|
||||||
|
r = ((fb1 >>> 2) & 31) * 255 / 31
|
||||||
|
g = (((fb1 & 3) << 3) | ((fb2 >>> 5) & 7)) * 255 / 31
|
||||||
|
b = (fb2 & 31) * 255 / 31
|
||||||
|
} else if (mode == 8) {
|
||||||
|
r = fb1; g = fb2; b = sys.peek(DISP_PLANE3 - off) & 255
|
||||||
|
} else { // mode 4
|
||||||
|
r = (fb1 >>> 4) * 17
|
||||||
|
g = (fb1 & 15) * 17
|
||||||
|
b = (fb2 >>> 4) * 17
|
||||||
|
}
|
||||||
|
dst[dstRow + x] = luma8(r | 0, g | 0, b | 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── sampleColour source ──────────────────────────────────────────────────────
|
||||||
|
// Companion to sampleGrayScreen: fill an RGB buffer (dst, length dstW·dstH·3,
|
||||||
|
// laid out R,G,B per cell) by point-sampling the GPU framebuffer at the CENTRE
|
||||||
|
// of each cell. Used by the player's colour-ASCII postprocessor — aa.mjs picks
|
||||||
|
// each glyph from brightness, this supplies the per-cell ink colour. Same
|
||||||
|
// backend-specific `mode` (4/5/8-bpp unpacking) and same cheap ~dstW·dstH peek
|
||||||
|
// count as sampleGrayScreen.
|
||||||
|
function sampleColourScreen(width, height, dst, dstW, dstH, mode) {
|
||||||
|
for (let y = 0; y < dstH; y++) {
|
||||||
|
let sy = ((y + 0.5) * height / dstH) | 0
|
||||||
|
if (sy >= height) sy = height - 1
|
||||||
|
let dstRow = y * dstW * 3
|
||||||
|
for (let x = 0; x < dstW; x++) {
|
||||||
|
let sx = ((x + 0.5) * width / dstW) | 0
|
||||||
|
if (sx >= width) sx = width - 1
|
||||||
|
let off = sy * 560 + sx
|
||||||
|
let fb1 = sys.peek(DISP_RG - off) & 255
|
||||||
|
let fb2 = sys.peek(DISP_BA - off) & 255
|
||||||
|
let r, g, b
|
||||||
|
if (mode == 5) {
|
||||||
|
r = ((fb1 >>> 2) & 31) * 255 / 31
|
||||||
|
g = (((fb1 & 3) << 3) | ((fb2 >>> 5) & 7)) * 255 / 31
|
||||||
|
b = (fb2 & 31) * 255 / 31
|
||||||
|
} else if (mode == 8) {
|
||||||
|
r = fb1; g = fb2; b = sys.peek(DISP_PLANE3 - off) & 255
|
||||||
|
} else { // mode 4
|
||||||
|
r = (fb1 >>> 4) * 17
|
||||||
|
g = (fb1 & 15) * 17
|
||||||
|
b = (fb2 >>> 4) * 17
|
||||||
|
}
|
||||||
|
let di = dstRow + x * 3
|
||||||
|
dst[di] = r | 0; dst[di + 1] = g | 0; dst[di + 2] = b | 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── sampleGray / sampleColour from a RAM RGB888 frame ─────────────────────────
|
||||||
|
// Companions to the *Screen samplers that read a decoded frame straight out of a
|
||||||
|
// JS-addressable RGB888 RAM buffer (3 bytes/pixel, forward-addressed) instead of
|
||||||
|
// the GPU display planes. Backends that decode into RAM (TEV / TAV) use these so
|
||||||
|
// the ASCII renderer can sample the frame WITHOUT it ever being uploaded to the
|
||||||
|
// video adapter — the whole point of the generic RAM-frame model. Same cheap
|
||||||
|
// ~dstW·dstH·3 peek count and the same nearest-sampling geometry as the *Screen
|
||||||
|
// versions (sampleGrayRGB row-aligned; sampleColourRGB at the cell centre).
|
||||||
|
function sampleGrayRGB(srcPtr, width, height, dst, dstW, dstH) {
|
||||||
|
for (let y = 0; y < dstH; y++) {
|
||||||
|
let sy = (y * height / dstH) | 0
|
||||||
|
let dstRow = y * dstW
|
||||||
|
for (let x = 0; x < dstW; x++) {
|
||||||
|
let sx = (x * width / dstW) | 0
|
||||||
|
let o = srcPtr + (sy * width + sx) * 3
|
||||||
|
let r = sys.peek(o) & 255, g = sys.peek(o + 1) & 255, b = sys.peek(o + 2) & 255
|
||||||
|
dst[dstRow + x] = luma8(r, g, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleColourRGB(srcPtr, width, height, dst, dstW, dstH) {
|
||||||
|
for (let y = 0; y < dstH; y++) {
|
||||||
|
let sy = ((y + 0.5) * height / dstH) | 0
|
||||||
|
if (sy >= height) sy = height - 1
|
||||||
|
let dstRow = y * dstW * 3
|
||||||
|
for (let x = 0; x < dstW; x++) {
|
||||||
|
let sx = ((x + 0.5) * width / dstW) | 0
|
||||||
|
if (sx >= width) sx = width - 1
|
||||||
|
let o = srcPtr + (sy * width + sx) * 3
|
||||||
|
let di = dstRow + x * 3
|
||||||
|
dst[di] = sys.peek(o) & 255; dst[di + 1] = sys.peek(o + 1) & 255; dst[di + 2] = sys.peek(o + 2) & 255
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exports = {
|
||||||
|
MAGIC_MOV, MAGIC_TEV, MAGIC_TAV, MAGIC_TAP, MAGIC_UCF,
|
||||||
|
MP2_FRAME_SIZE, QLUT,
|
||||||
|
SSF_OP_NOP, SSF_OP_SHOW, SSF_OP_HIDE, SSF_OP_MOVE,
|
||||||
|
SSF_OP_UPLOAD_LOW_FONT, SSF_OP_UPLOAD_HIGH_FONT,
|
||||||
|
DISP_RG, DISP_BA,
|
||||||
|
openSeqread, readMagic, detectFormat, magicEquals,
|
||||||
|
luma8,
|
||||||
|
makeAudioRouter, makeSubtitleEngine, makeBias,
|
||||||
|
sampleGrayScreen, sampleColourScreen,
|
||||||
|
sampleGrayRGB, sampleColourRGB
|
||||||
|
}
|
||||||
192
assets/disk0/tvdos/include/mediadec_ipf.mjs
Normal file
192
assets/disk0/tvdos/include/mediadec_ipf.mjs
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
/*
|
||||||
|
* mediadec_ipf.mjs — legacy MOV / iPF backend for the mediadec library.
|
||||||
|
*
|
||||||
|
* Ported from assets/disk0/tvdos/bin/playmv1.js. Decodes iPF1 / iPF1a /
|
||||||
|
* iPF2 / iPF2a / iPF1-delta video packets straight to the 4bpp display planes
|
||||||
|
* (the proven, fast path), plus MP2 and raw-PCM audio and the background-colour
|
||||||
|
* packet. Presents at decode time (so blit() is a no-op); bias lighting is a
|
||||||
|
* separate player-driven stage via the bias() method; the ASCII path reads the
|
||||||
|
* planes back via common.sampleGrayScreen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const WIDTH = 560
|
||||||
|
const HEIGHT = 448
|
||||||
|
const FBUF_SIZE = WIDTH * HEIGHT
|
||||||
|
|
||||||
|
function create(magic, sr, fileLength, opts, common) {
|
||||||
|
const audioR = common.makeAudioRouter(sr)
|
||||||
|
|
||||||
|
// Header (after the 8-byte magic): w, h, fps, frameCount, queue info.
|
||||||
|
let width = sr.readShort()
|
||||||
|
let height = sr.readShort()
|
||||||
|
let fps = sr.readShort(); if (fps == 0) fps = 9999
|
||||||
|
const FRAME_COUNT = sr.readInt() % 16777216
|
||||||
|
sr.readShort() // skip unused
|
||||||
|
sr.readShort() // audioQueueInfo (unused for playback)
|
||||||
|
sr.skip(10)
|
||||||
|
|
||||||
|
graphics.setGraphicsMode(4)
|
||||||
|
graphics.clearPixels(255)
|
||||||
|
graphics.clearPixels2(240)
|
||||||
|
|
||||||
|
const FRAME_TIME = 1.0 / fps
|
||||||
|
const applyBias = common.makeBias(width, height, 4)
|
||||||
|
|
||||||
|
const ipfbuf = sys.malloc(FBUF_SIZE)
|
||||||
|
|
||||||
|
const info = {
|
||||||
|
format: 'ipf', width: width, height: height, fps: fps,
|
||||||
|
totalFrames: FRAME_COUNT, hasAudio: true, hasSubtitles: false,
|
||||||
|
isInterlaced: false, colourSpace: 'YCoCg', graphicsMode: 4, isStill: false
|
||||||
|
}
|
||||||
|
|
||||||
|
// No subtitles in iPF; expose an inert state object for the uniform API.
|
||||||
|
const subtitle = { visible: false, text: "", position: 0, useUnicode: false, dirty: false }
|
||||||
|
|
||||||
|
let akku = FRAME_TIME
|
||||||
|
let lastT = sys.nanoTime()
|
||||||
|
let doFrameskip = true
|
||||||
|
let autoBg = true
|
||||||
|
let framesRead = 0
|
||||||
|
let frameCount = 0
|
||||||
|
let paused = false
|
||||||
|
|
||||||
|
function setBackgroundPacket() {
|
||||||
|
autoBg = false
|
||||||
|
let rgbx = sr.readInt()
|
||||||
|
graphics.setBackground((rgbx & 0xFF000000) >>> 24, (rgbx & 0x00FF0000) >>> 16, (rgbx & 0x0000FF00) >>> 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
function step() {
|
||||||
|
const now = sys.nanoTime()
|
||||||
|
if (paused) { lastT = now; return { type: 'idle' } }
|
||||||
|
akku += (now - lastT) / 1000000000.0
|
||||||
|
lastT = now
|
||||||
|
|
||||||
|
if (sr.getReadCount() >= fileLength) return { type: 'eof' }
|
||||||
|
if (akku < FRAME_TIME) return { type: 'idle' }
|
||||||
|
|
||||||
|
// Drain accumulated time into a frame budget (frameskip drops late frames).
|
||||||
|
let frameUnit = 0
|
||||||
|
while (akku >= FRAME_TIME) { akku -= FRAME_TIME; frameUnit += 1 }
|
||||||
|
if (!doFrameskip) frameUnit = 1
|
||||||
|
|
||||||
|
let displayed = false
|
||||||
|
while (frameUnit >= 1 && sr.getReadCount() < fileLength) {
|
||||||
|
let packetType = sr.readShort()
|
||||||
|
|
||||||
|
if (0xFFFF === packetType) { // sync — one frame boundary
|
||||||
|
frameUnit -= 1
|
||||||
|
}
|
||||||
|
else if (0xFEFF === packetType) { // explicit background colour
|
||||||
|
setBackgroundPacket()
|
||||||
|
}
|
||||||
|
else if (packetType < 2047) { // video
|
||||||
|
if (packetType == 4 || packetType == 5 || packetType == 260 || packetType == 261) {
|
||||||
|
let decodefun = (packetType > 255) ? graphics.decodeIpf2 : graphics.decodeIpf1
|
||||||
|
let payloadLen = sr.readInt()
|
||||||
|
if (framesRead >= FRAME_COUNT) return { type: 'eof' }
|
||||||
|
framesRead += 1
|
||||||
|
let gz = sr.readBytes(payloadLen)
|
||||||
|
if (frameUnit == 1) {
|
||||||
|
gzip.decompFromTo(gz, payloadLen, ipfbuf)
|
||||||
|
decodefun(ipfbuf, common.DISP_RG, common.DISP_BA, width, height, (packetType & 255) == 5)
|
||||||
|
audioR.fire()
|
||||||
|
displayed = true
|
||||||
|
frameCount += 1
|
||||||
|
}
|
||||||
|
sys.free(gz)
|
||||||
|
}
|
||||||
|
else if (packetType == 516) { // iPF1-delta
|
||||||
|
doFrameskip = false
|
||||||
|
let payloadLen = sr.readInt()
|
||||||
|
if (framesRead >= FRAME_COUNT) return { type: 'eof' }
|
||||||
|
framesRead += 1
|
||||||
|
let gz = sr.readBytes(payloadLen)
|
||||||
|
if (frameUnit == 1) {
|
||||||
|
gzip.decompFromTo(gz, payloadLen, ipfbuf)
|
||||||
|
graphics.applyIpf1d(ipfbuf, common.DISP_RG, common.DISP_BA, width, height)
|
||||||
|
audioR.fire()
|
||||||
|
displayed = true
|
||||||
|
frameCount += 1
|
||||||
|
}
|
||||||
|
sys.free(gz)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw Error(`Unknown iPF video packet type ${packetType} at ${sr.getReadCount() - 2}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (4096 <= packetType && packetType <= 6143) { // audio
|
||||||
|
let readLength = (packetType >>> 8 == 17)
|
||||||
|
? common.MP2_FRAME_SIZE[(packetType & 255) >>> 1]
|
||||||
|
: sr.readInt()
|
||||||
|
if (readLength == 0) throw Error("iPF audio read length is zero")
|
||||||
|
if (packetType >>> 8 == 17) { // MP2
|
||||||
|
audioR.ensureMp2()
|
||||||
|
sr.readBytes(readLength, audioR.sndBase - 2368)
|
||||||
|
audio.mp2Decode()
|
||||||
|
audio.mp2UploadDecoded(0)
|
||||||
|
}
|
||||||
|
else if (packetType == 0x1000 || packetType == 0x1001) { // raw PCM
|
||||||
|
audioR.rawPcm(readLength)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw Error(`iPF audio packet type ${packetType} at ${sr.getReadCount() - 2}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Unknown — stop to avoid desync (matches old players' break).
|
||||||
|
return { type: 'eof' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return displayed ? { type: 'frame', frameCount: frameCount } : { type: 'idle' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The frame is already on the display planes (decoded there in step()), so
|
||||||
|
// presenting is a no-op. Bias lighting is a separate, player-driven stage
|
||||||
|
// (bias() below) and is skipped when an explicit background packet disabled it.
|
||||||
|
function blit() { }
|
||||||
|
|
||||||
|
// iPF decodes straight to the 4bpp display planes (no fast JS planar->RGB
|
||||||
|
// path), so — unlike TEV / TAV — there is no RAM RGB888 frame: the planes ARE
|
||||||
|
// the frame. sampleGray/sampleColour therefore read the planes back; this still
|
||||||
|
// costs no extra upload in ASCII mode, since decoding already wrote the planes.
|
||||||
|
function sampleGray(dst, w, h) { common.sampleGrayScreen(width, height, dst, w, h, 4) }
|
||||||
|
function sampleColour(dst, w, h) { common.sampleColourScreen(width, height, dst, w, h, 4) }
|
||||||
|
|
||||||
|
return {
|
||||||
|
info: info,
|
||||||
|
subtitle: subtitle,
|
||||||
|
get frameCount() { return frameCount },
|
||||||
|
get currentTimecodeNs() { return Math.floor(frameCount * (1000000000.0 / fps)) },
|
||||||
|
get videoRate() { return 0 },
|
||||||
|
get frameMode() { return ' ' },
|
||||||
|
cues: [],
|
||||||
|
|
||||||
|
// No generic RAM frame for iPF: it decodes straight to the display planes,
|
||||||
|
// so frameBuffer is 0. Use sampleGray/sampleColour to read the frame instead.
|
||||||
|
get frameBuffer() { return 0 },
|
||||||
|
get frameWidth() { return width },
|
||||||
|
get frameHeight() { return height },
|
||||||
|
|
||||||
|
step: step,
|
||||||
|
blit: blit,
|
||||||
|
bias() { if (autoBg) applyBias() }, // skipped when an explicit bg packet set the colour
|
||||||
|
sampleGray: sampleGray,
|
||||||
|
sampleColour: sampleColour,
|
||||||
|
pause(p) { paused = p; if (p) audioR.stop(); else { audioR.resume(); lastT = sys.nanoTime() } },
|
||||||
|
isPaused() { return paused },
|
||||||
|
setVolume(v) { audioR.setVolume(v) },
|
||||||
|
getVolume() { return audioR.getVolume() },
|
||||||
|
seekSeconds(_n) { /* iPF has no index; seeking unsupported */ },
|
||||||
|
cue(_d) { /* no cues */ },
|
||||||
|
|
||||||
|
close() {
|
||||||
|
sys.free(ipfbuf)
|
||||||
|
audioR.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exports = { create }
|
||||||
757
assets/disk0/tvdos/include/mediadec_tav.mjs
Normal file
757
assets/disk0/tvdos/include/mediadec_tav.mjs
Normal file
@@ -0,0 +1,757 @@
|
|||||||
|
/*
|
||||||
|
* mediadec_tav.mjs — TAV (TSVM Advanced Video) backend for the mediadec library.
|
||||||
|
*
|
||||||
|
* Ported from assets/disk0/tvdos/bin/playtav.js — the heaviest backend. DWT
|
||||||
|
* codec with: I/P frames, unified 3D-DWT GOPs (async triple-buffer + overflow
|
||||||
|
* queue), interlaced fields (yadif), TAP still images, UCF cue files +
|
||||||
|
* multi-file concatenation, Left/Right + cue seeking, screen masking, videotex
|
||||||
|
* (text-mode video), bundled MP2, and MP2/TAD/native-PCM audio, plus extended
|
||||||
|
* headers (XFPS) and timecode-driven subtitles.
|
||||||
|
*
|
||||||
|
* The original main-loop body becomes step(): each call performs one iteration
|
||||||
|
* (optional packet read + GOP state machine + a time-gated display) and, when a
|
||||||
|
* frame is due, materialises it into PRESENT_RGB (an RGB888 RAM buffer) before
|
||||||
|
* returning 'frame'. This is the one structural change from the original: every
|
||||||
|
* source (I/P ping-pong, progressive GOP in the Java-heap videoBuffer, interlaced
|
||||||
|
* GOP) is funnelled into one RAM frame, so blit() (upload to the adapter) and the
|
||||||
|
* ASCII sampler both read from RAM — neither reads pixels back off the display
|
||||||
|
* planes, and `frameBuffer` exposes the frame for arbitrary reuse.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TAV_VERSION = 1
|
||||||
|
const UCF_VERSION = 1
|
||||||
|
const ADDRESSING_EXTERNAL = 0x01
|
||||||
|
const ADDRESSING_INTERNAL = 0x02
|
||||||
|
const TAV_TEMPORAL_LEVELS = 2
|
||||||
|
|
||||||
|
const TAV_PACKET_IFRAME = 0x10
|
||||||
|
const TAV_PACKET_PFRAME = 0x11
|
||||||
|
const TAV_PACKET_GOP_UNIFIED = 0x12
|
||||||
|
const TAV_PACKET_AUDIO_MP2 = 0x20
|
||||||
|
const TAV_PACKET_AUDIO_NATIVE = 0x21
|
||||||
|
const TAV_PACKET_AUDIO_PCM_16LE = 0x22
|
||||||
|
const TAV_PACKET_AUDIO_ADPCM = 0x23
|
||||||
|
const TAV_PACKET_AUDIO_TAD = 0x24
|
||||||
|
const TAV_PACKET_SUBTITLE = 0x30
|
||||||
|
const TAV_PACKET_SUBTITLE_TC = 0x31
|
||||||
|
const TAV_PACKET_VIDEOTEX = 0x3F
|
||||||
|
const TAV_PACKET_AUDIO_BUNDLED = 0x40
|
||||||
|
const TAV_PACKET_EXTENDED_HDR = 0xEF
|
||||||
|
const TAV_PACKET_SCREEN_MASK = 0xF2
|
||||||
|
const TAV_PACKET_GOP_SYNC = 0xFC
|
||||||
|
const TAV_PACKET_TIMECODE = 0xFD
|
||||||
|
const TAV_PACKET_SYNC_NTSC = 0xFE
|
||||||
|
const TAV_PACKET_SYNC = 0xFF
|
||||||
|
const TAV_FILE_HEADER_FIRST = 0x1F
|
||||||
|
|
||||||
|
const BLIP = '\x847u'
|
||||||
|
|
||||||
|
const BUFFER_SLOTS = 3
|
||||||
|
const MAX_GOP_SIZE = 24
|
||||||
|
|
||||||
|
function create(magic, sr, fileLength, opts, common, isTap) {
|
||||||
|
const QLUT = common.QLUT
|
||||||
|
const audioR = common.makeAudioRouter(sr)
|
||||||
|
const subEngine = common.makeSubtitleEngine(sr, -133121) // TAV font-ROM base
|
||||||
|
const SND_BASE = audioR.sndBase
|
||||||
|
const AUDIO_DEVICE = audioR.playhead
|
||||||
|
|
||||||
|
// ── Header (32 bytes incl. magic) ───────────────────────────────────────
|
||||||
|
let version = sr.readOneByte()
|
||||||
|
let width = sr.readShort()
|
||||||
|
let height = sr.readShort()
|
||||||
|
let fps = sr.readOneByte()
|
||||||
|
let fps_num = fps, fps_den = 1
|
||||||
|
let totalFrames = sr.readInt()
|
||||||
|
let waveletFilter = sr.readOneByte()
|
||||||
|
let decompLevels = sr.readOneByte()
|
||||||
|
let qualityY = sr.readOneByte()
|
||||||
|
let qualityCo = sr.readOneByte()
|
||||||
|
let qualityCg = sr.readOneByte()
|
||||||
|
let extraFlags = sr.readOneByte()
|
||||||
|
let videoFlags = sr.readOneByte()
|
||||||
|
let qualityLevel = sr.readOneByte()
|
||||||
|
let channelLayout = sr.readOneByte()
|
||||||
|
let entropyCoder = sr.readOneByte()
|
||||||
|
let encoderPreset = sr.readOneByte()
|
||||||
|
sr.skip(2) // reserved + device orientation
|
||||||
|
let fileRole = sr.readOneByte()
|
||||||
|
|
||||||
|
let baseVersion = (version > 8) ? (version - 8) : version
|
||||||
|
let temporalMotionCoder = (version > 8) ? 1 : 0
|
||||||
|
if (baseVersion < 1 || baseVersion > 8) throw Error(`Unsupported TAV base version ${baseVersion}`)
|
||||||
|
|
||||||
|
const hasAudio = (extraFlags & 0x01) !== 0
|
||||||
|
const hasSubtitles = (extraFlags & 0x02) !== 0
|
||||||
|
let isInterlaced = (videoFlags & 0x01) !== 0
|
||||||
|
let isNTSC = (videoFlags & 0x02) !== 0
|
||||||
|
let isLossless = (videoFlags & 0x04) !== 0
|
||||||
|
let colourSpace = (version % 2 == 0) ? "ICtCp" : "YCoCg"
|
||||||
|
|
||||||
|
// ── Graphics ─────────────────────────────────────────────────────────────
|
||||||
|
graphics.setGraphicsMode(4)
|
||||||
|
graphics.setGraphicsMode(5)
|
||||||
|
graphics.clearPixels(0); graphics.clearPixels2(0); graphics.clearPixels3(0); graphics.clearPixels4(0)
|
||||||
|
let gpuGraphicsMode = graphics.getGraphicsMode()
|
||||||
|
|
||||||
|
let decodeHeight = isInterlaced ? (height >> 1) : height
|
||||||
|
let frametime = 1000000000.0 / fps
|
||||||
|
let FRAME_TIME = 1.0 / fps
|
||||||
|
let applyBias = common.makeBias(width, height, gpuGraphicsMode)
|
||||||
|
|
||||||
|
// ── Frame buffers ────────────────────────────────────────────────────────
|
||||||
|
let FRAME_SIZE = width * height * 3
|
||||||
|
const SLOT_SIZE = MAX_GOP_SIZE * width * height * 3
|
||||||
|
const RGB_BUFFER_A = sys.malloc(FRAME_SIZE)
|
||||||
|
const RGB_BUFFER_B = sys.malloc(FRAME_SIZE)
|
||||||
|
sys.memset(RGB_BUFFER_A, 0, FRAME_SIZE)
|
||||||
|
sys.memset(RGB_BUFFER_B, 0, FRAME_SIZE)
|
||||||
|
let CURRENT_RGB = RGB_BUFFER_A
|
||||||
|
let PREV_RGB = RGB_BUFFER_B
|
||||||
|
|
||||||
|
// Canonical decoded-frame buffer: every displayed frame is materialised here
|
||||||
|
// as RGB888, whatever its source (I/P ping-pong, progressive GOP in the
|
||||||
|
// Java-heap videoBuffer, or an interlaced GOP that needs deinterlacing). This
|
||||||
|
// is the one ~735 kB buffer the generic RAM-frame model costs: blit() uploads
|
||||||
|
// it, the ASCII path samples it, and `frameBuffer` exposes it to callers — so
|
||||||
|
// a frame can be reused without ever round-tripping through the display planes.
|
||||||
|
const PRESENT_RGB = sys.malloc(FRAME_SIZE)
|
||||||
|
sys.memset(PRESENT_RGB, 0, FRAME_SIZE)
|
||||||
|
|
||||||
|
const FIELD_SIZE = width * decodeHeight * 3
|
||||||
|
const CURR_FIELD = isInterlaced ? sys.malloc(FIELD_SIZE) : 0
|
||||||
|
const PREV_FIELD = isInterlaced ? sys.malloc(FIELD_SIZE) : 0
|
||||||
|
const NEXT_FIELD = isInterlaced ? sys.malloc(FIELD_SIZE) : 0
|
||||||
|
if (isInterlaced) { sys.memset(CURR_FIELD, 0, FIELD_SIZE); sys.memset(PREV_FIELD, 0, FIELD_SIZE); sys.memset(NEXT_FIELD, 0, FIELD_SIZE) }
|
||||||
|
let prevField = PREV_FIELD, curField = CURR_FIELD, nextField = NEXT_FIELD
|
||||||
|
|
||||||
|
const info = {
|
||||||
|
format: isTap ? 'tap' : 'tav', width: width, height: height, fps: fps,
|
||||||
|
totalFrames: totalFrames, hasAudio: hasAudio, hasSubtitles: hasSubtitles,
|
||||||
|
isInterlaced: isInterlaced, colourSpace: colourSpace, graphicsMode: gpuGraphicsMode,
|
||||||
|
isStill: !!isTap
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Playback / GOP state ─────────────────────────────────────────────────
|
||||||
|
let frameCount = 0, trueFrameCount = 0
|
||||||
|
let akku = FRAME_TIME, akku2 = 0.0
|
||||||
|
let firstFrameIssued = false
|
||||||
|
let nextFrameTime = 0
|
||||||
|
let paused = false
|
||||||
|
let decoderDbgInfo = {}
|
||||||
|
let videoRate = 0
|
||||||
|
let videoRateBin = []
|
||||||
|
|
||||||
|
let currentGopBufferSlot = 0, currentGopSize = 0, currentGopFrameIndex = 0
|
||||||
|
let readyGopData = null, decodingGopData = null
|
||||||
|
let asyncDecodeInProgress = false, asyncDecodeSlot = 0, asyncDecodeGopSize = 0
|
||||||
|
let asyncDecodePtr = 0, asyncDecodeStartTime = 0
|
||||||
|
let iframeReady = false
|
||||||
|
let shouldReadPackets = true
|
||||||
|
let overflowQueue = []
|
||||||
|
|
||||||
|
let predecodedPcmBuffer = null, predecodedPcmSize = 0, predecodedPcmOffset = 0
|
||||||
|
const PCM_UPLOAD_CHUNK = 2304
|
||||||
|
|
||||||
|
let cueElements = [], currentCueIndex = -1, skipped = false
|
||||||
|
let iframePositions = []
|
||||||
|
let currentFileIndex = 1
|
||||||
|
|
||||||
|
// Subtitle/timecode
|
||||||
|
let currentTimecodeNs = 0, baseTimecodeNs = 0, baseTimecodeFrameCount = 0
|
||||||
|
|
||||||
|
// Screen mask
|
||||||
|
let screenMaskEntries = [], screenMaskTop = 0, screenMaskRight = 0, screenMaskBottom = 0, screenMaskLeft = 0
|
||||||
|
|
||||||
|
// Deferred-display descriptor consumed by blit()/sampleGray().
|
||||||
|
let pending = { kind: null, src: 0, frameIndex: 0, bufferOffset: 0, frameNo: 0, gopSize: 0 }
|
||||||
|
|
||||||
|
let lastT = sys.nanoTime()
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
function updateDataRateBin(rate) { videoRateBin.push(rate); if (videoRateBin.length > 10) videoRateBin.shift() }
|
||||||
|
function getVideoRate() { let b = videoRateBin.reduce((a, c) => a + c, 0); return b * fps / videoRateBin.length }
|
||||||
|
|
||||||
|
function parseXFPS(s) {
|
||||||
|
let p = s.split("/")
|
||||||
|
if (p.length === 2) { let n = parseInt(p[0], 10), d = parseInt(p[1], 10); if (!isNaN(n) && !isNaN(d) && d > 0) { fps_num = n; fps_den = d; fps = n / d; return true } }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateScreenMask(frameNum) {
|
||||||
|
if (screenMaskEntries.length === 0) return
|
||||||
|
for (let i = screenMaskEntries.length - 1; i >= 0; i--) {
|
||||||
|
if (screenMaskEntries[i].frameNum <= frameNum) {
|
||||||
|
screenMaskTop = screenMaskEntries[i].top; screenMaskRight = screenMaskEntries[i].right
|
||||||
|
screenMaskBottom = screenMaskEntries[i].bottom; screenMaskLeft = screenMaskEntries[i].left
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function fillMaskedRegions() { return } // disabled upstream; kept for parity
|
||||||
|
|
||||||
|
function rotateFields() { let t = prevField; prevField = curField; curField = nextField; nextField = t }
|
||||||
|
|
||||||
|
function cleanupAsyncDecode() {
|
||||||
|
// asyncDecodePtr ALIASES readyGopData.compressedPtr / decodingGopData.compressedPtr:
|
||||||
|
// startAsyncGop records the same compressedPtr in both the asyncDecodePtr tracker and
|
||||||
|
// the GOP record (handleGopPacket cases + overflow drain). The normal free paths know
|
||||||
|
// this (free via one var, zero the other); a blind free of all three here double-frees
|
||||||
|
// and sys.free throws "No allocation for pointer", aborting close() before it frees the
|
||||||
|
// RGB frame buffers (leaking two width*height*3 allocations). Free each pointer once.
|
||||||
|
let freed = {}
|
||||||
|
function freeOnce(p) { if (p && !freed[p]) { freed[p] = true; sys.free(p) } }
|
||||||
|
if (asyncDecodeInProgress) freeOnce(asyncDecodePtr)
|
||||||
|
if (readyGopData) freeOnce(readyGopData.compressedPtr)
|
||||||
|
if (decodingGopData) freeOnce(decodingGopData.compressedPtr)
|
||||||
|
asyncDecodeInProgress = false; asyncDecodePtr = 0; asyncDecodeGopSize = 0
|
||||||
|
readyGopData = null; decodingGopData = null
|
||||||
|
if (predecodedPcmBuffer !== null) { sys.free(predecodedPcmBuffer); predecodedPcmBuffer = null; predecodedPcmSize = 0; predecodedPcmOffset = 0 }
|
||||||
|
currentGopSize = 0; currentGopFrameIndex = 0; nextFrameTime = 0; shouldReadPackets = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNearestIframe(targetFrame) {
|
||||||
|
if (iframePositions.length === 0) return null
|
||||||
|
let result = null
|
||||||
|
for (let i = iframePositions.length - 1; i >= 0; i--) { if (iframePositions[i].frameNum <= targetFrame) { result = iframePositions[i]; break } }
|
||||||
|
return result || iframePositions[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanForwardToIframe(targetFrame) {
|
||||||
|
let savedPos = sr.getReadCount()
|
||||||
|
try {
|
||||||
|
let scanFrameCount = frameCount
|
||||||
|
while (sr.getReadCount() < fileLength) {
|
||||||
|
let packetPos = sr.getReadCount()
|
||||||
|
let pType = sr.readOneByte()
|
||||||
|
if (pType === TAV_PACKET_SYNC || pType === TAV_PACKET_SYNC_NTSC) { if (pType === TAV_PACKET_SYNC) scanFrameCount++; continue }
|
||||||
|
if (pType === TAV_PACKET_IFRAME && scanFrameCount >= targetFrame) { iframePositions.push({ offset: packetPos, frameNum: scanFrameCount }); return { offset: packetPos, frameNum: scanFrameCount } }
|
||||||
|
if (pType !== TAV_PACKET_SYNC && pType !== TAV_PACKET_SYNC_NTSC && pType !== TAV_FILE_HEADER_FIRST) { let s = sr.readInt(); sr.skip(s) }
|
||||||
|
else if (pType === TAV_FILE_HEADER_FIRST) break
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch (e) { serial.printerr(`Scan error: ${e}`); return null }
|
||||||
|
finally { sr.seek(savedPos) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyNewHeader(h) {
|
||||||
|
version = h.version; width = h.width; height = h.height; fps = h.fps
|
||||||
|
totalFrames = h.totalFrames; waveletFilter = h.waveletFilter; decompLevels = h.decompLevels
|
||||||
|
qualityY = h.qualityY; qualityCo = h.qualityCo; qualityCg = h.qualityCg
|
||||||
|
extraFlags = h.extraFlags; videoFlags = h.videoFlags; qualityLevel = h.qualityLevel
|
||||||
|
channelLayout = h.channelLayout
|
||||||
|
baseVersion = (version > 8) ? (version - 8) : version
|
||||||
|
temporalMotionCoder = (version > 8) ? 1 : 0
|
||||||
|
isInterlaced = (videoFlags & 0x01) !== 0; isNTSC = (videoFlags & 0x02) !== 0; isLossless = (videoFlags & 0x04) !== 0
|
||||||
|
colourSpace = (version % 2 == 0) ? "ICtCp" : "YCoCg"
|
||||||
|
decodeHeight = isInterlaced ? (height >> 1) : height
|
||||||
|
frametime = 1000000000.0 / fps; FRAME_TIME = 1.0 / fps
|
||||||
|
applyBias = common.makeBias(width, height, gpuGraphicsMode)
|
||||||
|
info.width = width; info.height = height; info.fps = fps; info.totalFrames = totalFrames
|
||||||
|
info.isInterlaced = isInterlaced; info.colourSpace = colourSpace
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a header object on success, or null/error code.
|
||||||
|
function tryReadNextTAVHeader() {
|
||||||
|
let newMagic = new Array(7)
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < newMagic.length; i++) newMagic[i] = sr.readOneByte()
|
||||||
|
while (newMagic[0] == 255) { newMagic.shift(); newMagic[newMagic.length - 1] = sr.readOneByte() }
|
||||||
|
|
||||||
|
let isValidTAV = true, isValidUCF = true
|
||||||
|
for (let i = 0; i < newMagic.length; i++) { if (newMagic[i] !== common.MAGIC_TAV[i + 1]) isValidTAV = false }
|
||||||
|
for (let i = 0; i < newMagic.length; i++) { if (newMagic[i] !== common.MAGIC_UCF[i + 1]) isValidUCF = false }
|
||||||
|
if (!isValidTAV && !isValidUCF) { serial.printerr("Header mismatch: got " + newMagic.join()); return null }
|
||||||
|
|
||||||
|
if (isValidTAV) {
|
||||||
|
let h = {
|
||||||
|
version: sr.readOneByte(), width: sr.readShort(), height: sr.readShort(),
|
||||||
|
fps: sr.readOneByte(), totalFrames: sr.readInt(), waveletFilter: sr.readOneByte(),
|
||||||
|
decompLevels: sr.readOneByte(), qualityY: sr.readOneByte(), qualityCo: sr.readOneByte(),
|
||||||
|
qualityCg: sr.readOneByte(), extraFlags: sr.readOneByte(), videoFlags: sr.readOneByte(),
|
||||||
|
qualityLevel: sr.readOneByte(), channelLayout: sr.readOneByte(), fileRole: sr.readOneByte()
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 4; i++) sr.readOneByte() // reserved
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
// UCF cue file: parse cue table then recurse to the following TAV header.
|
||||||
|
let uver = sr.readOneByte()
|
||||||
|
if (uver !== UCF_VERSION) { serial.println(`Unsupported UCF version ${uver}`); return null }
|
||||||
|
let numElements = sr.readShort()
|
||||||
|
let cueSize = sr.readInt()
|
||||||
|
sr.skip(1)
|
||||||
|
for (let i = 0; i < numElements; i++) {
|
||||||
|
let el = {}
|
||||||
|
el.addressingModeAndIntent = sr.readOneByte()
|
||||||
|
el.addressingMode = el.addressingModeAndIntent & 15
|
||||||
|
let nameLen = sr.readShort()
|
||||||
|
el.name = sr.readString(nameLen)
|
||||||
|
if (el.addressingMode === ADDRESSING_EXTERNAL) { let pl = sr.readShort(); el.path = sr.readString(pl) }
|
||||||
|
else if (el.addressingMode === ADDRESSING_INTERNAL) {
|
||||||
|
let ob = []
|
||||||
|
for (let j = 0; j < 6; j++) ob.push(sr.readOneByte())
|
||||||
|
let low32 = 0; for (let j = 0; j < 4; j++) low32 |= (ob[j] << (j * 8))
|
||||||
|
let high16 = 0; for (let j = 4; j < 6; j++) high16 |= (ob[j] << ((j - 4) * 8))
|
||||||
|
el.offset = (high16 * 0x100000000) + (low32 >>> 0)
|
||||||
|
} else { serial.println(`Unknown addressing mode ${el.addressingMode}`); return null }
|
||||||
|
cueElements.push(el)
|
||||||
|
}
|
||||||
|
let rc = sr.getReadCount()
|
||||||
|
sr.skip(cueSize - rc + 1)
|
||||||
|
currentFileIndex -= 1
|
||||||
|
return tryReadNextTAVHeader()
|
||||||
|
} catch (e) { serial.printerr(e); return null }
|
||||||
|
}
|
||||||
|
|
||||||
|
function feedPredecodedPcm() {
|
||||||
|
if (predecodedPcmBuffer !== null && predecodedPcmOffset < predecodedPcmSize) {
|
||||||
|
let remaining = predecodedPcmSize - predecodedPcmOffset
|
||||||
|
let uploadSize = Math.min(PCM_UPLOAD_CHUNK, remaining)
|
||||||
|
sys.memcpy(predecodedPcmBuffer + predecodedPcmOffset, SND_BASE, uploadSize)
|
||||||
|
audio.setSampleUploadLength(AUDIO_DEVICE, uploadSize)
|
||||||
|
audio.startSampleUpload(AUDIO_DEVICE)
|
||||||
|
predecodedPcmOffset += uploadSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAsyncGop(d) {
|
||||||
|
graphics.tavDecodeGopToVideoBufferAsync(
|
||||||
|
d.compressedPtr, d.compressedSize, d.gopSize,
|
||||||
|
width, decodeHeight, baseVersion >= 5, qualityLevel,
|
||||||
|
QLUT[qualityY], QLUT[qualityCo], QLUT[qualityCg], channelLayout,
|
||||||
|
waveletFilter, decompLevels, TAV_TEMPORAL_LEVELS, entropyCoder,
|
||||||
|
d.slot * SLOT_SIZE, temporalMotionCoder, encoderPreset
|
||||||
|
)
|
||||||
|
asyncDecodeInProgress = true; asyncDecodeSlot = d.slot; asyncDecodeGopSize = d.gopSize
|
||||||
|
asyncDecodePtr = d.compressedPtr; asyncDecodeStartTime = sys.nanoTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Decode one I/P video packet into CURRENT_RGB (or field buffer) ───────
|
||||||
|
function decodeIPFrame(packetType, packetOffset) {
|
||||||
|
updateScreenMask(frameCount)
|
||||||
|
if (packetType === TAV_PACKET_IFRAME) iframePositions.push({ offset: packetOffset, frameNum: frameCount })
|
||||||
|
const compressedSize = sr.readInt()
|
||||||
|
let compressedPtr = sr.readBytes(compressedSize)
|
||||||
|
updateDataRateBin(compressedSize)
|
||||||
|
videoRate = compressedSize
|
||||||
|
try {
|
||||||
|
let decodeTarget = isInterlaced ? curField : CURRENT_RGB
|
||||||
|
decoderDbgInfo = graphics.tavDecodeCompressed(
|
||||||
|
compressedPtr, compressedSize, decodeTarget, PREV_RGB,
|
||||||
|
width, decodeHeight, qualityLevel,
|
||||||
|
QLUT[qualityY], QLUT[qualityCo], QLUT[qualityCg], channelLayout,
|
||||||
|
trueFrameCount, waveletFilter, decompLevels, isLossless, version, entropyCoder, encoderPreset
|
||||||
|
)
|
||||||
|
if (isInterlaced) {
|
||||||
|
graphics.tavDeinterlace(trueFrameCount, width, decodeHeight, prevField, curField, nextField, CURRENT_RGB, "yadif")
|
||||||
|
rotateFields()
|
||||||
|
}
|
||||||
|
iframeReady = true
|
||||||
|
} catch (e) { console.log(`TAV frame ${frameCount}: decode failed: ${e}`) }
|
||||||
|
finally { sys.free(compressedPtr) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GOP packet handling (Cases 1–5 + overflow) ──────────────────────────
|
||||||
|
function handleGopPacket() {
|
||||||
|
const gopSize = sr.readOneByte()
|
||||||
|
const compressedSize = sr.readInt()
|
||||||
|
let compressedPtr = sr.readBytes(compressedSize)
|
||||||
|
updateDataRateBin(compressedSize / gopSize)
|
||||||
|
decoderDbgInfo.frameMode = " "
|
||||||
|
|
||||||
|
if (gopSize > MAX_GOP_SIZE) { sys.free(compressedPtr); return }
|
||||||
|
|
||||||
|
if (currentGopSize === 0 && !asyncDecodeInProgress) {
|
||||||
|
if (asyncDecodePtr !== 0) { sys.free(asyncDecodePtr); asyncDecodePtr = 0 }
|
||||||
|
startAsyncGop({ compressedPtr, compressedSize, gopSize, slot: currentGopBufferSlot })
|
||||||
|
}
|
||||||
|
else if (currentGopSize === 0 && asyncDecodeInProgress) {
|
||||||
|
if (readyGopData === null) {
|
||||||
|
readyGopData = { gopSize, slot: (currentGopBufferSlot + 1) % BUFFER_SLOTS, compressedPtr, compressedSize, needsDecode: true, startTime: 0, timeRemaining: 0 }
|
||||||
|
} else if (decodingGopData === null) {
|
||||||
|
decodingGopData = { gopSize, slot: (currentGopBufferSlot + 2) % BUFFER_SLOTS, compressedPtr, compressedSize, needsDecode: true, startTime: 0, timeRemaining: 0 }
|
||||||
|
shouldReadPackets = false
|
||||||
|
} else { sys.free(compressedPtr) }
|
||||||
|
}
|
||||||
|
else if (currentGopSize > 0 && readyGopData === null && !asyncDecodeInProgress && graphics.tavDecodeGopIsComplete()) {
|
||||||
|
let nextSlot = (currentGopBufferSlot + 1) % BUFFER_SLOTS
|
||||||
|
startAsyncGop({ compressedPtr, compressedSize, gopSize, slot: nextSlot })
|
||||||
|
readyGopData = { gopSize, slot: nextSlot, compressedPtr, startTime: asyncDecodeStartTime, timeRemaining: 0 }
|
||||||
|
shouldReadPackets = false
|
||||||
|
}
|
||||||
|
else if (currentGopSize > 0 && readyGopData !== null && decodingGopData === null && !asyncDecodeInProgress && graphics.tavDecodeGopIsComplete()) {
|
||||||
|
let decodingSlot = (currentGopBufferSlot + 2) % BUFFER_SLOTS
|
||||||
|
startAsyncGop({ compressedPtr, compressedSize, gopSize, slot: decodingSlot })
|
||||||
|
decodingGopData = { gopSize, slot: decodingSlot, compressedPtr, startTime: asyncDecodeStartTime, timeRemaining: 0 }
|
||||||
|
shouldReadPackets = false
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
overflowQueue.push({ gopSize, compressedPtr, compressedSize })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── One packet ───────────────────────────────────────────────────────────
|
||||||
|
// Returns true if a multi-file header switch happened (caller emits 'newfile').
|
||||||
|
function readOnePacket() {
|
||||||
|
let packetOffset = sr.getReadCount()
|
||||||
|
let packetType = sr.readOneByte()
|
||||||
|
let newfile = false
|
||||||
|
|
||||||
|
if (packetType == TAV_FILE_HEADER_FIRST) {
|
||||||
|
let nh = tryReadNextTAVHeader()
|
||||||
|
if (nh) {
|
||||||
|
applyNewHeader(nh)
|
||||||
|
frameCount = 0; akku = 0.0; akku2 = 0.0; firstFrameIssued = false
|
||||||
|
baseTimecodeNs = 0; baseTimecodeFrameCount = 0; currentTimecodeNs = 0
|
||||||
|
audio.purgeQueue(AUDIO_DEVICE)
|
||||||
|
currentFileIndex++
|
||||||
|
if (skipped) skipped = false; else currentCueIndex++
|
||||||
|
packetType = sr.readOneByte()
|
||||||
|
newfile = true
|
||||||
|
} else { return { eof: true } }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (packetType === TAV_PACKET_SYNC || packetType == TAV_PACKET_SYNC_NTSC) {
|
||||||
|
// vestigial in TAV's time-based model
|
||||||
|
}
|
||||||
|
else if (packetType === TAV_PACKET_IFRAME || packetType === TAV_PACKET_PFRAME) {
|
||||||
|
decodeIPFrame(packetType, packetOffset)
|
||||||
|
}
|
||||||
|
else if (packetType === TAV_PACKET_GOP_UNIFIED) {
|
||||||
|
handleGopPacket()
|
||||||
|
}
|
||||||
|
else if (packetType === TAV_PACKET_GOP_SYNC) {
|
||||||
|
sr.readOneByte() // frames-in-GOP (ignored; time-based)
|
||||||
|
if (currentGopSize > 0 && readyGopData !== null && decodingGopData !== null) shouldReadPackets = false
|
||||||
|
}
|
||||||
|
else if (packetType === TAV_PACKET_AUDIO_BUNDLED) {
|
||||||
|
let totalAudioSize = sr.readInt()
|
||||||
|
audioR.ensureMp2()
|
||||||
|
let mp2Buffer = sys.malloc(totalAudioSize)
|
||||||
|
sr.readBytes(totalAudioSize, mp2Buffer)
|
||||||
|
const estimatedPcmSize = totalAudioSize * 12
|
||||||
|
predecodedPcmBuffer = sys.malloc(estimatedPcmSize); predecodedPcmSize = 0; predecodedPcmOffset = 0
|
||||||
|
const MP2_DECODE_CHUNK = 2304
|
||||||
|
let srcOffset = 0
|
||||||
|
while (srcOffset < totalAudioSize) {
|
||||||
|
let chunkSize = Math.min(MP2_DECODE_CHUNK, totalAudioSize - srcOffset)
|
||||||
|
sys.memcpy(mp2Buffer + srcOffset, SND_BASE - 2368, chunkSize)
|
||||||
|
audio.mp2Decode()
|
||||||
|
sys.memcpy(SND_BASE, predecodedPcmBuffer + predecodedPcmSize, 2304)
|
||||||
|
predecodedPcmSize += 2304
|
||||||
|
srcOffset += chunkSize
|
||||||
|
}
|
||||||
|
sys.free(mp2Buffer)
|
||||||
|
}
|
||||||
|
else if (packetType === TAV_PACKET_AUDIO_MP2) { let len = sr.readInt(); audioR.mp2(len) }
|
||||||
|
else if (packetType === TAV_PACKET_AUDIO_TAD) { let sampleLen = sr.readShort(); let payloadLen = sr.readInt(); audioR.tad(sampleLen, payloadLen) }
|
||||||
|
else if (packetType === TAV_PACKET_AUDIO_NATIVE) { let zstdLen = sr.readInt(); audioR.nativePcm(zstdLen) }
|
||||||
|
else if (packetType === TAV_PACKET_SUBTITLE) { let size = sr.readInt(); subEngine.parseLegacy(size) }
|
||||||
|
else if (packetType === TAV_PACKET_SUBTITLE_TC) { let size = sr.readInt(); subEngine.parseTC(size) }
|
||||||
|
else if (packetType === TAV_PACKET_VIDEOTEX) {
|
||||||
|
let compressedSize = sr.readInt()
|
||||||
|
let compressedPtr = sr.readBytes(compressedSize)
|
||||||
|
let decompressedPtr = sys.malloc(8192)
|
||||||
|
gzip.decompFromTo(compressedPtr, compressedSize, decompressedPtr)
|
||||||
|
let rows = sys.peek(decompressedPtr), cols = sys.peek(decompressedPtr + 1)
|
||||||
|
let gridSize = rows * cols
|
||||||
|
sys.memcpy(decompressedPtr + 2, -1302529, gridSize * 3)
|
||||||
|
sys.free(compressedPtr); sys.free(decompressedPtr)
|
||||||
|
iframeReady = true // displayed via the I/P path (uploads CURRENT_RGB under the text)
|
||||||
|
}
|
||||||
|
else if (packetType === TAV_PACKET_EXTENDED_HDR) {
|
||||||
|
let numPairs = sr.readShort()
|
||||||
|
for (let i = 0; i < numPairs; i++) {
|
||||||
|
let keyBytes = sr.readBytes(4); let key = ""
|
||||||
|
for (let j = 0; j < 4; j++) key += String.fromCharCode(sys.peek(keyBytes + j))
|
||||||
|
sys.free(keyBytes)
|
||||||
|
let valueType = sr.readOneByte()
|
||||||
|
if (valueType === 0x04) { sr.readInt(); sr.readInt() }
|
||||||
|
else if (valueType === 0x10) {
|
||||||
|
let length = sr.readShort(); let dataBytes = sr.readBytes(length); let dataStr = ""
|
||||||
|
for (let j = 0; j < length; j++) dataStr += String.fromCharCode(sys.peek(dataBytes + j))
|
||||||
|
sys.free(dataBytes)
|
||||||
|
if (key === "XFPS" && parseXFPS(dataStr)) { frametime = 1000000000.0 / fps; FRAME_TIME = 1.0 / fps }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (packetType === TAV_PACKET_SCREEN_MASK) {
|
||||||
|
let frameNum = sr.readInt()
|
||||||
|
let top = sr.readOneByte() | (sr.readOneByte() << 8)
|
||||||
|
let right = sr.readOneByte() | (sr.readOneByte() << 8)
|
||||||
|
let bottom = sr.readOneByte() | (sr.readOneByte() << 8)
|
||||||
|
let left = sr.readOneByte() | (sr.readOneByte() << 8)
|
||||||
|
screenMaskEntries.push({ frameNum, top, right, bottom, left })
|
||||||
|
}
|
||||||
|
else if (packetType === TAV_PACKET_TIMECODE) {
|
||||||
|
let lo = sr.readInt(), hi = sr.readInt()
|
||||||
|
let tc = hi * 0x100000000 + (lo >>> 0)
|
||||||
|
baseTimecodeNs = tc; baseTimecodeFrameCount = frameCount; currentTimecodeNs = tc
|
||||||
|
decoderDbgInfo.frameMode = BLIP
|
||||||
|
}
|
||||||
|
else if (packetType == 0x00) { /* stray arg-terminator byte */ }
|
||||||
|
else { serial.println(`TAV unknown packet 0x${packetType.toString(16)}`); return { eof: true } }
|
||||||
|
|
||||||
|
return { newfile: newfile }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── step(): one main-loop iteration ─────────────────────────────────────
|
||||||
|
function step() {
|
||||||
|
// TAP still: show the pre-decoded frame once, then idle.
|
||||||
|
if (isTap) {
|
||||||
|
if (!firstFrameIssued) { firstFrameIssued = true; pending = { kind: 'rgb', src: CURRENT_RGB, frameNo: 0 }; materializeFrame(); return { type: 'frame', frameCount: 1 } }
|
||||||
|
return { type: 'idle' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// EOF: stream exhausted and nothing buffered.
|
||||||
|
if (sr.getReadCount() >= fileLength && currentGopSize === 0 && readyGopData === null && decodingGopData === null && !asyncDecodeInProgress && overflowQueue.length === 0) {
|
||||||
|
return { type: 'eof' }
|
||||||
|
}
|
||||||
|
|
||||||
|
let newfileEvent = false
|
||||||
|
|
||||||
|
// 1) Gated packet read.
|
||||||
|
if (shouldReadPackets && !paused && sr.getReadCount() < fileLength) {
|
||||||
|
let r = readOnePacket()
|
||||||
|
if (r.eof) return { type: 'eof' }
|
||||||
|
if (r.newfile) newfileEvent = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time accumulation (only while a GOP plays / after first frame).
|
||||||
|
let t2 = sys.nanoTime()
|
||||||
|
if (!paused && firstFrameIssued) {
|
||||||
|
let dt = (t2 - lastT) / 1000000000.0
|
||||||
|
if (currentGopSize > 0) akku += dt
|
||||||
|
akku2 += dt
|
||||||
|
}
|
||||||
|
lastT = t2
|
||||||
|
|
||||||
|
let displayed = false
|
||||||
|
|
||||||
|
// Step 1: first-GOP decode wait.
|
||||||
|
if (asyncDecodeInProgress && currentGopSize === 0) {
|
||||||
|
if (!graphics.tavDecodeGopIsComplete()) { sys.sleep(1) }
|
||||||
|
else {
|
||||||
|
const res = graphics.tavDecodeGopGetResult(); decoderDbgInfo = res[1]
|
||||||
|
currentGopSize = asyncDecodeGopSize; currentGopFrameIndex = 0; currentGopBufferSlot = asyncDecodeSlot
|
||||||
|
asyncDecodeInProgress = false
|
||||||
|
if (nextFrameTime === 0) nextFrameTime = sys.nanoTime()
|
||||||
|
if (!(currentGopSize > 0 && readyGopData !== null && decodingGopData !== null)) shouldReadPackets = true
|
||||||
|
sys.free(asyncDecodePtr); asyncDecodePtr = 0; asyncDecodeGopSize = 0
|
||||||
|
if (readyGopData !== null && readyGopData.needsDecode) {
|
||||||
|
startAsyncGop(readyGopData); readyGopData.needsDecode = false; readyGopData.startTime = asyncDecodeStartTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2a: display I/P frame when due.
|
||||||
|
if (!paused && iframeReady && currentGopSize === 0) {
|
||||||
|
if (nextFrameTime === 0) nextFrameTime = sys.nanoTime()
|
||||||
|
while (sys.nanoTime() < nextFrameTime && !paused) sys.sleep(1)
|
||||||
|
if (!paused) {
|
||||||
|
pending = { kind: 'rgb', src: CURRENT_RGB, frameNo: trueFrameCount }
|
||||||
|
materializeFrame()
|
||||||
|
audioR.fire()
|
||||||
|
firstFrameIssued = true
|
||||||
|
frameCount++; trueFrameCount++; iframeReady = false
|
||||||
|
currentTimecodeNs = Math.floor(akku2 * 1000000000)
|
||||||
|
if (subEngine.hasEvents()) subEngine.poll(currentTimecodeNs)
|
||||||
|
let t = CURRENT_RGB; CURRENT_RGB = PREV_RGB; PREV_RGB = t
|
||||||
|
nextFrameTime += frametime
|
||||||
|
displayed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2&3: display GOP frame when due.
|
||||||
|
if (!paused && currentGopSize > 0 && currentGopFrameIndex < currentGopSize) {
|
||||||
|
while (sys.nanoTime() < nextFrameTime && !paused) sys.sleep(1)
|
||||||
|
if (!paused) {
|
||||||
|
if (isInterlaced) pending = { kind: 'gop-interlaced', frameIndex: currentGopFrameIndex, bufferOffset: currentGopBufferSlot * SLOT_SIZE, frameNo: trueFrameCount, gopSize: currentGopSize }
|
||||||
|
else pending = { kind: 'gop', frameIndex: currentGopFrameIndex, bufferOffset: currentGopBufferSlot * SLOT_SIZE, frameNo: trueFrameCount, gopSize: currentGopSize }
|
||||||
|
materializeFrame()
|
||||||
|
audioR.fire()
|
||||||
|
firstFrameIssued = true
|
||||||
|
currentGopFrameIndex++; frameCount++; trueFrameCount++
|
||||||
|
currentTimecodeNs = Math.floor(akku2 * 1000000000)
|
||||||
|
if (subEngine.hasEvents()) subEngine.poll(currentTimecodeNs)
|
||||||
|
feedPredecodedPcm()
|
||||||
|
if (decodingGopData !== null && decodingGopData.needsDecode && graphics.tavDecodeGopIsComplete()) {
|
||||||
|
startAsyncGop(decodingGopData); decodingGopData.needsDecode = false; decodingGopData.startTime = asyncDecodeStartTime
|
||||||
|
}
|
||||||
|
nextFrameTime += frametime
|
||||||
|
displayed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4–7: GOP finished → transition to ready GOP (triple-buffer rotate).
|
||||||
|
if (!paused && currentGopSize > 0 && currentGopFrameIndex >= currentGopSize) {
|
||||||
|
if (readyGopData !== null) {
|
||||||
|
if (readyGopData.needsDecode) { startAsyncGop(readyGopData); readyGopData.needsDecode = false; readyGopData.startTime = sys.nanoTime() }
|
||||||
|
while (!graphics.tavDecodeGopIsComplete() && !paused) sys.sleep(1)
|
||||||
|
if (!paused) {
|
||||||
|
graphics.tavDecodeGopGetResult()
|
||||||
|
sys.free(readyGopData.compressedPtr)
|
||||||
|
currentGopBufferSlot = readyGopData.slot; currentGopSize = readyGopData.gopSize; currentGopFrameIndex = 0
|
||||||
|
readyGopData = decodingGopData; decodingGopData = null
|
||||||
|
if (graphics.tavDecodeGopIsComplete()) { asyncDecodeInProgress = false; asyncDecodePtr = 0; asyncDecodeGopSize = 0 }
|
||||||
|
shouldReadPackets = true
|
||||||
|
// Drain overflow queue into a free slot.
|
||||||
|
if (overflowQueue.length > 0 && !asyncDecodeInProgress && graphics.tavDecodeGopIsComplete()) {
|
||||||
|
const ov = overflowQueue.shift()
|
||||||
|
let targetSlot = (readyGopData === null) ? (currentGopBufferSlot + 1) % BUFFER_SLOTS
|
||||||
|
: (decodingGopData === null) ? (currentGopBufferSlot + 2) % BUFFER_SLOTS : -1
|
||||||
|
if (targetSlot < 0) overflowQueue.unshift(ov)
|
||||||
|
else {
|
||||||
|
startAsyncGop({ compressedPtr: ov.compressedPtr, compressedSize: ov.compressedSize, gopSize: ov.gopSize, slot: targetSlot })
|
||||||
|
let rec = { gopSize: ov.gopSize, slot: targetSlot, compressedPtr: ov.compressedPtr, startTime: asyncDecodeStartTime, timeRemaining: 0 }
|
||||||
|
if (readyGopData === null) readyGopData = rec; else decodingGopData = rec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentGopSize = 0; currentGopFrameIndex = 0; shouldReadPackets = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sys.sleep(1)
|
||||||
|
|
||||||
|
if (newfileEvent) return { type: 'newfile', frameCount: frameCount }
|
||||||
|
return displayed ? { type: 'frame', frameCount: frameCount } : { type: 'idle' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Materialise / present / sample ───────────────────────────────────────
|
||||||
|
// Land the just-decoded frame in PRESENT_RGB (RGB888 RAM), whatever its source.
|
||||||
|
// Called by step() the moment a frame becomes due, so blit() (upload) and the
|
||||||
|
// ASCII sampler can both consume it from RAM and neither path has to read the
|
||||||
|
// pixels back off the display planes.
|
||||||
|
// rgb : I/P (or TAP still) — already RGB888 in CURRENT_RGB; copy in.
|
||||||
|
// gop : progressive GOP frame in the Java-heap videoBuffer; copy out.
|
||||||
|
// gop-interlaced : interlaced GOP fields; deinterlace into PRESENT_RGB.
|
||||||
|
function materializeFrame() {
|
||||||
|
if (pending.kind === 'rgb') {
|
||||||
|
sys.memcpy(pending.src, PRESENT_RGB, FRAME_SIZE)
|
||||||
|
} else if (pending.kind === 'gop') {
|
||||||
|
graphics.tavCopyGopFrameToRGB(pending.frameIndex, width, height, pending.bufferOffset, PRESENT_RGB)
|
||||||
|
} else if (pending.kind === 'gop-interlaced') {
|
||||||
|
graphics.tavDeinterlaceGopFrameToRGB(pending.frameIndex, pending.gopSize, width, decodeHeight, height, pending.frameNo, pending.bufferOffset, prevField, curField, nextField, PRESENT_RGB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Present the materialised RAM frame to the display planes (with dithering).
|
||||||
|
// bias lighting is a separate, player-driven stage (bias() below).
|
||||||
|
function blit() {
|
||||||
|
graphics.uploadRGBToFramebuffer(PRESENT_RGB, width, height, pending.frameNo, false)
|
||||||
|
if (pending.kind === 'gop' || pending.kind === 'gop-interlaced') { updateScreenMask(frameCount); fillMaskedRegions() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The current frame already sits in PRESENT_RGB (materialised in step()), so
|
||||||
|
// sampling never touches the display planes — ASCII mode needs no blit().
|
||||||
|
function sampleGray(dst, w, h) { common.sampleGrayRGB(PRESENT_RGB, width, height, dst, w, h) }
|
||||||
|
function sampleColour(dst, w, h) { common.sampleColourRGB(PRESENT_RGB, width, height, dst, w, h) }
|
||||||
|
|
||||||
|
// ── TAP still: decode the single image now ──────────────────────────────
|
||||||
|
if (isTap) {
|
||||||
|
let packetType = sr.readOneByte()
|
||||||
|
while (packetType !== TAV_PACKET_IFRAME && sr.getReadCount() < fileLength) {
|
||||||
|
if (packetType === TAV_PACKET_EXTENDED_HDR) {
|
||||||
|
let numPairs = sr.readShort()
|
||||||
|
for (let i = 0; i < numPairs; i++) {
|
||||||
|
let kb = sr.readBytes(4); let key = ""; for (let j = 0; j < 4; j++) key += String.fromCharCode(sys.peek(kb + j)); sys.free(kb)
|
||||||
|
let vt = sr.readOneByte()
|
||||||
|
if (vt === 0x04) sr.skip(8)
|
||||||
|
else if (vt === 0x10) { let len = sr.readShort(); let db = sr.readBytes(len); if (key === "XFPS") { let s = ""; for (let j = 0; j < len; j++) s += String.fromCharCode(sys.peek(db + j)); parseXFPS(s) } sys.free(db) }
|
||||||
|
}
|
||||||
|
} else if (packetType === TAV_PACKET_SCREEN_MASK) { sr.skip(12) }
|
||||||
|
else if (packetType === TAV_PACKET_TIMECODE) { sr.skip(8) }
|
||||||
|
else { let size = sr.readInt(); sr.skip(size) }
|
||||||
|
packetType = sr.readOneByte()
|
||||||
|
}
|
||||||
|
if (packetType === TAV_PACKET_IFRAME) {
|
||||||
|
const compressedSize = sr.readInt()
|
||||||
|
const compressedPtr = sr.readBytes(compressedSize)
|
||||||
|
graphics.tavDecodeCompressed(compressedPtr, compressedSize, CURRENT_RGB, PREV_RGB, width, height, qualityLevel, QLUT[qualityY], QLUT[qualityCo], QLUT[qualityCg], channelLayout, 0, waveletFilter, decompLevels, isLossless, version, entropyCoder, 2)
|
||||||
|
sys.free(compressedPtr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
info: info,
|
||||||
|
subtitle: subEngine.subtitle,
|
||||||
|
get frameCount() { return frameCount },
|
||||||
|
get currentTimecodeNs() { return currentTimecodeNs },
|
||||||
|
get akku() { return akku2 },
|
||||||
|
get videoRate() { return getVideoRate() },
|
||||||
|
get frameMode() { return decoderDbgInfo.frameMode || ' ' },
|
||||||
|
get qY() { return decoderDbgInfo.qY }, get qCo() { return decoderDbgInfo.qCo }, get qCg() { return decoderDbgInfo.qCg },
|
||||||
|
get cues() { return cueElements },
|
||||||
|
get currentCueIndex() { return currentCueIndex },
|
||||||
|
get currentFileIndex() { return currentFileIndex },
|
||||||
|
|
||||||
|
// Generic RAM frame: RGB888 buffer holding the current decoded frame,
|
||||||
|
// valid after step() returns 'frame'. Callers may read it for their own use.
|
||||||
|
get frameBuffer() { return PRESENT_RGB },
|
||||||
|
get frameWidth() { return width },
|
||||||
|
get frameHeight() { return height },
|
||||||
|
|
||||||
|
step: step,
|
||||||
|
blit: blit,
|
||||||
|
bias() { applyBias() },
|
||||||
|
sampleGray: sampleGray,
|
||||||
|
sampleColour: sampleColour,
|
||||||
|
|
||||||
|
pause(p) {
|
||||||
|
paused = p
|
||||||
|
if (p) audioR.stop()
|
||||||
|
else { audioR.resume(); lastT = sys.nanoTime() }
|
||||||
|
},
|
||||||
|
isPaused() { return paused },
|
||||||
|
setVolume(v) { audioR.setVolume(v) },
|
||||||
|
getVolume() { return audioR.getVolume() },
|
||||||
|
|
||||||
|
seekSeconds(n) {
|
||||||
|
if (isTap) return
|
||||||
|
let target
|
||||||
|
if (n < 0) target = Math.max(0, frameCount - Math.floor(fps * (-n)))
|
||||||
|
else target = Math.min(totalFrames - 1, frameCount + Math.floor(fps * n))
|
||||||
|
let seekTarget = findNearestIframe(target)
|
||||||
|
if (n > 0 && (!seekTarget || seekTarget.frameNum <= frameCount)) seekTarget = scanForwardToIframe(target)
|
||||||
|
if (!seekTarget) return
|
||||||
|
if (n > 0 && seekTarget.frameNum <= frameCount) return
|
||||||
|
cleanupAsyncDecode()
|
||||||
|
sr.seek(seekTarget.offset)
|
||||||
|
frameCount = seekTarget.frameNum; akku = FRAME_TIME; akku2 += n; firstFrameIssued = false
|
||||||
|
baseTimecodeNs = Math.floor(seekTarget.frameNum * frametime); baseTimecodeFrameCount = seekTarget.frameNum; currentTimecodeNs = baseTimecodeNs
|
||||||
|
subEngine.resetTo(baseTimecodeNs)
|
||||||
|
audio.purgeQueue(AUDIO_DEVICE)
|
||||||
|
skipped = true
|
||||||
|
},
|
||||||
|
|
||||||
|
cue(d) {
|
||||||
|
if (cueElements.length === 0) return
|
||||||
|
currentCueIndex = (d < 0)
|
||||||
|
? ((currentCueIndex <= 0) ? cueElements.length - 1 : currentCueIndex - 1)
|
||||||
|
: ((currentCueIndex >= cueElements.length - 1) ? 0 : currentCueIndex + 1)
|
||||||
|
let cue = cueElements[currentCueIndex]
|
||||||
|
if (cue.addressingMode !== ADDRESSING_INTERNAL) return
|
||||||
|
cleanupAsyncDecode()
|
||||||
|
sr.seek(cue.offset)
|
||||||
|
frameCount = 0; akku = FRAME_TIME; akku2 = 0.0; firstFrameIssued = false
|
||||||
|
baseTimecodeNs = 0; baseTimecodeFrameCount = 0; currentTimecodeNs = 0
|
||||||
|
subEngine.resetTo(0)
|
||||||
|
audio.purgeQueue(AUDIO_DEVICE)
|
||||||
|
skipped = true
|
||||||
|
},
|
||||||
|
|
||||||
|
close() {
|
||||||
|
cleanupAsyncDecode()
|
||||||
|
sys.free(RGB_BUFFER_A); sys.free(RGB_BUFFER_B); sys.free(PRESENT_RGB)
|
||||||
|
if (isInterlaced) { sys.free(CURR_FIELD); sys.free(PREV_FIELD); sys.free(NEXT_FIELD) }
|
||||||
|
while (overflowQueue.length > 0) { const ov = overflowQueue.shift(); sys.free(ov.compressedPtr) }
|
||||||
|
audioR.close()
|
||||||
|
sys.poke(-1299460, 20); sys.poke(-1299460, 21) // reset font ROM
|
||||||
|
graphics.resetPalette()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exports = { create }
|
||||||
233
assets/disk0/tvdos/include/mediadec_tev.mjs
Normal file
233
assets/disk0/tvdos/include/mediadec_tev.mjs
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
/*
|
||||||
|
* mediadec_tev.mjs — TEV (TSVM Enhanced Video) backend for the mediadec library.
|
||||||
|
*
|
||||||
|
* Ported from assets/disk0/tvdos/bin/playtev.js. DCT codec, YCoCg-R / ICtCp,
|
||||||
|
* motion compensation, optional deblock / boundary-aware decoding, interlaced
|
||||||
|
* (yadif/bwdif) support, NTSC frame duplication, MP2 audio, SSF + SSF-TC
|
||||||
|
* subtitles. Decodes into an off-screen RGB888 ping-pong buffer (the generic
|
||||||
|
* RAM frame): blit() uploads it to the adapter, while the ASCII path samples it
|
||||||
|
* straight from RAM, and `frameBuffer` exposes it for arbitrary reuse.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TEV_VERSION_YCOCG = 2
|
||||||
|
const TEV_VERSION_ICtCp = 3
|
||||||
|
|
||||||
|
const TEV_PACKET_IFRAME = 0x10
|
||||||
|
const TEV_PACKET_PFRAME = 0x11
|
||||||
|
const TEV_PACKET_AUDIO_MP2 = 0x20
|
||||||
|
const TEV_PACKET_SUBTITLE = 0x30
|
||||||
|
const TEV_PACKET_SUBTITLE_TC = 0x31
|
||||||
|
const TEV_PACKET_SYNC = 0xFF
|
||||||
|
|
||||||
|
function create(magic, sr, fileLength, opts, common) {
|
||||||
|
const audioR = common.makeAudioRouter(sr)
|
||||||
|
const subEngine = common.makeSubtitleEngine(sr, -1300607) // TEV font-ROM base
|
||||||
|
|
||||||
|
// Header
|
||||||
|
let version = sr.readOneByte()
|
||||||
|
if (version !== TEV_VERSION_YCOCG && version !== TEV_VERSION_ICtCp) {
|
||||||
|
throw Error(`Unsupported TEV version: ${version}`)
|
||||||
|
}
|
||||||
|
let width = sr.readShort()
|
||||||
|
let height = sr.readShort()
|
||||||
|
let fps = sr.readOneByte()
|
||||||
|
let totalFrames = sr.readInt()
|
||||||
|
let qualityY = sr.readOneByte()
|
||||||
|
let qualityCo = sr.readOneByte()
|
||||||
|
let qualityCg = sr.readOneByte()
|
||||||
|
let flags = sr.readOneByte()
|
||||||
|
let videoFlags = sr.readOneByte()
|
||||||
|
sr.readOneByte() // unused
|
||||||
|
const hasAudio = !!(flags & 1)
|
||||||
|
const hasSubtitle = !!(flags & 2)
|
||||||
|
const isInterlaced = !!(videoFlags & 1)
|
||||||
|
const isNTSC = !!(videoFlags & 2)
|
||||||
|
const colorSpace = (version === TEV_VERSION_ICtCp) ? "ICtCp" : "YCoCg"
|
||||||
|
|
||||||
|
// Options
|
||||||
|
const debugMV = !!opts.debugMotionVectors
|
||||||
|
const enableDeblock = !!opts.enableDeblocking
|
||||||
|
const enableBoundaryAware = !!opts.enableBoundaryAwareDecoding
|
||||||
|
const deinterlaceAlgo = opts.deinterlaceAlgorithm || "yadif"
|
||||||
|
|
||||||
|
graphics.setGraphicsMode(4)
|
||||||
|
graphics.clearPixels(0)
|
||||||
|
graphics.clearPixels2(0)
|
||||||
|
// NB: palette 0 is translucent black by default (used by the playgui chrome);
|
||||||
|
// we deliberately do NOT redefine it, nor reset it on close.
|
||||||
|
|
||||||
|
const FRAME_PIXELS = width * height
|
||||||
|
const FRAME_SIZE = 560 * 448 * 3
|
||||||
|
const FIELD_SIZE = 560 * 224 * 3
|
||||||
|
|
||||||
|
const RGB_BUFFER_A = sys.malloc(FRAME_SIZE)
|
||||||
|
const RGB_BUFFER_B = sys.malloc(FRAME_SIZE)
|
||||||
|
sys.memset(RGB_BUFFER_A, 0, FRAME_PIXELS * 3)
|
||||||
|
sys.memset(RGB_BUFFER_B, 0, FRAME_PIXELS * 3)
|
||||||
|
let CURRENT_RGB = RGB_BUFFER_A
|
||||||
|
let PREV_RGB = RGB_BUFFER_B
|
||||||
|
|
||||||
|
const CURR_FIELD = isInterlaced ? sys.malloc(FIELD_SIZE) : 0
|
||||||
|
const PREV_FIELD = isInterlaced ? sys.malloc(FIELD_SIZE) : 0
|
||||||
|
const NEXT_FIELD = isInterlaced ? sys.malloc(FIELD_SIZE) : 0
|
||||||
|
if (isInterlaced) {
|
||||||
|
sys.memset(CURR_FIELD, 0, FIELD_SIZE); sys.memset(PREV_FIELD, 0, FIELD_SIZE); sys.memset(NEXT_FIELD, 0, FIELD_SIZE)
|
||||||
|
}
|
||||||
|
let curField = CURR_FIELD, prevField = PREV_FIELD, nextField = NEXT_FIELD
|
||||||
|
|
||||||
|
sys.memset(common.DISP_RG, 0, FRAME_PIXELS)
|
||||||
|
sys.memset(common.DISP_BA, 15, FRAME_PIXELS)
|
||||||
|
|
||||||
|
const FRAME_TIME = 1.0 / fps
|
||||||
|
const FRAME_TIME_NS = 1000000000.0 / fps
|
||||||
|
const applyBias = common.makeBias(width, height, 4)
|
||||||
|
|
||||||
|
const info = {
|
||||||
|
format: 'tev', width: width, height: height, fps: fps,
|
||||||
|
totalFrames: totalFrames, hasAudio: hasAudio, hasSubtitles: hasSubtitle,
|
||||||
|
isInterlaced: isInterlaced, colourSpace: colorSpace, graphicsMode: 4, isStill: false
|
||||||
|
}
|
||||||
|
|
||||||
|
let akku = FRAME_TIME
|
||||||
|
let lastT = sys.nanoTime()
|
||||||
|
let frameCount = 0
|
||||||
|
let trueFrameCount = 0
|
||||||
|
let frameDuped = false
|
||||||
|
let paused = false
|
||||||
|
let currentFrameType = "I"
|
||||||
|
let videoRate = 0
|
||||||
|
let currentFrameSrc = CURRENT_RGB
|
||||||
|
|
||||||
|
const blockDataPtr = sys.malloc(FRAME_SIZE)
|
||||||
|
|
||||||
|
function rotateFields() { let t = prevField; prevField = curField; curField = nextField; nextField = t }
|
||||||
|
|
||||||
|
function decodeVideo(packetType) {
|
||||||
|
let payloadLen = sr.readInt()
|
||||||
|
videoRate = payloadLen
|
||||||
|
let compressedPtr = sr.readBytes(payloadLen)
|
||||||
|
currentFrameType = (packetType == TEV_PACKET_IFRAME) ? "I" : "P"
|
||||||
|
|
||||||
|
// NTSC frame duplication: drop one decode every 1000 frames (≈29.97).
|
||||||
|
if (isNTSC && frameCount % 1000 == 501 && !frameDuped) {
|
||||||
|
frameDuped = true
|
||||||
|
sys.free(compressedPtr)
|
||||||
|
return false // keep previous frame on screen
|
||||||
|
}
|
||||||
|
frameDuped = false
|
||||||
|
|
||||||
|
let actualSize
|
||||||
|
try { actualSize = gzip.decompFromTo(compressedPtr, payloadLen, blockDataPtr) }
|
||||||
|
catch (e) { sys.free(compressedPtr); serial.println(`TEV frame ${frameCount}: gzip failed: ${e}`); return false }
|
||||||
|
|
||||||
|
let decodingHeight = isInterlaced ? (height / 2) | 0 : height
|
||||||
|
if (isInterlaced) {
|
||||||
|
graphics.tevDecode(blockDataPtr, nextField, curField, width, decodingHeight, qualityY, qualityCo, qualityCg, trueFrameCount, debugMV, version, enableDeblock, enableBoundaryAware)
|
||||||
|
graphics.tevDeinterlace(trueFrameCount, width, decodingHeight, prevField, curField, nextField, CURRENT_RGB, deinterlaceAlgo)
|
||||||
|
rotateFields()
|
||||||
|
} else {
|
||||||
|
graphics.tevDecode(blockDataPtr, CURRENT_RGB, PREV_RGB, width, decodingHeight, qualityY, qualityCo, qualityCg, trueFrameCount, debugMV, version, enableDeblock, enableBoundaryAware)
|
||||||
|
}
|
||||||
|
currentFrameSrc = CURRENT_RGB
|
||||||
|
sys.free(compressedPtr)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function step() {
|
||||||
|
const now = sys.nanoTime()
|
||||||
|
if (paused) { lastT = now; return { type: 'idle' } }
|
||||||
|
akku += (now - lastT) / 1000000000.0
|
||||||
|
lastT = now
|
||||||
|
|
||||||
|
if (sr.getReadCount() >= fileLength) return { type: 'eof' }
|
||||||
|
if (akku < FRAME_TIME) return { type: 'idle' }
|
||||||
|
|
||||||
|
let packetType = sr.readOneByte()
|
||||||
|
|
||||||
|
if (packetType == TEV_PACKET_SYNC) {
|
||||||
|
akku -= FRAME_TIME
|
||||||
|
frameCount++
|
||||||
|
trueFrameCount++
|
||||||
|
// Swap ping-pong: the just-shown frame becomes the reference.
|
||||||
|
let t = CURRENT_RGB; CURRENT_RGB = PREV_RGB; PREV_RGB = t
|
||||||
|
return { type: 'idle' }
|
||||||
|
}
|
||||||
|
else if (packetType == TEV_PACKET_IFRAME || packetType == TEV_PACKET_PFRAME) {
|
||||||
|
let shown = decodeVideo(packetType)
|
||||||
|
if (shown) {
|
||||||
|
// audio after frame 0 (progressive) / frame 1 (interlaced)
|
||||||
|
if (!isInterlaced || frameCount > 0) audioR.fire()
|
||||||
|
if (subEngine.hasEvents()) subEngine.poll(frameCount * FRAME_TIME_NS)
|
||||||
|
return { type: 'frame', frameCount: frameCount }
|
||||||
|
}
|
||||||
|
return { type: 'idle' }
|
||||||
|
}
|
||||||
|
else if (packetType == TEV_PACKET_AUDIO_MP2) {
|
||||||
|
let audioLen = sr.readInt()
|
||||||
|
audioR.mp2(audioLen)
|
||||||
|
return { type: 'idle' }
|
||||||
|
}
|
||||||
|
else if (packetType == TEV_PACKET_SUBTITLE) {
|
||||||
|
let size = sr.readInt(); subEngine.parseLegacy(size); return { type: 'idle' }
|
||||||
|
}
|
||||||
|
else if (packetType == TEV_PACKET_SUBTITLE_TC) {
|
||||||
|
let size = sr.readInt(); subEngine.parseTC(size); return { type: 'idle' }
|
||||||
|
}
|
||||||
|
else if (packetType == 0x00) {
|
||||||
|
return { type: 'idle' } // stray arg-terminator byte
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
serial.println(`TEV unknown packet type 0x${packetType.toString(16)}`)
|
||||||
|
return { type: 'eof' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Present the decoded RAM frame to the display planes (with dithering).
|
||||||
|
// bias lighting is a separate, player-driven stage (bias() below).
|
||||||
|
function blit() {
|
||||||
|
graphics.uploadRGBToFramebuffer(currentFrameSrc, width, height, frameCount, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The decoded frame already sits in currentFrameSrc (RGB888 RAM), so sampling
|
||||||
|
// reads RAM directly — ASCII mode needs no blit() / display-plane round-trip.
|
||||||
|
function sampleGray(dst, w, h) { common.sampleGrayRGB(currentFrameSrc, width, height, dst, w, h) }
|
||||||
|
function sampleColour(dst, w, h) { common.sampleColourRGB(currentFrameSrc, width, height, dst, w, h) }
|
||||||
|
|
||||||
|
return {
|
||||||
|
info: info,
|
||||||
|
subtitle: subEngine.subtitle,
|
||||||
|
get frameCount() { return frameCount },
|
||||||
|
get currentTimecodeNs() { return Math.floor(frameCount * FRAME_TIME_NS) },
|
||||||
|
get videoRate() { return videoRate * fps },
|
||||||
|
get frameMode() { return currentFrameType },
|
||||||
|
get qY() { return qualityY }, get qCo() { return qualityCo }, get qCg() { return qualityCg },
|
||||||
|
cues: [],
|
||||||
|
|
||||||
|
// Generic RAM frame: the current decoded frame as RGB888 (the live
|
||||||
|
// ping-pong buffer), valid after step() returns 'frame'. Callers may read it.
|
||||||
|
get frameBuffer() { return currentFrameSrc },
|
||||||
|
get frameWidth() { return width },
|
||||||
|
get frameHeight() { return height },
|
||||||
|
|
||||||
|
step: step,
|
||||||
|
blit: blit,
|
||||||
|
bias() { applyBias() },
|
||||||
|
sampleGray: sampleGray,
|
||||||
|
sampleColour: sampleColour,
|
||||||
|
pause(p) { paused = p; if (p) audioR.stop(); else { audioR.resume(); lastT = sys.nanoTime() } },
|
||||||
|
isPaused() { return paused },
|
||||||
|
setVolume(v) { audioR.setVolume(v) },
|
||||||
|
getVolume() { return audioR.getVolume() },
|
||||||
|
seekSeconds(_n) { /* TEV has no index; seeking unsupported */ },
|
||||||
|
cue(_d) {},
|
||||||
|
|
||||||
|
close() {
|
||||||
|
sys.free(blockDataPtr)
|
||||||
|
sys.free(RGB_BUFFER_A); sys.free(RGB_BUFFER_B)
|
||||||
|
if (isInterlaced) { sys.free(CURR_FIELD); sys.free(PREV_FIELD); sys.free(NEXT_FIELD) }
|
||||||
|
audioR.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exports = { create }
|
||||||
581
assets/disk0/tvdos/include/synopsis.mjs
Normal file
581
assets/disk0/tvdos/include/synopsis.mjs
Normal file
@@ -0,0 +1,581 @@
|
|||||||
|
/*
|
||||||
|
* synopsis.mjs -- TVDOS Synopsis Format (TSF) loader, cache and completion
|
||||||
|
* resolver.
|
||||||
|
*
|
||||||
|
* A TSF document (see the "Command Synopsis Format" chapter of the manual and
|
||||||
|
* tvdos_synopsis_format_draft.md) is a JSON file describing a command's
|
||||||
|
* command-line interface: its options, positional arguments, subcommands,
|
||||||
|
* argument types, completion sources and validation constraints. This module
|
||||||
|
* turns those documents into the answers command.js needs while the user is
|
||||||
|
* typing -- chiefly "what can come next at the caret?".
|
||||||
|
*
|
||||||
|
* Where the documents live
|
||||||
|
* ------------------------
|
||||||
|
* * Apps : colocated with the executable, full filename + ".synopsis"
|
||||||
|
* e.g. \tvdos\bin\geturl.js -> \tvdos\bin\geturl.js.synopsis
|
||||||
|
* * Built-in : the shell coreutils are not files, so their synopses live
|
||||||
|
* coreutils in a dedicated directory, \tvdos\synopsis\<name>.synopsis.
|
||||||
|
* Aliases (ls -> dir, rm -> del, ...) resolve to the
|
||||||
|
* canonical command's file automatically.
|
||||||
|
*
|
||||||
|
* Caching (two layers)
|
||||||
|
* --------------------
|
||||||
|
* Parsing JSON and compiling a completion model on every TAB would be wasteful,
|
||||||
|
* so results are cached:
|
||||||
|
* 1. In memory, for the life of the shell session (command.js keeps the
|
||||||
|
* require() handle, so this object persists across keystrokes).
|
||||||
|
* 2. On disk, under \tvdos\cache\synopsis\, as a compiled-model blob. The
|
||||||
|
* TSVM file layer exposes no reliable modification time, so the cache is
|
||||||
|
* validated against the source file's *byte size* plus a CACHE_VERSION
|
||||||
|
* stamp. A source edit that preserves the byte count will not invalidate
|
||||||
|
* the disk cache -- an accepted trade-off. Every disk operation is
|
||||||
|
* best-effort: a failure never breaks completion, it just falls back to
|
||||||
|
* re-parsing.
|
||||||
|
*
|
||||||
|
* Public API
|
||||||
|
* ----------
|
||||||
|
* getCompletion(commandToken, prefixTokens, word) -> result | { ok:false }
|
||||||
|
* getModel(commandToken) -> compiled model | null
|
||||||
|
* getSummary(commandToken) -> one-line summary | null
|
||||||
|
* getUsage(commandToken) -> generated usage string | null
|
||||||
|
* resolveSynopsisPath(commandToken) -> full path | null
|
||||||
|
* registerProvider(name, fn) -> register an `internal` completion source
|
||||||
|
* clearCache() -> drop the in-memory caches
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TSF_VERSION = "1.0"
|
||||||
|
const CACHE_VERSION = 1 // bump when compile()'s output shape changes
|
||||||
|
const SYN_DIR = "\\tvdos\\synopsis" // built-in / coreutil synopses
|
||||||
|
const CACHE_PARENT = "\\tvdos\\cache"
|
||||||
|
const CACHE_DIR = "\\tvdos\\cache\\synopsis" // compiled-model disk cache
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// small local helpers (deliberately mirror command.js internals)
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
function drive() { return (typeof _G !== "undefined" && _G.shell) ? _G.shell.getCurrentDrive() : "A" }
|
||||||
|
|
||||||
|
function trimStartRevSlash(s) {
|
||||||
|
let cnt = 0
|
||||||
|
while (cnt < s.length && s[cnt] === '\\') cnt += 1
|
||||||
|
return s.substring(cnt)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidDriveLetter(l) {
|
||||||
|
if (typeof l === 'string' || l instanceof String) {
|
||||||
|
let lc = l.charCodeAt(0)
|
||||||
|
return (l == '$' || 65 <= lc && lc <= 90 || 97 <= lc && lc <= 122)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileExists(p) { try { return files.open(p).exists } catch (e) { return false } }
|
||||||
|
function fileSize(p) { try { return files.open(p).size | 0 } catch (e) { return 0 } }
|
||||||
|
function readText(p) { try { let f = files.open(p); return f.exists ? f.sread() : null } catch (e) { return null } }
|
||||||
|
|
||||||
|
let _cacheDirReady = false
|
||||||
|
function ensureCacheDir() {
|
||||||
|
if (_cacheDirReady) return
|
||||||
|
let d = drive()
|
||||||
|
let segs = [CACHE_PARENT, CACHE_DIR]
|
||||||
|
for (let i = 0; i < segs.length; i++) {
|
||||||
|
try { let f = files.open(`${d}:${segs[i]}`); if (!f.exists) f.mkDir() } catch (e) { /* best-effort */ }
|
||||||
|
}
|
||||||
|
_cacheDirReady = true
|
||||||
|
}
|
||||||
|
function writeText(p, s) {
|
||||||
|
try { ensureCacheDir(); files.open(p).swrite(s); return true } catch (e) { return false }
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// executable + synopsis-path resolution
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// Find the runnable file a bare command name would resolve to, mirroring the
|
||||||
|
// search order command.js uses (current directory, then PATH, with PATHEXT).
|
||||||
|
function findExecutable(cmd) {
|
||||||
|
let d = drive()
|
||||||
|
if (isValidDriveLetter(cmd[0]) && cmd[1] === ':') {
|
||||||
|
try { let f = files.open(cmd); return f.exists ? f.fullPath : null } catch (e) { return null }
|
||||||
|
}
|
||||||
|
let pwd = (typeof _G !== "undefined" && _G.shell) ? _G.shell.getPwd() : [""]
|
||||||
|
let searchDir = (cmd.charAt(0) === '/') ? [""] : ["/" + pwd.join("/")].concat(_TVDOS.getPath())
|
||||||
|
let pathExt = []
|
||||||
|
if (cmd.split(".")[1] === undefined) {
|
||||||
|
(_TVDOS.variables.PATHEXT || "").split(';').forEach(function (it) {
|
||||||
|
if (it.length) { pathExt.push(it); pathExt.push(it.toUpperCase()) }
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
pathExt.push("")
|
||||||
|
}
|
||||||
|
for (let i = 0; i < searchDir.length; i++) {
|
||||||
|
for (let j = 0; j < pathExt.length; j++) {
|
||||||
|
let search = searchDir[i]; if (!search.endsWith('\\')) search += '\\'
|
||||||
|
let sp = trimStartRevSlash(search + cmd + pathExt[j])
|
||||||
|
try { let f = files.open(`${d}:\\${sp}`); if (f.exists) return f.fullPath } catch (e) { /* keep looking */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a command token to the full path of its .synopsis document, or null.
|
||||||
|
function resolveSynopsisPath(token) {
|
||||||
|
if (!token) return null
|
||||||
|
let d = drive()
|
||||||
|
let lower = token.toLowerCase()
|
||||||
|
|
||||||
|
// built-in coreutil? -> \tvdos\synopsis\<name>.synopsis
|
||||||
|
// try the typed name first, then any alias that shares the same function so
|
||||||
|
// `ls` finds dir.synopsis without a duplicate file.
|
||||||
|
if (typeof _G !== "undefined" && _G.shell && _G.shell.coreutils &&
|
||||||
|
typeof _G.shell.coreutils[lower] === 'function') {
|
||||||
|
let fn = _G.shell.coreutils[lower]
|
||||||
|
let names = [lower]
|
||||||
|
Object.keys(_G.shell.coreutils).forEach(function (k) {
|
||||||
|
if (_G.shell.coreutils[k] === fn && names.indexOf(k) < 0) names.push(k)
|
||||||
|
})
|
||||||
|
for (let i = 0; i < names.length; i++) {
|
||||||
|
let p = `${d}:${SYN_DIR}\\${names[i]}.synopsis`
|
||||||
|
if (fileExists(p)) return p
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// app -> <executable>.synopsis colocated with the program
|
||||||
|
let exe = findExecutable(token)
|
||||||
|
if (!exe) return null
|
||||||
|
let p = exe + ".synopsis"
|
||||||
|
return fileExists(p) ? p : null
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// TSF compilation -- raw document -> completion model
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
function compile(doc) {
|
||||||
|
if (!doc || typeof doc !== 'object') return null
|
||||||
|
let symbols = doc.symbols || {}
|
||||||
|
|
||||||
|
// ---- options: every symbol of kind "option" is an offerable flag ----
|
||||||
|
let flags = [] // one entry per option symbol
|
||||||
|
let flagMap = {} // flag string ("-r", "--recursive", "--no-recursive") -> entry
|
||||||
|
Object.keys(symbols).forEach(function (id) {
|
||||||
|
let s = symbols[id]
|
||||||
|
if (!s || s.kind !== 'option') return
|
||||||
|
let value = s.value || null
|
||||||
|
let hasValue = !!value
|
||||||
|
let entry = {
|
||||||
|
id: id,
|
||||||
|
long: s.long || null,
|
||||||
|
short: s.short || null,
|
||||||
|
summary: s.summary || '',
|
||||||
|
negatable: !!s.negatable,
|
||||||
|
hasValue: hasValue,
|
||||||
|
valueRequired: hasValue ? (value.required !== false) : false,
|
||||||
|
value: value
|
||||||
|
}
|
||||||
|
flags.push(entry)
|
||||||
|
if (entry.long) flagMap[entry.long] = entry
|
||||||
|
if (entry.short) flagMap[entry.short] = entry
|
||||||
|
if (entry.negatable && entry.long) flagMap['--no-' + entry.long.replace(/^--/, '')] = entry
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- positionals + subcommands, in grammar order ----
|
||||||
|
let positionals = []
|
||||||
|
let subcommands = []
|
||||||
|
let seenSub = {}
|
||||||
|
function walk(node, inRepeat) {
|
||||||
|
if (!node || typeof node !== 'object') return
|
||||||
|
switch (node.type) {
|
||||||
|
case 'sequence':
|
||||||
|
case 'choice':
|
||||||
|
(node.children || []).forEach(function (c) { walk(c, inRepeat) }); break
|
||||||
|
case 'optional': walk(node.child, inRepeat); break
|
||||||
|
case 'repeat': walk(node.child, true); break
|
||||||
|
case 'oneOrMore': walk(node.child, true); break
|
||||||
|
case 'reference': {
|
||||||
|
let sym = symbols[node.symbol]
|
||||||
|
if (!sym) return
|
||||||
|
if (sym.kind === 'positional') {
|
||||||
|
positionals.push({
|
||||||
|
id: node.symbol,
|
||||||
|
name: sym.name || node.symbol,
|
||||||
|
type: sym.type || 'string',
|
||||||
|
values: sym.values || null,
|
||||||
|
completion: sym.completion || null,
|
||||||
|
summary: sym.summary || '',
|
||||||
|
repeatable: !!inRepeat
|
||||||
|
})
|
||||||
|
} else if (sym.kind === 'subcommand') {
|
||||||
|
if (!seenSub[node.symbol]) {
|
||||||
|
seenSub[node.symbol] = true
|
||||||
|
subcommands.push({ name: sym.name || node.symbol, summary: sym.summary || '', tsf: sym.tsf || null })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break // option / group references add no positional ordering
|
||||||
|
}
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(doc.synopsis, false)
|
||||||
|
|
||||||
|
return {
|
||||||
|
cacheVersion: CACHE_VERSION,
|
||||||
|
tsfVersion: doc.tsfVersion || null,
|
||||||
|
name: doc.name || null,
|
||||||
|
summary: doc.summary || '',
|
||||||
|
description: doc.description || '',
|
||||||
|
symbols: symbols,
|
||||||
|
synopsisNode: doc.synopsis || null,
|
||||||
|
flags: flags,
|
||||||
|
flagMap: flagMap,
|
||||||
|
positionals: positionals,
|
||||||
|
subcommands: subcommands,
|
||||||
|
constraints: doc.constraints || []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// loading + caching
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
let _mem = {} // synopsisPath -> { srcSize, model }
|
||||||
|
let _resolveMemo = {} // "drive|pwd|token" -> synopsisPath | null
|
||||||
|
|
||||||
|
function cacheKey(p) {
|
||||||
|
// FNV-1a 32-bit hash, prefixed with a sanitised basename for readability.
|
||||||
|
let h = 2166136261
|
||||||
|
for (let i = 0; i < p.length; i++) { h ^= p.charCodeAt(i); h = (h * 16777619) >>> 0 }
|
||||||
|
let base = (p.split(/[\\/]/).pop() || 'syn').replace(/[^A-Za-z0-9._-]/g, '_')
|
||||||
|
return base + '_' + ('00000000' + h.toString(16)).slice(-8)
|
||||||
|
}
|
||||||
|
function cachePath(synPath) { return `${drive()}:${CACHE_DIR}\\${cacheKey(synPath)}.json` }
|
||||||
|
|
||||||
|
function loadModel(synPath) {
|
||||||
|
if (!synPath) return null
|
||||||
|
let srcSize = fileSize(synPath)
|
||||||
|
|
||||||
|
// 1. in-memory
|
||||||
|
let mem = _mem[synPath]
|
||||||
|
if (mem && mem.srcSize === srcSize) return mem.model
|
||||||
|
|
||||||
|
// 2. disk cache (size + version validated)
|
||||||
|
let cachedText = readText(cachePath(synPath))
|
||||||
|
if (cachedText) {
|
||||||
|
try {
|
||||||
|
let c = JSON.parse(cachedText)
|
||||||
|
if (c && c.cacheVersion === CACHE_VERSION && c.srcSize === srcSize && c.model) {
|
||||||
|
_mem[synPath] = { srcSize: srcSize, model: c.model }
|
||||||
|
return c.model
|
||||||
|
}
|
||||||
|
} catch (e) { /* corrupt cache -> re-parse */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. parse the source
|
||||||
|
let src = readText(synPath)
|
||||||
|
if (src === null) return null
|
||||||
|
let doc
|
||||||
|
try { doc = JSON.parse(src) }
|
||||||
|
catch (e) { try { serial.printerr("synopsis: bad JSON in " + synPath + ": " + e) } catch (_) {} ; return null }
|
||||||
|
let model = compile(doc)
|
||||||
|
if (!model) return null
|
||||||
|
|
||||||
|
_mem[synPath] = { srcSize: srcSize, model: model }
|
||||||
|
writeText(cachePath(synPath), JSON.stringify({ cacheVersion: CACHE_VERSION, srcSize: srcSize, model: model }))
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModel(token) {
|
||||||
|
if (!token) return null
|
||||||
|
let key = drive() + '|' + ((typeof _G !== "undefined" && _G.shell) ? _G.shell.getPwdString() : '') + '|' + token
|
||||||
|
let synPath
|
||||||
|
if (Object.prototype.hasOwnProperty.call(_resolveMemo, key)) synPath = _resolveMemo[key]
|
||||||
|
else { synPath = resolveSynopsisPath(token); _resolveMemo[key] = synPath }
|
||||||
|
return synPath ? loadModel(synPath) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCache() { _mem = {}; _resolveMemo = {}; _cacheDirReady = false }
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// internal completion providers (for `"completion": { "method": "internal" }`)
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
let _providers = {}
|
||||||
|
function registerProvider(name, fn) { _providers[name] = fn }
|
||||||
|
function safeProvider(name, word, model) {
|
||||||
|
let fn = _providers[name]
|
||||||
|
if (!fn) return []
|
||||||
|
try { return fn(word, model) || [] } catch (e) { return [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// "commands" -- runnable command names (coreutils + PATH executables).
|
||||||
|
registerProvider('commands', function (word) {
|
||||||
|
word = (word || '').toLowerCase()
|
||||||
|
let out = [], seen = {}
|
||||||
|
function add(n) { let k = n.toLowerCase(); if (seen[k]) return; seen[k] = true; out.push(n) }
|
||||||
|
if (typeof _G !== "undefined" && _G.shell && _G.shell.coreutils)
|
||||||
|
Object.keys(_G.shell.coreutils).forEach(function (k) { if (k.toLowerCase().indexOf(word) === 0) add(k) })
|
||||||
|
try {
|
||||||
|
let d = drive()
|
||||||
|
let exts = (_TVDOS.variables.PATHEXT || "").split(';')
|
||||||
|
.filter(function (e) { return e.length }).map(function (e) { return e.toLowerCase() })
|
||||||
|
_TVDOS.getPath().forEach(function (dir) {
|
||||||
|
let full = (dir === '') ? `${d}:\\` : `${d}:${dir.charAt(0) === '\\' ? dir : '\\' + dir}`
|
||||||
|
try {
|
||||||
|
let f = files.open(full); if (!f.exists || !f.isDirectory) return
|
||||||
|
;(f.list() || []).forEach(function (it) {
|
||||||
|
if (it.isDirectory) return
|
||||||
|
let nl = (it.name || '').toLowerCase()
|
||||||
|
if (!exts.some(function (e) { return nl.endsWith(e) })) return
|
||||||
|
let nm = it.name
|
||||||
|
exts.forEach(function (e) { if (nm.toLowerCase().endsWith(e)) nm = nm.substring(0, nm.length - e.length) })
|
||||||
|
if (nm.toLowerCase().indexOf(word) === 0) add(nm)
|
||||||
|
})
|
||||||
|
} catch (e) { /* skip unreadable dir */ }
|
||||||
|
})
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
// "envvars" -- environment variable names.
|
||||||
|
registerProvider('envvars', function (word) {
|
||||||
|
word = word || ''
|
||||||
|
try {
|
||||||
|
return Object.keys(_TVDOS.variables || {}).filter(function (k) {
|
||||||
|
return k.toLowerCase().indexOf(word.toLowerCase()) === 0
|
||||||
|
})
|
||||||
|
} catch (e) { return [] }
|
||||||
|
})
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// completion query
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
// Turn a `values` array (bare values or { value, summary } objects) into
|
||||||
|
// completion candidates whose value matches `word` as a prefix.
|
||||||
|
function valuesToCandidates(values, word) {
|
||||||
|
if (!values) return []
|
||||||
|
word = word || ''
|
||||||
|
let out = []
|
||||||
|
values.forEach(function (v) {
|
||||||
|
let val, sum
|
||||||
|
if (v && typeof v === 'object' && ('value' in v)) { val = '' + v.value; sum = v.summary || '' }
|
||||||
|
else { val = '' + v; sum = '' }
|
||||||
|
if (val.indexOf(word) === 0) out.push({ label: val, value: val + ' ', summary: sum, isDir: false })
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Candidates implied by an argument descriptor (a positional, or an option's
|
||||||
|
// `value`). Returns { candidates, filesystem } where `filesystem` is false or
|
||||||
|
// one of 'path' | 'file' | 'directory' -- a request that the caller ALSO offer
|
||||||
|
// matching filesystem entries.
|
||||||
|
function descriptorCandidates(desc, word, model) {
|
||||||
|
word = word || ''
|
||||||
|
let none = { candidates: [], filesystem: false }
|
||||||
|
if (!desc) return none
|
||||||
|
|
||||||
|
let method = (desc.completion && desc.completion.method) || (desc.type === 'enum' ? 'enum' : null)
|
||||||
|
|
||||||
|
// explicit completion block
|
||||||
|
if (method === 'none') return none
|
||||||
|
if (method === 'enum') return { candidates: valuesToCandidates(desc.values, word), filesystem: false }
|
||||||
|
if (method === 'list') {
|
||||||
|
let items = (desc.completion && (desc.completion.items || desc.completion.values)) || desc.values || []
|
||||||
|
return { candidates: valuesToCandidates(items, word), filesystem: false }
|
||||||
|
}
|
||||||
|
if (method === 'internal') {
|
||||||
|
let prov = desc.completion && desc.completion.provider
|
||||||
|
return { candidates: valuesToCandidates(safeProvider(prov, word, model), word), filesystem: false }
|
||||||
|
}
|
||||||
|
// method 'command' (run a program for candidates) is intentionally not
|
||||||
|
// executed here -- side-effect / latency safety -- so it falls through to
|
||||||
|
// the type defaults below.
|
||||||
|
|
||||||
|
// no completion block (or unhandled method): default behaviour by type
|
||||||
|
switch (desc.type) {
|
||||||
|
case 'path': return { candidates: [], filesystem: 'path' }
|
||||||
|
case 'file': return { candidates: [], filesystem: 'file' }
|
||||||
|
case 'directory': return { candidates: [], filesystem: 'directory' }
|
||||||
|
case 'boolean': return { candidates: valuesToCandidates(['true', 'false'], word), filesystem: false }
|
||||||
|
case 'command': return { candidates: valuesToCandidates(safeProvider('commands', word, model), word), filesystem: false }
|
||||||
|
case 'enum': return { candidates: valuesToCandidates(desc.values, word), filesystem: false }
|
||||||
|
case 'user': if (_providers['users']) return { candidates: valuesToCandidates(safeProvider('users', word, model), word), filesystem: false }; break
|
||||||
|
case 'group': if (_providers['groups']) return { candidates: valuesToCandidates(safeProvider('groups', word, model), word), filesystem: false }; break
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
// string / integer / float / url / hostname / unknown: a soft `values`
|
||||||
|
// list may still help; otherwise there is nothing to offer.
|
||||||
|
if (desc.values) return { candidates: valuesToCandidates(desc.values, word), filesystem: false }
|
||||||
|
return none
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every textual form a flag may be typed as (long, short, and the --no- form).
|
||||||
|
function flagForms(entry) {
|
||||||
|
let forms = []
|
||||||
|
if (entry.long) forms.push(entry.long)
|
||||||
|
if (entry.short) forms.push(entry.short)
|
||||||
|
if (entry.negatable && entry.long) forms.push('--no-' + entry.long.replace(/^--/, ''))
|
||||||
|
return forms
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count how many positional arguments `tokens` (the args already typed before
|
||||||
|
// the caret) have consumed, skipping option flags and the values they take.
|
||||||
|
function countPositionals(tokens, model) {
|
||||||
|
let n = 0, skip = false
|
||||||
|
for (let i = 0; i < tokens.length; i++) {
|
||||||
|
let t = tokens[i]
|
||||||
|
if (skip) { skip = false; continue } // this token was an option's value
|
||||||
|
if (t.length > 0 && t.charAt(0) === '-') {
|
||||||
|
if (t.indexOf('=') >= 0) continue // inline value -- no following value token
|
||||||
|
let e = model.flagMap[t]
|
||||||
|
if (e && e.hasValue && e.valueRequired) skip = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalise(r) { return { ok: true, candidates: r.candidates, filesystem: r.filesystem } }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Main entry point used by command.js.
|
||||||
|
*
|
||||||
|
* commandToken : the command (first word on the line)
|
||||||
|
* prefixTokens : the argument tokens already typed, in order, EXCLUDING the
|
||||||
|
* word currently under the caret
|
||||||
|
* word : the partial word under the caret (may be "")
|
||||||
|
*
|
||||||
|
* Returns { ok:false } when there is no synopsis for the command (the caller
|
||||||
|
* should fall back to its own default completion). Otherwise returns
|
||||||
|
* { ok:true, candidates:[{label,value,summary,isDir}], filesystem:<flag> }
|
||||||
|
* where `filesystem` (false | 'path' | 'file' | 'directory') asks the caller to
|
||||||
|
* additionally offer matching filesystem entries.
|
||||||
|
*/
|
||||||
|
function getCompletion(commandToken, prefixTokens, word) {
|
||||||
|
let model = getModel(commandToken)
|
||||||
|
if (!model) return { ok: false }
|
||||||
|
word = word || ''
|
||||||
|
prefixTokens = prefixTokens || []
|
||||||
|
|
||||||
|
// (1) the caret is on an option flag
|
||||||
|
if (word.length > 0 && word.charAt(0) === '-') {
|
||||||
|
// inline value form: --flag=partial
|
||||||
|
if (word.indexOf('--') === 0 && word.indexOf('=') >= 0) {
|
||||||
|
let eq = word.indexOf('=')
|
||||||
|
let flagPart = word.substring(0, eq)
|
||||||
|
let valPart = word.substring(eq + 1)
|
||||||
|
let entry = model.flagMap[flagPart]
|
||||||
|
if (entry && entry.hasValue) {
|
||||||
|
let r = descriptorCandidates(entry.value, valPart, model)
|
||||||
|
r.candidates = r.candidates.map(function (c) {
|
||||||
|
return { label: c.label, value: flagPart + '=' + c.value.replace(/ $/, '') + ' ', summary: c.summary, isDir: false }
|
||||||
|
})
|
||||||
|
return { ok: true, candidates: r.candidates, filesystem: false }
|
||||||
|
}
|
||||||
|
return { ok: true, candidates: [], filesystem: false }
|
||||||
|
}
|
||||||
|
// list flags matching the prefix
|
||||||
|
let out = []
|
||||||
|
model.flags.forEach(function (e) {
|
||||||
|
flagForms(e).forEach(function (f) {
|
||||||
|
if (f.indexOf(word) === 0) out.push({ label: f, value: f + ' ', summary: e.summary, isDir: false })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return { ok: true, candidates: out, filesystem: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
// (2) the caret is on the value of the immediately preceding option
|
||||||
|
let prev = prefixTokens.length > 0 ? prefixTokens[prefixTokens.length - 1] : null
|
||||||
|
if (prev && prev.charAt(0) === '-' && prev.indexOf('=') < 0) {
|
||||||
|
let entry = model.flagMap[prev]
|
||||||
|
if (entry && entry.hasValue && entry.valueRequired)
|
||||||
|
return finalise(descriptorCandidates(entry.value, word, model))
|
||||||
|
}
|
||||||
|
|
||||||
|
// (3) a positional argument (or a subcommand in the first slot)
|
||||||
|
let posIndex = countPositionals(prefixTokens, model)
|
||||||
|
if (posIndex === 0 && model.subcommands.length > 0) {
|
||||||
|
let out = model.subcommands
|
||||||
|
.filter(function (s) { return s.name.indexOf(word) === 0 })
|
||||||
|
.map(function (s) { return { label: s.name, value: s.name + ' ', summary: s.summary, isDir: false } })
|
||||||
|
return { ok: true, candidates: out, filesystem: false }
|
||||||
|
}
|
||||||
|
let desc = null
|
||||||
|
if (model.positionals.length > 0) {
|
||||||
|
if (posIndex < model.positionals.length) desc = model.positionals[posIndex]
|
||||||
|
else {
|
||||||
|
let last = model.positionals[model.positionals.length - 1]
|
||||||
|
if (last && last.repeatable) desc = last
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No descriptor for this slot -> let the caller use its default completion.
|
||||||
|
if (!desc) return { ok: false }
|
||||||
|
return finalise(descriptorCandidates(desc, word, model))
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// generated help (per the spec, usage text is derived output, not normative)
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
function grammarToText(node, symbols) {
|
||||||
|
if (!node || typeof node !== 'object') return ''
|
||||||
|
switch (node.type) {
|
||||||
|
case 'sequence':
|
||||||
|
return (node.children || []).map(function (c) { return grammarToText(c, symbols) })
|
||||||
|
.filter(function (s) { return s.length }).join(' ')
|
||||||
|
case 'choice':
|
||||||
|
return '(' + (node.children || []).map(function (c) { return grammarToText(c, symbols) }).join(' | ') + ')'
|
||||||
|
case 'optional':
|
||||||
|
return '[' + grammarToText(node.child, symbols) + ']'
|
||||||
|
case 'repeat': {
|
||||||
|
// a repeat over a group is the familiar [OPTION...] slot
|
||||||
|
let child = node.child
|
||||||
|
if (child && child.type === 'reference' && symbols[child.symbol] && symbols[child.symbol].kind === 'group')
|
||||||
|
return '[' + grammarToText(child, symbols) + '...]'
|
||||||
|
return grammarToText(child, symbols) + '...'
|
||||||
|
}
|
||||||
|
case 'oneOrMore': {
|
||||||
|
let t = grammarToText(node.child, symbols)
|
||||||
|
return t + ' [' + t + '...]'
|
||||||
|
}
|
||||||
|
case 'reference': {
|
||||||
|
let s = symbols[node.symbol]
|
||||||
|
if (!s) return node.symbol
|
||||||
|
if (s.kind === 'group') return 'OPTION'
|
||||||
|
if (s.kind === 'option') return s.long || s.short || node.symbol
|
||||||
|
if (s.kind === 'subcommand') return s.name || node.symbol
|
||||||
|
if (s.kind === 'positional') return s.name || node.symbol
|
||||||
|
return node.symbol
|
||||||
|
}
|
||||||
|
default: return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUsage(token) {
|
||||||
|
let m = getModel(token)
|
||||||
|
if (!m) return null
|
||||||
|
let body = grammarToText(m.synopsisNode, m.symbols)
|
||||||
|
return ((m.name || token) + (body ? ' ' + body : '')).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSummary(token) {
|
||||||
|
let m = getModel(token)
|
||||||
|
return m ? (m.summary || '') : null
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Module exports
|
||||||
|
///////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
exports = {
|
||||||
|
getCompletion,
|
||||||
|
getModel,
|
||||||
|
getSummary,
|
||||||
|
getUsage,
|
||||||
|
resolveSynopsisPath,
|
||||||
|
registerProvider,
|
||||||
|
clearCache,
|
||||||
|
TSF_VERSION,
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
* Markup
|
* Markup
|
||||||
* ------
|
* ------
|
||||||
* <b>...</b> emphasised foreground colour
|
* <b>...</b> emphasised foreground colour
|
||||||
|
* <b>...</b> de-emphasised foreground colour
|
||||||
* <c>...</c> centre-align this source line
|
* <c>...</c> centre-align this source line
|
||||||
* <r>...</r> right-align this source line
|
* <r>...</r> right-align this source line
|
||||||
* <l>...</l> left-align this source line
|
* <l>...</l> left-align this source line
|
||||||
@@ -43,12 +44,14 @@
|
|||||||
|
|
||||||
const COL_TEXT = 239 // popup body default (== colWHITE)
|
const COL_TEXT = 239 // popup body default (== colWHITE)
|
||||||
const COL_EMPH = 230 // <b>...</b> highlight (== colVoiceHdr)
|
const COL_EMPH = 230 // <b>...</b> highlight (== colVoiceHdr)
|
||||||
|
const COL_DEEMPH = 248 // <s>...</s> unhighlight
|
||||||
const COL_BRAND = 211 // first half of "Microtone"
|
const COL_BRAND = 211 // first half of "Microtone"
|
||||||
const COL_BRAND_DIM = 239 // second half of "Microtone"
|
const COL_BRAND_DIM = 239 // second half of "Microtone"
|
||||||
|
|
||||||
const fgEsc = (n) => `\x1B[38;5;${n}m`
|
const fgEsc = (n) => `\x1B[38;5;${n}m`
|
||||||
const ESC_DEFAULT = fgEsc(COL_TEXT)
|
const ESC_DEFAULT = fgEsc(COL_TEXT)
|
||||||
const ESC_EMPH = fgEsc(COL_EMPH)
|
const ESC_EMPH = fgEsc(COL_EMPH)
|
||||||
|
const ESC_DEEMPH = fgEsc(COL_DEEMPH)
|
||||||
const MICROTONE = `${fgEsc(COL_BRAND)}Micro${fgEsc(COL_BRAND_DIM)}tone${ESC_DEFAULT}`
|
const MICROTONE = `${fgEsc(COL_BRAND)}Micro${fgEsc(COL_BRAND_DIM)}tone${ESC_DEFAULT}`
|
||||||
|
|
||||||
|
|
||||||
@@ -123,6 +126,8 @@ function tokenise(line) {
|
|||||||
// inline tags (case-sensitive for <b>, case-insensitive for <o>)
|
// 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 + 3) === '<b>') { buf += ESC_EMPH; i += 3; continue }
|
||||||
if (line.slice(i, i + 4) === '</b>') { buf += ESC_DEFAULT; i += 4; continue }
|
if (line.slice(i, i + 4) === '</b>') { buf += ESC_DEFAULT; i += 4; continue }
|
||||||
|
if (line.slice(i, i + 3) === '<s>') { buf += ESC_DEEMPH; i += 3; continue }
|
||||||
|
if (line.slice(i, i + 4) === '</s>') { buf += ESC_DEFAULT; i += 4; continue }
|
||||||
const head3 = line.slice(i, i + 3).toLowerCase()
|
const head3 = line.slice(i, i + 3).toLowerCase()
|
||||||
const head4 = line.slice(i, i + 4).toLowerCase()
|
const head4 = line.slice(i, i + 4).toLowerCase()
|
||||||
if (head3 === '<o>') { flushWord(); tokens.push({type: 'anchor', open: true}); i += 3; continue }
|
if (head3 === '<o>') { flushWord(); tokens.push({type: 'anchor', open: true}); i += 3; continue }
|
||||||
@@ -327,5 +332,6 @@ exports = {
|
|||||||
COL_BRAND_DIM,
|
COL_BRAND_DIM,
|
||||||
ESC_DEFAULT,
|
ESC_DEFAULT,
|
||||||
ESC_EMPH,
|
ESC_EMPH,
|
||||||
|
ESC_DEEMPH,
|
||||||
MICROTONE,
|
MICROTONE,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,28 +49,22 @@ class WindowObject {
|
|||||||
let tt = ''+this.title
|
let tt = ''+this.title
|
||||||
con.move(this.y, this.x + ((this.width - 2 - tt.length) >>> 1))
|
con.move(this.y, this.x + ((this.width - 2 - tt.length) >>> 1))
|
||||||
if (this.titleBack !== undefined) print(`\x1B[48;5;${this.titleBack}m`)
|
if (this.titleBack !== undefined) print(`\x1B[48;5;${this.titleBack}m`)
|
||||||
print(`\x84${charset[6]}u`)
|
|
||||||
print(`\x1B[38;5;${colourText}m${tt}`)
|
print(`\x1B[38;5;${colourText}m${tt}`)
|
||||||
print(`\x1B[38;5;${colour}m\x84${charset[7]}u`)
|
|
||||||
if (this.titleBack !== undefined) print(`\x1B[48;5;${oldBack}m`)
|
if (this.titleBack !== undefined) print(`\x1B[48;5;${oldBack}m`)
|
||||||
}
|
}
|
||||||
if (this.titleLeft !== undefined) {
|
if (this.titleLeft !== undefined) {
|
||||||
let tt = ''+this.titleLeft
|
let tt = ''+this.titleLeft
|
||||||
con.move(this.y, this.x)
|
con.move(this.y, this.x + 1)
|
||||||
print(`\x84${charset[0]}u`)
|
|
||||||
if (this.titleBackLeft !== undefined) print(`\x1B[48;5;${this.titleBackLeft}m`)
|
if (this.titleBackLeft !== undefined) print(`\x1B[48;5;${this.titleBackLeft}m`)
|
||||||
print(`\x1B[38;5;${colourText}m`);print(tt)
|
print(`\x1B[38;5;${colourText}m`);print(tt)
|
||||||
if (this.titleBackLeft !== undefined) print(`\x1B[48;5;${oldBack}m`)
|
if (this.titleBackLeft !== undefined) print(`\x1B[48;5;${oldBack}m`)
|
||||||
print(`\x1B[38;5;${colour}m`);print(`\x84${charset[4]}u`)
|
|
||||||
}
|
}
|
||||||
if (this.titleRight !== undefined) {
|
if (this.titleRight !== undefined) {
|
||||||
let tt = ''+this.titleRight
|
let tt = ''+this.titleRight
|
||||||
con.move(this.y + this.height - 1, this.x + this.width - tt.length - 2)
|
con.move(this.y + this.height - 1, this.x + this.width - tt.length - 1)
|
||||||
print(`\x84${charset[4]}u`)
|
|
||||||
if (this.titleBackRight !== undefined) print(`\x1B[48;5;${this.titleBackRight}m`)
|
if (this.titleBackRight !== undefined) print(`\x1B[48;5;${this.titleBackRight}m`)
|
||||||
print(`\x1B[38;5;${colourText}m${tt}`)
|
print(`\x1B[38;5;${colourText}m${tt}`)
|
||||||
if (this.titleBackRight !== undefined) print(`\x1B[48;5;${oldBack}m`)
|
if (this.titleBackRight !== undefined) print(`\x1B[48;5;${oldBack}m`)
|
||||||
print(`\x1B[38;5;${colour}m\x84${charset[3]}u`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -217,6 +211,13 @@ function scrollHorz(dx, stringSize, stringViewSize, currentCursorPos, currentScr
|
|||||||
// action string to close the dialog,
|
// action string to close the dialog,
|
||||||
// or null to stay open.
|
// or null to stay open.
|
||||||
// showScrollbar: bool?, -- default: auto (true when overflowing).
|
// 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
|
// drawWell: bool?, -- draw the list background
|
||||||
// bg: number?, -- list background colour (default 242).
|
// bg: number?, -- list background colour (default 242).
|
||||||
// },
|
// },
|
||||||
@@ -307,6 +308,12 @@ function showDialog(opts) {
|
|||||||
const hasList = !!list
|
const hasList = !!list
|
||||||
const listOnActivate = list ? list.onActivate : null
|
const listOnActivate = list ? list.onActivate : null
|
||||||
const listBgColour = (list && list.bg != null) ? list.bg : listBg
|
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) {
|
function firstSelectable(from, dir) {
|
||||||
if (!hasList || listItems.length === 0) return -1
|
if (!hasList || listItems.length === 0) return -1
|
||||||
let i = from
|
let i = from
|
||||||
@@ -531,8 +538,10 @@ function showDialog(opts) {
|
|||||||
con.move(lbRow + 1 + r, lbCol + lw - 2)
|
con.move(lbRow + 1 + r, lbCol + lw - 2)
|
||||||
const maxScroll = Math.max(1, listItems.length - listHeight)
|
const maxScroll = Math.max(1, listItems.length - listHeight)
|
||||||
const indPos = (maxScroll <= 0) ? 0 : ((listScroll * (listHeight - 1) / maxScroll) | 0)
|
const indPos = (maxScroll <= 0) ? 0 : ((listScroll * (listHeight - 1) / maxScroll) | 0)
|
||||||
let trough = (r === 0) ? 0xBA : (r === listHeight - 1) ? 0xBC : 0xBB
|
// seg: 0 = top cap, 1 = middle, 2 = bottom cap; +3 selects the
|
||||||
con.addch(r === indPos ? (trough + 3) : trough)
|
// 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])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
12
assets/disk0/tvdos/synopsis/cat.synopsis
Normal file
12
assets/disk0/tvdos/synopsis/cat.synopsis
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "cat",
|
||||||
|
"summary": "Print a file, or pipe its contents onward",
|
||||||
|
"symbols": {
|
||||||
|
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "File to read; reads from the pipe when omitted" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "optional",
|
||||||
|
"child": { "type": "reference", "symbol": "file" }
|
||||||
|
}
|
||||||
|
}
|
||||||
12
assets/disk0/tvdos/synopsis/cd.synopsis
Normal file
12
assets/disk0/tvdos/synopsis/cd.synopsis
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "cd",
|
||||||
|
"summary": "Change the current working directory",
|
||||||
|
"symbols": {
|
||||||
|
"dir": { "kind": "positional", "type": "directory", "name": "DIR", "summary": "Directory to change into; prints the current directory when omitted" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "optional",
|
||||||
|
"child": { "type": "reference", "symbol": "dir" }
|
||||||
|
}
|
||||||
|
}
|
||||||
22
assets/disk0/tvdos/synopsis/chvt.synopsis
Normal file
22
assets/disk0/tvdos/synopsis/chvt.synopsis
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "chvt",
|
||||||
|
"summary": "Switch to virtual console N (1-6)",
|
||||||
|
"symbols": {
|
||||||
|
"console": {
|
||||||
|
"kind": "positional",
|
||||||
|
"type": "enum",
|
||||||
|
"name": "N",
|
||||||
|
"summary": "Target virtual console",
|
||||||
|
"values": [
|
||||||
|
{ "value": "1", "summary": "Virtual console 1" },
|
||||||
|
{ "value": "2", "summary": "Virtual console 2" },
|
||||||
|
{ "value": "3", "summary": "Virtual console 3" },
|
||||||
|
{ "value": "4", "summary": "Virtual console 4" },
|
||||||
|
{ "value": "5", "summary": "Virtual console 5" },
|
||||||
|
{ "value": "6", "summary": "Virtual console 6" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"synopsis": { "type": "reference", "symbol": "console" }
|
||||||
|
}
|
||||||
7
assets/disk0/tvdos/synopsis/cls.synopsis
Normal file
7
assets/disk0/tvdos/synopsis/cls.synopsis
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "cls",
|
||||||
|
"summary": "Clear the screen",
|
||||||
|
"symbols": {},
|
||||||
|
"synopsis": { "type": "sequence", "children": [] }
|
||||||
|
}
|
||||||
16
assets/disk0/tvdos/synopsis/cp.synopsis
Normal file
16
assets/disk0/tvdos/synopsis/cp.synopsis
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "cp",
|
||||||
|
"summary": "Copy a file",
|
||||||
|
"symbols": {
|
||||||
|
"source": { "kind": "positional", "type": "file", "name": "SOURCE", "summary": "File to copy from" },
|
||||||
|
"dest": { "kind": "positional", "type": "path", "name": "DEST", "summary": "Destination path" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "sequence",
|
||||||
|
"children": [
|
||||||
|
{ "type": "reference", "symbol": "source" },
|
||||||
|
{ "type": "reference", "symbol": "dest" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
7
assets/disk0/tvdos/synopsis/date.synopsis
Normal file
7
assets/disk0/tvdos/synopsis/date.synopsis
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "date",
|
||||||
|
"summary": "Print the system date and time",
|
||||||
|
"symbols": {},
|
||||||
|
"synopsis": { "type": "sequence", "children": [] }
|
||||||
|
}
|
||||||
9
assets/disk0/tvdos/synopsis/del.synopsis
Normal file
9
assets/disk0/tvdos/synopsis/del.synopsis
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "del",
|
||||||
|
"summary": "Delete a file",
|
||||||
|
"symbols": {
|
||||||
|
"file": { "kind": "positional", "type": "file", "name": "FILE", "summary": "File to delete" }
|
||||||
|
},
|
||||||
|
"synopsis": { "type": "reference", "symbol": "file" }
|
||||||
|
}
|
||||||
12
assets/disk0/tvdos/synopsis/dir.synopsis
Normal file
12
assets/disk0/tvdos/synopsis/dir.synopsis
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "dir",
|
||||||
|
"summary": "List the contents of a directory",
|
||||||
|
"symbols": {
|
||||||
|
"path": { "kind": "positional", "type": "directory", "name": "PATH", "summary": "Directory to list; the working directory when omitted" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "optional",
|
||||||
|
"child": { "type": "reference", "symbol": "path" }
|
||||||
|
}
|
||||||
|
}
|
||||||
12
assets/disk0/tvdos/synopsis/echo.synopsis
Normal file
12
assets/disk0/tvdos/synopsis/echo.synopsis
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "echo",
|
||||||
|
"summary": "Print text, expanding $VARIABLE references",
|
||||||
|
"symbols": {
|
||||||
|
"text": { "kind": "positional", "type": "string", "name": "TEXT", "summary": "Text to print", "completion": { "method": "none" } }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "repeat",
|
||||||
|
"child": { "type": "reference", "symbol": "text" }
|
||||||
|
}
|
||||||
|
}
|
||||||
7
assets/disk0/tvdos/synopsis/exit.synopsis
Normal file
7
assets/disk0/tvdos/synopsis/exit.synopsis
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "exit",
|
||||||
|
"summary": "Exit the command processor",
|
||||||
|
"symbols": {},
|
||||||
|
"synopsis": { "type": "sequence", "children": [] }
|
||||||
|
}
|
||||||
9
assets/disk0/tvdos/synopsis/mkdir.synopsis
Normal file
9
assets/disk0/tvdos/synopsis/mkdir.synopsis
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "mkdir",
|
||||||
|
"summary": "Create a directory",
|
||||||
|
"symbols": {
|
||||||
|
"dir": { "kind": "positional", "type": "path", "name": "DIR", "summary": "Directory to create" }
|
||||||
|
},
|
||||||
|
"synopsis": { "type": "reference", "symbol": "dir" }
|
||||||
|
}
|
||||||
16
assets/disk0/tvdos/synopsis/mv.synopsis
Normal file
16
assets/disk0/tvdos/synopsis/mv.synopsis
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "mv",
|
||||||
|
"summary": "Move or rename a file",
|
||||||
|
"symbols": {
|
||||||
|
"source": { "kind": "positional", "type": "file", "name": "SOURCE", "summary": "File to move" },
|
||||||
|
"dest": { "kind": "positional", "type": "path", "name": "DEST", "summary": "Destination path" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "sequence",
|
||||||
|
"children": [
|
||||||
|
{ "type": "reference", "symbol": "source" },
|
||||||
|
{ "type": "reference", "symbol": "dest" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
7
assets/disk0/tvdos/synopsis/panic.synopsis
Normal file
7
assets/disk0/tvdos/synopsis/panic.synopsis
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "panic",
|
||||||
|
"summary": "Deliberately raise an error (diagnostic aid)",
|
||||||
|
"symbols": {},
|
||||||
|
"synopsis": { "type": "sequence", "children": [] }
|
||||||
|
}
|
||||||
12
assets/disk0/tvdos/synopsis/rem.synopsis
Normal file
12
assets/disk0/tvdos/synopsis/rem.synopsis
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "rem",
|
||||||
|
"summary": "A comment; the line is ignored",
|
||||||
|
"symbols": {
|
||||||
|
"text": { "kind": "positional", "type": "string", "name": "TEXT", "summary": "Comment text", "completion": { "method": "none" } }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "repeat",
|
||||||
|
"child": { "type": "reference", "symbol": "text" }
|
||||||
|
}
|
||||||
|
}
|
||||||
18
assets/disk0/tvdos/synopsis/set.synopsis
Normal file
18
assets/disk0/tvdos/synopsis/set.synopsis
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "set",
|
||||||
|
"summary": "Set or display an environment variable",
|
||||||
|
"symbols": {
|
||||||
|
"assignment": {
|
||||||
|
"kind": "positional",
|
||||||
|
"type": "string",
|
||||||
|
"name": "NAME=VALUE",
|
||||||
|
"summary": "Variable assignment, or a name to display; lists all variables when omitted",
|
||||||
|
"completion": { "method": "internal", "provider": "envvars" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "optional",
|
||||||
|
"child": { "type": "reference", "symbol": "assignment" }
|
||||||
|
}
|
||||||
|
}
|
||||||
7
assets/disk0/tvdos/synopsis/ver.synopsis
Normal file
7
assets/disk0/tvdos/synopsis/ver.synopsis
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "ver",
|
||||||
|
"summary": "Print the operating system version",
|
||||||
|
"symbols": {},
|
||||||
|
"synopsis": { "type": "sequence", "children": [] }
|
||||||
|
}
|
||||||
9
assets/disk0/tvdos/synopsis/which.synopsis
Normal file
9
assets/disk0/tvdos/synopsis/which.synopsis
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "which",
|
||||||
|
"summary": "Report how a command name resolves",
|
||||||
|
"symbols": {
|
||||||
|
"program": { "kind": "positional", "type": "command", "name": "PROGRAM", "summary": "Command name to resolve" }
|
||||||
|
},
|
||||||
|
"synopsis": { "type": "reference", "symbol": "program" }
|
||||||
|
}
|
||||||
BIN
assets/disk0/tvdos/tsvm.chr
Normal file
BIN
assets/disk0/tvdos/tsvm.chr
Normal file
Binary file not shown.
@@ -1,5 +1,7 @@
|
|||||||
\begin{itemlist}
|
\begin{itemlist}
|
||||||
\item Song, Minjae. 2021. ``Terran BASIC Reference Manual for Language Version 1.2, Third Edition''.
|
\item Song, Minjae. 2021. ``Terran BASIC Reference Manual for Language Version 1.2, Third Edition''.
|
||||||
|
\item Bradner, S. 1997. ``Key words for use in RFCs to Indicate Requirement Levels.'' RFC 2119. \url{https://www.rfc-editor.org/rfc/rfc2119}.
|
||||||
|
\item Leiba, B. 2017. ``Ambiguity of Uppercase vs Lowercase in RFC 2119 Key Words.'' RFC 8174. \url{https://www.rfc-editor.org/rfc/rfc8174}.
|
||||||
\item Wikipedia. ``List of DOS commands.'' Updated 2022-08-29 15:00. \url{https://en.wikipedia.org/wiki/List_of_DOS_commands}.
|
\item Wikipedia. ``List of DOS commands.'' Updated 2022-08-29 15:00. \url{https://en.wikipedia.org/wiki/List_of_DOS_commands}.
|
||||||
\item Wikipedia. ``Pipeline (software).'' Updated 2022-07-17 06:21. \url{https://en.wikipedia.org/wiki/Pipeline_(software)}.
|
\item Wikipedia. ``Pipeline (software).'' Updated 2022-07-17 06:21. \url{https://en.wikipedia.org/wiki/Pipeline_(software)}.
|
||||||
\end{itemlist}
|
\end{itemlist}
|
||||||
|
|||||||
@@ -3,16 +3,16 @@
|
|||||||
\section{Specs}
|
\section{Specs}
|
||||||
|
|
||||||
\begin{outline}
|
\begin{outline}
|
||||||
\1 16 MB memory space with maximum 8 MB of scratchpad memory
|
\1 16 MB memory space with maximum 8 MB of core memory
|
||||||
\1 7 peripheral card slots, each can map 1 MB of memory to the memory space
|
\1 7 peripheral card slots, each can map 1 MB of memory to the memory space
|
||||||
\1 Standard graphics adapter on slot 1, with 256 simultaneous colours, 560\times448 pixels framebuffer and 80-column 32-row text buffer
|
\1 Standard graphics adapter on slot 1, with 256 simultaneous colours, 560\times448 pixels framebuffer and 80-column 32-row text buffer
|
||||||
\1 Built-in mouse input support
|
\1 Built-in mouse input support
|
||||||
\1 4 serial ports to connect disk drives, modems and other computers
|
\1 4 serial ports to connect disk drives, modems and other computers
|
||||||
\end{outline}
|
\end{outline}
|
||||||
|
|
||||||
There are three memories on the system: Hardware Memory (8 MB), Scratchpad Memory (up to 8 MB) and Program Memory (infinite!)
|
There are three memories on the system: Hardware Memory (8 MB), Core Memory (up to 8 MB) and Program Memory (infinite!)
|
||||||
|
|
||||||
Your Javascript program is stored into the Program Memory, and since its capacity is limitless, you can put a large graphics directly into your Javascript source code, but the Program Memory is the slowest of all three memories. For faster graphics, you need to store them onto the Scratchpad Memory then DMA-Copy them to the graphics adapter.
|
Your Javascript program is stored into the Program Memory, and since its capacity is limitless, you can put a large graphics directly into your Javascript source code, but the Program Memory is the slowest of all three memories. For faster graphics, you need to store them onto the Core Memory then DMA-Copy them to the graphics adapter.
|
||||||
|
|
||||||
\section{Javascript Extensions}
|
\section{Javascript Extensions}
|
||||||
|
|
||||||
@@ -36,7 +36,29 @@ Your Javascript program is stored into the Program Memory, and since its capacit
|
|||||||
|
|
||||||
\chapter{Libraries}
|
\chapter{Libraries}
|
||||||
|
|
||||||
|
\thismachine\ runs your program on an embedded ECMAScript engine. The language is standard Javascript; what makes a \thismachine\ program a \thismachine\ program is the set of \emph{host namespaces} the engine exposes --- global objects, available without any import, through which the program talks to the hardware.
|
||||||
|
|
||||||
|
\section{Namespaces}
|
||||||
|
|
||||||
|
\index{namespaces}The following namespaces are always present, whether or not an operating system is loaded:
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\inlinesynopsis{sys}{low-level system and memory access (\code{peek}, \code{poke}, \code{malloc}, timing, input).}
|
||||||
|
\1\inlinesynopsis{graphics}{the graphics adapter: pixels, palette, graphics modes, image decoding.}
|
||||||
|
\1\inlinesynopsis{audio}{the sound card: PCM playback and the tracker engine.}
|
||||||
|
\1\inlinesynopsis{com}{block (``serial'') communication with attached devices.}
|
||||||
|
\1\inlinesynopsis{dma}{bulk memory-to-memory and memory-to-device transfers.}
|
||||||
|
\1\inlinesynopsis{gzip}{compression and decompression (see the note on the name below).}
|
||||||
|
\1\inlinesynopsis{base64}{Base64 encoding and decoding.}
|
||||||
|
\1\inlinesynopsis{serial}{a debug text channel to the host; output appears on the host's console, not on the \thismachine\ screen.}
|
||||||
|
\1\inlinesynopsis{con}{screen text manipulation, built on top of the above.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
The plain \code{print}, \code{println}, \code{printerr}, \code{printerrln} and \code{read} functions are also global.
|
||||||
|
|
||||||
|
A second set of namespaces --- \code{files}, \code{input}, \code{unicode}, \code{GL}, \code{require}, \code{exports} and the \code{\_G.shell} family --- is provided by \thedos\ and is only present once the operating system has booted. Those are documented in the \thedos\ part of this book.
|
||||||
|
|
||||||
|
\textbf{A note on \code{gzip}:} despite the name, the \code{gzip} namespace compresses and decompresses using the \emph{Zstandard} format. The decompressor recognises both Zstandard and the older gzip streams, so older files keep working. The name is historical.
|
||||||
|
|
||||||
\section{Standard Input and Output}
|
\section{Standard Input and Output}
|
||||||
|
|
||||||
@@ -96,6 +118,8 @@ Functions:
|
|||||||
\1\formalsynopsis{color\_fore}{code: Int}{Defines the foreground colour of the text. 0 -- black, 1 -- red, 2 -- green, 3 -- yellow, 4 -- blue, 5 -- magenta, 6 -- cyan, 7 -- white, -1 -- transparent}
|
\1\formalsynopsis{color\_fore}{code: Int}{Defines the foreground colour of the text. 0 -- black, 1 -- red, 2 -- green, 3 -- yellow, 4 -- blue, 5 -- magenta, 6 -- cyan, 7 -- white, -1 -- transparent}
|
||||||
\1\formalsynopsis{color\_back}{code: Int}{Defines the background colour of the text.}
|
\1\formalsynopsis{color\_back}{code: Int}{Defines the background colour of the text.}
|
||||||
\1\formalsynopsis{color\_pair}{fore: Int, back: Int}{Defines the foreground and background colour of the text. Colour code for this function differs from the \code{color\_back} and \code{color\_fore}; please refer to the \ref{colourpalette}.}
|
\1\formalsynopsis{color\_pair}{fore: Int, back: Int}{Defines the foreground and background colour of the text. Colour code for this function differs from the \code{color\_back} and \code{color\_fore}; please refer to the \ref{colourpalette}.}
|
||||||
|
\1\formalsynopsis{get\_color\_fore}{}[Int]{Returns the current text foreground colour (palette index).}
|
||||||
|
\1\formalsynopsis{get\_color\_back}{}[Int]{Returns the current text background colour (palette index).}
|
||||||
\1\formalsynopsis{clear}{}{Clears the text buffer. The framebuffer (if any) will not be affected.}
|
\1\formalsynopsis{clear}{}{Clears the text buffer. The framebuffer (if any) will not be affected.}
|
||||||
\1\formalsynopsis{reset\_graphics}{}{Resets foreground and background colour to defaults and makes the cursor visible if it was hidden.}
|
\1\formalsynopsis{reset\_graphics}{}{Resets foreground and background colour to defaults and makes the cursor visible if it was hidden.}
|
||||||
\end{outline}
|
\end{outline}
|
||||||
@@ -146,7 +170,7 @@ Sys library allows programmers to manipulate the system in low-level.
|
|||||||
\begin{outline}
|
\begin{outline}
|
||||||
\1\formalsynopsis{poke}{address: Int, value: Int}{Puts a value into the memory of the specified address.}
|
\1\formalsynopsis{poke}{address: Int, value: Int}{Puts a value into the memory of the specified address.}
|
||||||
\1\formalsynopsis{peek}{address: Int}[Int]{Reads a value from the memory of the specified address.}
|
\1\formalsynopsis{peek}{address: Int}[Int]{Reads a value from the memory of the specified address.}
|
||||||
\1\formalsynopsis{malloc}{size: Int}[Int]{Allocates a space of the given size on the Scratchpad memory and returns its pointer (starting address)}
|
\1\formalsynopsis{malloc}{size: Int}[Int]{Allocates a space of the given size on the Core memory and returns its pointer (starting address)}
|
||||||
\1\formalsynopsis{free}{pointer: Int}{Frees the memory space previously \code{malloc}'d}
|
\1\formalsynopsis{free}{pointer: Int}{Frees the memory space previously \code{malloc}'d}
|
||||||
\1\formalsynopsis{memcpy}{from: Int, to: Int, length: Int}{Copies the memory block of the given length. From and To are pointers.}
|
\1\formalsynopsis{memcpy}{from: Int, to: Int, length: Int}{Copies the memory block of the given length. From and To are pointers.}
|
||||||
\1\formalsynopsis{mapRom}{romSlotNum: Int}{Maps the contents on the given ROM to the memory address {-\nobreak65537.\nobreak.-\nobreak131072}}
|
\1\formalsynopsis{mapRom}{romSlotNum: Int}{Maps the contents on the given ROM to the memory address {-\nobreak65537.\nobreak.-\nobreak131072}}
|
||||||
@@ -163,7 +187,7 @@ Sys library allows programmers to manipulate the system in low-level.
|
|||||||
\1\formalsynopsis{waitForMemChg}{address: Int, andMask: Int, xorMask: Int}[]{Do nothing until a memory value is changed. More specifically, this function will \code{spin} while:$$ (\mathrm{peek}(addr)\ \mathrm{xor}\ xorMask)\ \mathrm{and}\ andMask = 0 $$}
|
\1\formalsynopsis{waitForMemChg}{address: Int, andMask: Int, xorMask: Int}[]{Do nothing until a memory value is changed. More specifically, this function will \code{spin} while:$$ (\mathrm{peek}(addr)\ \mathrm{xor}\ xorMask)\ \mathrm{and}\ andMask = 0 $$}
|
||||||
\1\formalsynopsis{getSysRq}{}[Boolean]{Returns true if System Request key is down.}
|
\1\formalsynopsis{getSysRq}{}[Boolean]{Returns true if System Request key is down.}
|
||||||
\1\formalsynopsis{unsetSysRq}{}{After using the System Request key, call this function to `release' the key so that other programs can use it.}
|
\1\formalsynopsis{unsetSysRq}{}{After using the System Request key, call this function to `release' the key so that other programs can use it.}
|
||||||
\1\formalsynopsis{maxmem}{}[Int]{returns the size of the Scratchpad Memory in bytes.}
|
\1\formalsynopsis{maxmem}{}[Int]{returns the size of the Core Memory in bytes.}
|
||||||
\1\formalsynopsis{getUsedMem}{}[Int]{Returns how many memories can be \code{malloc}'d.}
|
\1\formalsynopsis{getUsedMem}{}[Int]{Returns how many memories can be \code{malloc}'d.}
|
||||||
\1\formalsynopsis{getMallocStatus}{}[IntArray(2)]{Returns the \code{malloc} status in following order:$$ [\mathrm{Malloc\ unit\ size,\ allocated\ block\ counts}] $$}
|
\1\formalsynopsis{getMallocStatus}{}[IntArray(2)]{Returns the \code{malloc} status in following order:$$ [\mathrm{Malloc\ unit\ size,\ allocated\ block\ counts}] $$}
|
||||||
\end{outline}
|
\end{outline}
|
||||||
@@ -172,6 +196,37 @@ Sys library allows programmers to manipulate the system in low-level.
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
\section{DMA}
|
||||||
|
|
||||||
|
\index{dma (library)}The \thismachine\ can copy blocks of memory directly, without the program shuffling individual bytes. This is far faster than a \code{peek}/\code{poke} loop and is the usual way of moving graphics into the framebuffer.
|
||||||
|
|
||||||
|
\namespaceis{DMA}{dma}
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\formalsynopsis{ramToRam}{from: Int, to: Int, length: Int}{Copies a block of memory from one address to another.}
|
||||||
|
\1\formalsynopsis{ramToFrame}{from: Int, to: Int, length: Int}{Copies a block of memory into the framebuffer of the first graphics adapter.}
|
||||||
|
\1\formalsynopsis{ramToFrame}{from: Int, devnum: Int, offset: Int, length: Int}{Copies a block of memory into the framebuffer of the graphics adapter in the given slot, starting at the given offset.}
|
||||||
|
\1\formalsynopsis{frameToRam}{from: Int, to: Int, length: Int}{Copies a block of framebuffer memory back into ordinary memory.}
|
||||||
|
\1\formalsynopsis{frameToRam}{from: Int, to: Int, devnum: Int, length: Int}{As above, sourcing the framebuffer of the graphics adapter in the given slot.}
|
||||||
|
\1\formalsynopsis{comToRam}{portNo: Int, srcOff: Int, destOff: Int, length: Int}{Copies bytes from a serial port's receive block into memory.}
|
||||||
|
\1\formalsynopsis{ramToCom}{srcOff: Int, portNo: Int, length: Int}{Copies bytes from memory into a serial port's send block.}
|
||||||
|
\1\formalsynopsis{strToRam}{str: String, to: Int, srcOff: Int, length: Int}{Writes the characters of a Javascript string into memory as bytes.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
|
||||||
|
\section{Serial Debugger}
|
||||||
|
|
||||||
|
\index{serial (library)}The \code{serial} namespace prints to a debug channel that appears on the \emph{host} machine's console, never on the \thismachine\ screen. It is invaluable for tracing a program without disturbing what the user sees.
|
||||||
|
|
||||||
|
\namespaceis{Serial}{serial}
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\inlinesynopsis{print}[Anything]{prints a value to the host debug console.}
|
||||||
|
\1\inlinesynopsis{println}[Anything]{prints a value followed by a new line to the host debug console.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
\chapter{Serial Communication}
|
\chapter{Serial Communication}
|
||||||
|
|
||||||
Some peripherals such as disk drives are connected through the ``Serial Communication''.
|
Some peripherals such as disk drives are connected through the ``Serial Communication''.
|
||||||
@@ -328,7 +383,13 @@ The com-port will behave differently if you're writing to or reading from the ad
|
|||||||
|
|
||||||
\section{Keyboard}
|
\section{Keyboard}
|
||||||
|
|
||||||
TODO
|
\index{keyboard}The keyboard is part of the main hardware (the slot-zero IO device) and is read through MMIO. It offers two distinct views of the same physical keyboard, and a program chooses whichever suits it.
|
||||||
|
|
||||||
|
\textbf{The input stream (TTY view).} For ordinary line- and character-oriented input, the program opens the text input stream by writing a non-zero value to MMIO \code{-39}. Keystrokes are then translated to characters and pushed onto the \emph{keyboard input buffer}; the head of that buffer is read from MMIO \code{-38}, and reading it shifts the buffer along. This is the mechanism behind \code{con.getch}, \code{read} and the \code{sys.read}/\code{sys.readKey} family. Opening the stream clears the buffer, so it must be opened exactly once.
|
||||||
|
|
||||||
|
\textbf{The raw view.} For interactive programs that need to know which keys are physically held down --- games, editors --- the program latches the current input by writing to MMIO \code{-40} and then reads the \emph{list of pressed keys} from \code{-41..-48}. This buffer holds up to eight simultaneous keys (``8-key rollover''), sorted, with zero filling the unused slots. \code{con.poll\_keys} exposes this directly. Latching freezes the values so that a multi-key read is consistent.
|
||||||
|
|
||||||
|
The codes in the raw view are the engine keycodes listed below; the codes in the stream view are character codes (with a handful of control values, e.g.\ \code{3} for Ctrl-C, \code{8} for Backspace, \code{13} for Return, and \code{19}--\code{22} for the arrow keys). \thedos\ builds its event-driven \code{input} library (see the \thedos\ part) on top of the raw view.
|
||||||
|
|
||||||
\subsection{Keycodes}
|
\subsection{Keycodes}
|
||||||
|
|
||||||
@@ -451,7 +512,14 @@ F12 & 142 \\
|
|||||||
|
|
||||||
\section{Mouse}
|
\section{Mouse}
|
||||||
|
|
||||||
TODO
|
\index{mouse}The mouse is read through the same slot-zero IO device. Its position and button state are memory-mapped:
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1the cursor's X position is a 16-bit value at MMIO \code{-33..-34}, and the Y position at \code{-35..-36}. Like the keyboard's raw view, both are latched by a write to \code{-40} so a coordinate pair can be read consistently.
|
||||||
|
\1the button state is a single byte at \code{-37}. It is a bit-field: bit 0 is the left button, bit 1 the right, bit 2 the middle. Bits 6 and 7 report a wheel notch up and down respectively; these wheel bits latch in hardware and clear when read, so a single notch is reported exactly once.
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
Most programs do not poke these addresses directly. Under \thedos, the \code{input} library turns mouse motion, clicks and wheel notches into the \textbf{mouse\_down}, \textbf{mouse\_up}, \textbf{mouse\_move} and \textbf{mouse\_wheel} events described in the \thedos\ part of this book.
|
||||||
|
|
||||||
|
|
||||||
\chapter{Peripherals and Memory Mapping}
|
\chapter{Peripherals and Memory Mapping}
|
||||||
@@ -490,7 +558,7 @@ The memory map of \thismachine\ is illustrated as following:
|
|||||||
\draw(0,12.5) node[anchor=north east] {Start};
|
\draw(0,12.5) node[anchor=north east] {Start};
|
||||||
\draw(6,12.5) node[anchor=north west] {End};
|
\draw(6,12.5) node[anchor=north west] {End};
|
||||||
|
|
||||||
\draw(3,10) node[anchor=mid] {\memlabel{Scratchpad Memory}};
|
\draw(3,10) node[anchor=mid] {\memlabel{Core Memory}};
|
||||||
\draw(3,7.5) node[anchor=mid] {\memlabel{MMIO Area}};
|
\draw(3,7.5) node[anchor=mid] {\memlabel{MMIO Area}};
|
||||||
\draw(3,6.5) node[anchor=mid] {\memlabel{Peripheral Memory \#1}};
|
\draw(3,6.5) node[anchor=mid] {\memlabel{Peripheral Memory \#1}};
|
||||||
\draw(3,5.5) node[anchor=mid] {\memlabel{Peripheral Memory \#2}};
|
\draw(3,5.5) node[anchor=mid] {\memlabel{Peripheral Memory \#2}};
|
||||||
@@ -559,7 +627,7 @@ Address & RW & Description \\
|
|||||||
-41..-48 & RO & List of pressed keys (latched by -40) \\
|
-41..-48 & RO & List of pressed keys (latched by -40) \\
|
||||||
-49 & RO & System Flags A (\code{0b r000 000t}, where r: RESET button held, t: STOP button held) \\
|
-49 & RO & System Flags A (\code{0b r000 000t}, where r: RESET button held, t: STOP button held) \\
|
||||||
-50..-52 & RO & Unused System Flags \\
|
-50..-52 & RO & Unused System Flags \\
|
||||||
-65..-68 & RO & Size of the Scratchpad Memory \\
|
-65..-68 & RO & Size of the Core Memory \\
|
||||||
-69 & WO & Counter Latch (\code{0b01}--Uptime, \code{0b10}--RTC) \\
|
-69 & WO & Counter Latch (\code{0b01}--Uptime, \code{0b10}--RTC) \\
|
||||||
-73..-80 & RO & System Uptime in nanoseconds (latched by -69) \\
|
-73..-80 & RO & System Uptime in nanoseconds (latched by -69) \\
|
||||||
-81..-88 & RO & RTC in nanoseconds (latched by -69) \\
|
-81..-88 & RO & RTC in nanoseconds (latched by -69) \\
|
||||||
@@ -578,7 +646,14 @@ Address & RW & Description \\
|
|||||||
|
|
||||||
\chapter{Text and Graphics Display}
|
\chapter{Text and Graphics Display}
|
||||||
|
|
||||||
TODO: Textbuf, pixelbuf, graphics mode, chart of the draw order
|
\index{display model}The reference graphics adapter drives the screen from two superimposed surfaces:
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1the \textbf{framebuffer} (also called the pixel buffer), a grid of pixels whose size and colour depth depend on the current graphics mode; and
|
||||||
|
\1the \textbf{text buffer}, a grid of character cells, each with its own foreground and background colour.
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
These are composited every frame in a fixed order: first the screen background (border) colour, then the framebuffer, then the text on top. The text is therefore always visible over whatever the framebuffer holds, and a text cell whose background uses the transparent palette entry lets the framebuffer show through. A program that wants a purely graphical display simply leaves the text buffer blank; one that wants a classic terminal leaves the framebuffer cleared.
|
||||||
|
|
||||||
While the graphics adapters can be plugged into any peripheral slot, it is highly recommended they occupy the 1st slot. The Memory address charts for the graphics adapter on this documentation will assume as such.
|
While the graphics adapters can be plugged into any peripheral slot, it is highly recommended they occupy the 1st slot. The Memory address charts for the graphics adapter on this documentation will assume as such.
|
||||||
|
|
||||||
@@ -634,7 +709,35 @@ Sequence & Description \\
|
|||||||
|
|
||||||
\section{Frame Buffer}
|
\section{Frame Buffer}
|
||||||
|
|
||||||
TODO
|
\index{frame buffer}The framebuffer holds the pixel image. In the default mode it is $560\times448$ pixels, one byte per pixel, where each byte is an index into the 256-entry colour palette. Pixels are written either with the \code{graphics} library (\code{plotPixel}, \code{plotRect}, \dots), or far more quickly by \code{dma}-copying a prepared image into the framebuffer region of the adapter's memory.
|
||||||
|
|
||||||
|
\subsection{Graphics Modes}
|
||||||
|
|
||||||
|
\index{graphics mode}The adapter can be reconfigured into several modes that trade resolution, colour depth and the number of \emph{layers} (independent pixel planes that the adapter composites together). The mode is selected with \code{graphics.setGraphicsMode}, or by poking the Current Graphics Mode register. The higher modes need extra video-memory banks to be fitted; if the required banks are absent, the mode change is ignored.
|
||||||
|
|
||||||
|
\begin{center}
|
||||||
|
\begin{tabulary}{\textwidth}{clcl}
|
||||||
|
Mode & Resolution & Colours & Layers / requirement \\
|
||||||
|
\hline
|
||||||
|
0 & $560\times448$ & 256 & 1 layer (default) \\
|
||||||
|
1 & $280\times224$ & 256 & 4 layers \\
|
||||||
|
2 & $280\times224$ & 4096 & 2 layers \\
|
||||||
|
3 & $560\times448$ & 256 & 2 layers (needs bank 2) \\
|
||||||
|
4 & $560\times448$ & 4096 & 1 layer (needs bank 2) \\
|
||||||
|
5 & $560\times448$ & 15-bit & 1 layer (needs bank 2) \\
|
||||||
|
8 & $560\times448$ & 24-bit & 1 layer (needs banks 3 \& 4) \\
|
||||||
|
\end{tabulary}
|
||||||
|
\end{center}
|
||||||
|
|
||||||
|
A graphics adapter ships with one 256\,kB memory bank and can hold up to four. The number of installed banks is reported by the adapter and limits which modes are available.
|
||||||
|
|
||||||
|
\subsection{Direct Colour}
|
||||||
|
|
||||||
|
\index{direct colour}The 4096-colour (``direct colour'') modes do not use the palette. Instead two layers are paired to form one frame: the low layer carries the red and green channels (\code{0b RRRR GGGG}) and the high layer carries the blue channel and a 4-bit transparency (\code{0b BBBB AAAA}), giving 4096 colours each with 16 levels of transparency.
|
||||||
|
|
||||||
|
\subsection{Layers and Scrolling}
|
||||||
|
|
||||||
|
\index{layers}When a mode provides more than one layer, the \emph{layer arrangement} register chooses the order in which the layers are stacked, so a program can swap front and back planes without moving any pixels. The whole framebuffer can also be panned: the horizontal and vertical framebuffer-scroll registers shift the entire image, and a per-scanline horizontal offset table allows each scanline to be displaced independently --- the basis of split-screen and wavy ``raster'' effects.
|
||||||
|
|
||||||
\subsection{Colour Palette}
|
\subsection{Colour Palette}
|
||||||
\label{colourpalette}
|
\label{colourpalette}
|
||||||
@@ -940,14 +1043,55 @@ TODO
|
|||||||
|
|
||||||
\section{The Graphics Library}
|
\section{The Graphics Library}
|
||||||
|
|
||||||
\index{graphics (library)}Graphics library provides basic functions to communicate and manipulate the graphics adapter.
|
\index{graphics (library)}Graphics library provides basic functions to communicate and manipulate the graphics adapter. Coordinates are in pixels with the origin at the top-left; colours are palette indices unless the adapter is in a direct-colour mode.
|
||||||
|
|
||||||
\namespaceis{Graphics}{graphics}
|
\namespaceis{Graphics}{graphics}
|
||||||
|
|
||||||
|
Drawing:
|
||||||
\begin{outline}
|
\begin{outline}
|
||||||
\1\formalsynopsis{TODO}{to be added.}
|
\1\formalsynopsis{plotPixel}{x: Int, y: Int, colour: Int}{Plots a single pixel on the first framebuffer.}
|
||||||
|
\1\formalsynopsis{plotPixel2}{x: Int, y: Int, colour: Int}{Plots a single pixel on the second framebuffer.}
|
||||||
|
\1\formalsynopsis{plotRect}{x: Int, y: Int, w: Int, h: Int, colour: Int}{Fills a rectangle on the first framebuffer. An optional sixth argument selects a blending effect.}
|
||||||
|
\1\formalsynopsis{plotRect2}{x: Int, y: Int, w: Int, h: Int, colour: Int}{As \code{plotRect}, on the second framebuffer.}
|
||||||
|
\1\formalsynopsis{clearPixels}{col: Int}{Fills the entire framebuffer with the given colour.}
|
||||||
|
\1\formalsynopsis{clearText}{}{Blanks the text buffer.}
|
||||||
|
\1\formalsynopsis{getGpuMemBase}{}[Int]{Returns the base address of the graphics adapter's memory, for direct access via \code{peek}/\code{poke} or \code{dma}.}
|
||||||
\end{outline}
|
\end{outline}
|
||||||
|
|
||||||
|
Colour:
|
||||||
|
\begin{outline}
|
||||||
|
\1\formalsynopsis{setBackground}{r: Int, g: Int, b: Int}{Sets the screen background (border) colour, 8 bits per channel.}
|
||||||
|
\1\formalsynopsis{resetPalette}{}{Restores the default colour palette.}
|
||||||
|
\1\formalsynopsis{setPalette}{index: Int, r: Int, g: Int, b: Int, a: Int}{Sets one palette entry. Channels are 4-bit (0--15); \code{a} (alpha) defaults to 15 (opaque).}
|
||||||
|
\1\formalsynopsis{setTextFore}{b: Int}{Sets the current text foreground colour.}
|
||||||
|
\1\formalsynopsis{setTextBack}{b: Int}{Sets the current text background colour.}
|
||||||
|
\1\formalsynopsis{getTextFore}{}[Int]{Returns the current text foreground colour.}
|
||||||
|
\1\formalsynopsis{getTextBack}{}[Int]{Returns the current text background colour.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
Mode and geometry:
|
||||||
|
\begin{outline}
|
||||||
|
\1\formalsynopsis{setGraphicsMode}{mode: Int}{Switches the graphics mode (see \emph{Graphics Modes}).}
|
||||||
|
\1\formalsynopsis{getGraphicsMode}{}[Int]{Returns the current graphics mode.}
|
||||||
|
\1\formalsynopsis{getPixelDimension}{}[IntArray(2)]{Returns the framebuffer size as \code{[width, height]}.}
|
||||||
|
\1\formalsynopsis{getTermDimension}{}[IntArray(2)]{Returns the text grid size as \code{[columns, rows]}.}
|
||||||
|
\1\formalsynopsis{setFramebufferScroll}{x: Int, y: Int}{Pans the whole framebuffer to the given offset.}
|
||||||
|
\1\formalsynopsis{getFramebufferScroll}{}[IntArray(2)]{Returns the current framebuffer scroll offset.}
|
||||||
|
\1\formalsynopsis{scrollFrame}{xdelta: Int, ydelta: Int}{Pans the framebuffer by a relative amount.}
|
||||||
|
\1\formalsynopsis{setLineOffset}{line: Int, offset: Int}{Sets the per-scanline horizontal offset (raster effects).}
|
||||||
|
\1\formalsynopsis{getLineOffset}{line: Int}[Int]{Returns the per-scanline horizontal offset.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
Text cursor and symbols (these talk to the adapter directly; under \thedos\ prefer the \code{con} library):
|
||||||
|
\begin{outline}
|
||||||
|
\1\formalsynopsis{getCursorYX}{}[IntArray(2)]{Returns the text cursor position as \code{[row, column]}.}
|
||||||
|
\1\formalsynopsis{setCursorYX}{cy: Int, cx: Int}{Moves the text cursor.}
|
||||||
|
\1\formalsynopsis{putSymbol}{c: Int}{Writes the character of the given code at the cursor, without advancing it.}
|
||||||
|
\1\formalsynopsis{putSymbolAt}{cy: Int, cx: Int, c: Int}{Writes a character at a specific cell.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
Image decoding: the adapter can decode compressed still images straight into memory. \code{decodeImage(srcFilePtr, srcFileLen)} decodes an image (JPEG, PNG, TGA, \dots) whose bytes are already in memory and returns its dimensions; \code{decodeImageTo} writes the result to a given buffer, and \code{decodeImageResample} scales it on the way. These, together with the hardware decoders for the iPF, TEV and TAV formats, are what the \thedos\ media players are built on; the formats themselves are described in the \thedos\ part.
|
||||||
|
|
||||||
|
|
||||||
\section{MMIO and Memory Mapping}
|
\section{MMIO and Memory Mapping}
|
||||||
|
|
||||||
@@ -987,3 +1131,44 @@ Address & RW & Description \\
|
|||||||
-1310209..-1310720 & RW & Palettes in This Pattern: {\ttfamily 0b RRRR GGGG; 0b BBBB AAAA} \\
|
-1310209..-1310720 & RW & Palettes in This Pattern: {\ttfamily 0b RRRR GGGG; 0b BBBB AAAA} \\
|
||||||
-1310721..-1561600 & RW & Second Framebuffer \\
|
-1310721..-1561600 & RW & Second Framebuffer \\
|
||||||
\end{tabulary}
|
\end{tabulary}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
\chapter{Audio}
|
||||||
|
|
||||||
|
\index{audio adapter}The \thismachine\ sound card is built from four independent \textbf{playheads}. Each playhead can play either a stream of PCM samples or a tracker track, and the four are mixed together to the card's native output of 32\,kHz stereo.
|
||||||
|
|
||||||
|
The playheads are not locked to one another, so timing between them is not guaranteed. A single piece of music should therefore live on a single playhead; the remaining playheads are best used for sound effects or left idle.
|
||||||
|
|
||||||
|
\section{PCM Playback}
|
||||||
|
|
||||||
|
\index{PCM playback}In PCM mode a playhead is fed a queue of raw samples, which the card plays back at the hardware rate. This is the path used by the \thedos\ players for raw PCM, WAV/ADPCM, MP2 and \thedos's own compressed audio --- a player decodes the file into samples and pushes them into a playhead's queue. The relevant format details are covered, from the listener's point of view, in the \thedos\ part.
|
||||||
|
|
||||||
|
\section{Tracker Playback}
|
||||||
|
|
||||||
|
\index{tracker engine}In tracker mode a playhead is driven by the card's built-in tracker engine. The engine keeps a pool of digitised \emph{samples}, a set of \emph{instruments} built from them, and the \emph{patterns} and cue sheet that sequence the notes; once these are loaded the playhead renders the song autonomously. This is how \thismachine\ plays its native \textbf{Taud} music.
|
||||||
|
|
||||||
|
This guide deliberately stops at that overview. The Taud file format, the instrument and envelope model, the effect commands, and the \emph{Microtone} tracker used to author Taud music are large topics with a manual of their own, and are out of scope here.
|
||||||
|
|
||||||
|
\section{The Audio Library}
|
||||||
|
|
||||||
|
\index{audio (library)}The \code{audio} namespace controls the playheads. The functions below cover everyday playback; the engine also exposes a large number of tracker-control and voice-inspection calls (pattern and cue upload, tempo, per-voice queries, \dots) that belong with the Taud documentation.
|
||||||
|
|
||||||
|
\namespaceis{Audio}{audio}
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\formalsynopsis{setPcmMode}{playhead: Int}{Puts a playhead (0--3) into PCM mode.}
|
||||||
|
\1\formalsynopsis{setTrackerMode}{playhead: Int}{Puts a playhead into tracker mode.}
|
||||||
|
\1\formalsynopsis{play}{playhead: Int}{Starts the playhead.}
|
||||||
|
\1\formalsynopsis{stop}{playhead: Int}{Stops the playhead.}
|
||||||
|
\1\formalsynopsis{isPlaying}{playhead: Int}[Boolean]{Whether the playhead is currently playing.}
|
||||||
|
\1\formalsynopsis{getFreePlayhead}{fallback: Int}[Int]{Returns the lowest-numbered playhead that is not currently playing.}
|
||||||
|
\1\formalsynopsis{getPosition}{playhead: Int}[Int]{Current playback position of the playhead.}
|
||||||
|
\1\formalsynopsis{setMasterVolume}{playhead: Int, volume: Int}{Sets the playhead's output volume.}
|
||||||
|
\1\formalsynopsis{setMasterPan}{playhead: Int, pan: Int}{Sets the playhead's stereo pan.}
|
||||||
|
\1\formalsynopsis{putPcmDataByPtr}{playhead: Int, ptr: Int, length: Int, destOffset: Int}{Copies PCM samples from memory into the playhead's buffer.}
|
||||||
|
\1\formalsynopsis{purgeQueue}{playhead: Int}{Empties the playhead's pending sample queue.}
|
||||||
|
\1\formalsynopsis{resetParams}{playhead: Int}{Resets the playhead to default parameters.}
|
||||||
|
\1\formalsynopsis{getMemAddr}{}[Int]{Returns the base address of the audio adapter's memory space.}
|
||||||
|
\1\formalsynopsis{getBaseAddr}{}[Int]{Returns the base address of the audio adapter's MMIO area.}
|
||||||
|
\end{outline}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
\thismachine\ is a virtual machine programmable using mainly, but not limited to, Javascript, and can have 7 virtual peripherals that can be communicated using MMIOs exclusively. \thismachine\ has default graphics of 80-column 32-rows text mode with one 560\times448 pixels framebuffer with 256 palette colours with 4096 colours to choose from.
|
\thismachine\ is a virtual machine that imitates the architecture of an 8-bit era home computer while being programmed, mainly but not exclusively, in Javascript. A \thismachine\ system is built around a flat memory space into which both the core memory and the hardware peripherals are mapped, so that every device --- the graphics adapter, the sound card, the disk drives --- is reached through the same \code{peek} and \code{poke} operations that touch ordinary memory.
|
||||||
|
|
||||||
This is the documentation for \thismachine\ \tsvmver.
|
Out of the box, \thismachine\ presents an 80-column, 32-row text display backed by a $560\times448$-pixel framebuffer with 256 simultaneous colours chosen from a palette of 4096, built-in keyboard and mouse input, and four serial ports for attaching disk drives, modems and other machines. Up to seven expansion cards may be fitted, each mapping a megabyte of its own memory into the address space.
|
||||||
|
|
||||||
|
This guide is one book in two parts. The first part, \emph{\thismachine}, documents the virtual machine itself: its memory map, the Javascript runtime and its built-in libraries, the way peripherals are addressed, and the text, graphics and audio hardware. The second part, \emph{\thedos}, documents the disk operating system that is usually shipped with the machine: how it boots, the commands and applications it provides, how it plays back media, the format in which commands describe themselves, and the libraries it offers to programs of your own.
|
||||||
|
|
||||||
|
This is the documentation for \thismachine\ version \tsvmver\ and the \thedos\ that accompanies it.
|
||||||
|
|||||||
@@ -10,6 +10,6 @@ Copyrighted under the terms of MIT License
|
|||||||
|
|
||||||
\begin{center}
|
\begin{center}
|
||||||
\begin{tabulary}{\textwidth}{ll}
|
\begin{tabulary}{\textwidth}{ll}
|
||||||
Zeroth Edition (for version 1.0): & \thepublishingdate
|
\theedition\ (for version \tsvmver): & \thepublishingdate
|
||||||
\end{tabulary}
|
\end{tabulary}
|
||||||
\end{center}
|
\end{center}
|
||||||
|
|||||||
@@ -146,7 +146,7 @@
|
|||||||
\newcommand{\codeline}[1]{%
|
\newcommand{\codeline}[1]{%
|
||||||
\colorbox{lgrey}{%
|
\colorbox{lgrey}{%
|
||||||
\begin{tabular*}{\textwidth}{l}%
|
\begin{tabular*}{\textwidth}{l}%
|
||||||
\monofont #1 \\% TODO fill the cell with \hl colour
|
\monofont #1 \\% cell background is provided by the enclosing \colorbox
|
||||||
\end{tabular*}%
|
\end{tabular*}%
|
||||||
}}
|
}}
|
||||||
|
|
||||||
@@ -203,8 +203,8 @@
|
|||||||
\newcommand{\thismachine}{TSVM}
|
\newcommand{\thismachine}{TSVM}
|
||||||
\newcommand{\thedos}{TVDOS}
|
\newcommand{\thedos}{TVDOS}
|
||||||
\newcommand{\tsvmver}{1.2}
|
\newcommand{\tsvmver}{1.2}
|
||||||
\newcommand{\theedition}{Zeroth Edition}
|
\newcommand{\theedition}{First Edition}
|
||||||
\newcommand{\thepublishingdate}{0000-00-00}
|
\newcommand{\thepublishingdate}{2026-06-06}
|
||||||
\newcommand{\oreallypress}{\begingroup\hspace{0.083em}\large\textbf{O'REALLY\raisebox{1ex}{\scriptsize ?}} \large Press\endgroup}
|
\newcommand{\oreallypress}{\begingroup\hspace{0.083em}\large\textbf{O'REALLY\raisebox{1ex}{\scriptsize ?}} \large Press\endgroup}
|
||||||
|
|
||||||
\newcommand{\argN}[1]{arg\textsubscript{#1}}
|
\newcommand{\argN}[1]{arg\textsubscript{#1}}
|
||||||
@@ -247,10 +247,10 @@
|
|||||||
|
|
||||||
% \input{changesmeta}
|
% \input{changesmeta}
|
||||||
|
|
||||||
\part{The Virtual Machine}
|
\part{\thismachine: The Virtual Machine}
|
||||||
\input{implementation}
|
\input{implementation}
|
||||||
|
|
||||||
\part{The DOS}
|
\part{\thedos: The Disk Operating System}
|
||||||
\input{tvdos}
|
\input{tvdos}
|
||||||
|
|
||||||
\part*{Bibliography}
|
\part*{Bibliography}
|
||||||
|
|||||||
738
doc/tvdos.tex
738
doc/tvdos.tex
@@ -7,16 +7,16 @@ All \thedos-related features requires the DOS to be fully loaded.
|
|||||||
|
|
||||||
\chapter{Bootstrapping}
|
\chapter{Bootstrapping}
|
||||||
|
|
||||||
\index{boot process}\thedos\ goes through follwing progress to deliver the \code{A:\rs} prompt:
|
\index{boot process}\thedos\ goes through the following progress to deliver the \code{A:\rs} prompt:
|
||||||
|
|
||||||
\section{Probing Bootable Devices}
|
\section{Probing Bootable Devices}
|
||||||
The BIOS will probe serial devices to find first bootable drive. If found, port number of the driver is written to the \code{\_BIOS} object, then attempts to load and run the bootloader.
|
The BIOS will probe serial devices to find the first bootable drive. If found, the port number of the drive is written to the \code{\_BIOS} object, then it attempts to load and run the bootloader.
|
||||||
|
|
||||||
\section{The Bootloader}
|
\section{The Bootloader}
|
||||||
The Bootloader is a short program that loads the \code{TVDOS.SYS} file.
|
The Bootloader is a short program that loads the \code{TVDOS.SYS} file.
|
||||||
|
|
||||||
\section{TVDOS.SYS}
|
\section{TVDOS.SYS}
|
||||||
\thedos.SYS will load system libraries and variables and then will try to run the boot script by executing \code{A:\rs{}AUTOEXEC.BAT}
|
\thedos.SYS loads the system libraries and variables, installs the filesystem and input drivers, and then runs the boot script.
|
||||||
|
|
||||||
Boot Procedure:
|
Boot Procedure:
|
||||||
|
|
||||||
@@ -26,21 +26,58 @@ Boot Procedure:
|
|||||||
\item initialise DOS variables
|
\item initialise DOS variables
|
||||||
\item install filesystem drivers
|
\item install filesystem drivers
|
||||||
\item install input device drivers
|
\item install input device drivers
|
||||||
\item install GL using the external file
|
\item install \code{GL} using the external file
|
||||||
\item execute \code{AUTOEXEC.BAT}
|
\item run \code{\rs{}commandrc} to set up the environment
|
||||||
\begin{enumerate}
|
\item hand the screen to the virtual-console manager, \code{\rs{}tvdos\rs{}VTMGR.SYS}
|
||||||
\item execute \code{command.js} with proper arguments
|
\item when the manager exits, run \code{\rs{}AUTOEXEC.BAT} as a bare fallback shell
|
||||||
\item \code{command.js} to initialise \code{shell.*} functions (this includes coreutils and patched version of \code{require})
|
|
||||||
\item \code{command.js} to parse and run \code{AUTOEXEC.BAT}
|
|
||||||
\end{enumerate}
|
|
||||||
\end{enumerate}
|
\end{enumerate}
|
||||||
|
|
||||||
\section{AUTOEXEC.BAT}
|
Steps 7--9 are run through \code{command.js}: \thedos.SYS loads \code{command.js}, which initialises the \code{shell.*} functions (the coreutils and a patched \code{require}), and then parses and runs the requested script.
|
||||||
|
|
||||||
AUTOEXEC can setup user-specific variables (e.g. keyboard layout) and launch the command shell of your choice, \code{COMMAND} is the most common shell.
|
\section{The Boot Configuration: commandrc and AUTOEXEC.BAT}
|
||||||
|
|
||||||
Variables can be set or changed using \textbf{SET} commands.
|
\index{commandrc}\index{AUTOEXEC.BAT}The boot configuration is split across two files with deliberately different jobs. The split exists because \thedos\ runs several independent shell sessions at once (see \emph{Virtual Consoles}): the environment must be established in \emph{every} session, but applications should be launched only \emph{once per session}.
|
||||||
|
|
||||||
|
\code{\rs{}commandrc} is the \textbf{environment} file. It holds \code{set} commands and nothing else, and \thedos.SYS runs it in every context --- the boot console and every virtual-console pane. Because it has no \code{.BAT} extension, the boot block runs it line by line. A typical \code{commandrc} configures the search paths and the keyboard layout:
|
||||||
|
|
||||||
|
\begin{lstlisting}
|
||||||
|
set PATH=\tbas;\hopper\bin;$PATH
|
||||||
|
set INCLPATH=\hopper\include;$INCLPATH
|
||||||
|
set HELPPATH=\hopper\help;$HELPPATH
|
||||||
|
set KEYBOARD=us_qwerty
|
||||||
|
\end{lstlisting}
|
||||||
|
|
||||||
|
\code{\rs{}AUTOEXEC.BAT} is the \textbf{per-console launch} script. It is run once for each console: by each virtual-console pane as it starts, and by the boot console as the fallback once the virtual-console manager has exited. It performs work that must happen per session --- registering the input method, then starting the interactive shell:
|
||||||
|
|
||||||
|
\begin{lstlisting}
|
||||||
|
command -fancy
|
||||||
|
\end{lstlisting}
|
||||||
|
|
||||||
|
Variables are set or changed with the \textbf{set} command. A value may refer to the previous value of a variable with \code{\$NAME}, as the \code{\$PATH} above does, which appends to rather than replaces the existing search path.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
\chapter{Virtual Consoles}
|
||||||
|
|
||||||
|
\index{virtual consoles}\thedos\ runs up to six independent shell sessions at once, called \textbf{virtual consoles}. Only one is shown on the physical screen at a time; the others keep running in the background. This is managed by \code{VTMGR.SYS}, which the boot process starts automatically (see \emph{Bootstrapping}).
|
||||||
|
|
||||||
|
\section{Switching Consoles}
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\textbf{Alt-1} through \textbf{Alt-6} switch to that console. A console is created the first time it is selected, and re-created if its shell has exited.
|
||||||
|
\1the \code{chvt} \emph{N} command switches to console \emph{N} from within a script or a running shell.
|
||||||
|
\1\textbf{Alt-0} shuts the virtual-console manager down entirely, after which the boot console's \code{AUTOEXEC.BAT} runs as a single bare shell.
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
Console 1 is created at boot; consoles 2--6 are created on first use. Each console runs its own \code{command -fancy} shell, with its own working directory and screen, but they all share the same environment set up by \code{commandrc}. The prompt of consoles 2--6 is prefixed with \code{[\emph{N}]} so the active console is always identifiable.
|
||||||
|
|
||||||
|
\section{Concurrency}
|
||||||
|
|
||||||
|
Switching is truly concurrent: a console keeps running even while it is not on screen, and you can switch away from a long-running command and back again without interrupting it. A console that is waiting for keyboard input is parked until it is brought to the foreground.
|
||||||
|
|
||||||
|
\section{Well-behaved Applications}
|
||||||
|
|
||||||
|
Most programs need no special handling to run inside a virtual console, because they draw through the \code{con} library and the \code{print} family, which \thedos\ routes to the correct console automatically. The one exception is a program that writes to the text area \emph{directly} through \code{graphics.getGpuMemBase()}: such a program would paint the physical screen and bleed into whichever console is visible. Applications that need direct text-area access must resolve their writes through a console-aware base address; the bundled applications that do this are already adapted.
|
||||||
|
|
||||||
|
|
||||||
\chapter{Coreutils}
|
\chapter{Coreutils}
|
||||||
@@ -50,6 +87,7 @@ Variables can be set or changed using \textbf{SET} commands.
|
|||||||
\begin{outline}
|
\begin{outline}
|
||||||
\1\dossynopsis{cat}[file]{Reads a file and pipes its contents to the pipe, or to the console if no pipes are specified.}
|
\1\dossynopsis{cat}[file]{Reads a file and pipes its contents to the pipe, or to the console if no pipes are specified.}
|
||||||
\1\dossynopsis{cd}[dir]{Change the current working directory. Alias: chdir}
|
\1\dossynopsis{cd}[dir]{Change the current working directory. Alias: chdir}
|
||||||
|
\1\dossynopsis{chvt}[N]{Switches to virtual console \emph{N} (1--6). Only meaningful inside a virtual console. See \emph{Virtual Consoles}.}
|
||||||
\1\dossynopsis{cls}{Clears the text buffer and the framebuffer if available.}
|
\1\dossynopsis{cls}{Clears the text buffer and the framebuffer if available.}
|
||||||
\1\dossynopsis{cp}[from to]{Make copies of the specified file. The source file must not be a directory. Alias: copy}
|
\1\dossynopsis{cp}[from to]{Make copies of the specified file. The source file must not be a directory. Alias: copy}
|
||||||
\1\dossynopsis{date}{Prints the system date. Alias: time}
|
\1\dossynopsis{date}{Prints the system date. Alias: time}
|
||||||
@@ -59,42 +97,148 @@ Variables can be set or changed using \textbf{SET} commands.
|
|||||||
\1\dossynopsis{exit}{Exits the current command processor.}
|
\1\dossynopsis{exit}{Exits the current command processor.}
|
||||||
\1\dossynopsis{mkdir}[path]{Creates a directory. Aliase: md}
|
\1\dossynopsis{mkdir}[path]{Creates a directory. Aliase: md}
|
||||||
\1\dossynopsis{mv}[from to]{Moves or renames the file. Aliase: move}
|
\1\dossynopsis{mv}[from to]{Moves or renames the file. Aliase: move}
|
||||||
|
\1\dossynopsis{panic}{Deliberately raises an error in the command processor. A diagnostic aid.}
|
||||||
\1\dossynopsis{rem}{Comment-out the line.}
|
\1\dossynopsis{rem}{Comment-out the line.}
|
||||||
\1\dossynopsis{set}[key=value]{Sets the global variable \code{key} to \code{value}, or displays the list of global variables if no arguments were given.}
|
\1\dossynopsis{set}[key=value]{Sets the global variable \code{key} to \code{value}, or displays the list of global variables if no arguments were given.}
|
||||||
\1\dossynopsis{ver}{Prints the version of \thedos.}
|
\1\dossynopsis{ver}{Prints the version of \thedos.}
|
||||||
|
\1\dossynopsis{which}[program]{Reports how a name would resolve: as a shell built-in, or as the full path of the executable found on the \code{PATH}. Alias: where}
|
||||||
\end{outline}
|
\end{outline}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
\chapter{Built-in Apps}
|
\chapter{Built-in Apps}
|
||||||
|
|
||||||
\index{built-in apps (DOS)}Built-in Applications are the programs shipped with the standard distribution of \thedos\ that is written for users' convenience.
|
\index{built-in apps (DOS)}Built-in Applications are the programs shipped with the standard distribution of \thedos\ that are written for users' convenience.
|
||||||
|
|
||||||
This chapter will only briefly list and describe the applications.
|
This chapter lists the general-purpose applications. The applications for playing and converting media have a chapter of their own, \emph{Media Playback and Formats}.
|
||||||
|
|
||||||
|
\section{Shells and Languages}
|
||||||
|
|
||||||
\begin{outline}
|
\begin{outline}
|
||||||
\1\dossynopsis{basica}{Invokes a BASIC interpreter stored in the ROM. If no BASIC rom is present, nothing will be done.}
|
\1\dossynopsis{command}{The default text-based \thedos\ shell. Call with \code{command -fancy} for a more \ae sthetically pleasing look.}
|
||||||
\1\dossynopsis{basic}{If your system is bundled with a software-based BASIC, this command will invoke the BASIC interpreter stored in the disk.}
|
\1\dossynopsis{basica}{Invokes a BASIC interpreter stored in the ROM. If no BASIC ROM is present, nothing is done.}
|
||||||
\1\dossynopsis{color}{Changes the background and the foreground of the active session.}
|
\1\dossynopsis{basic}{If your system is bundled with a software-based BASIC, invokes the BASIC interpreter stored on the disk.}
|
||||||
\1\dossynopsis{command}{The default text-based DOS shell. Call with \code{command -fancy} for more \ae sthetically pleasing looks.}
|
|
||||||
\1\dossynopsis{decodeipf}[file]{Decodes the IPF-formatted image to the framebuffer using the graphics processor.}
|
|
||||||
\1\dossynopsis{drives}{Shows the list of the connected and mounted disk drives.}
|
|
||||||
\1\dossynopsis{edit}[file]{The interactive full-screen text editor.}
|
|
||||||
\1\dossynopsis{encodeipf}[1/2 imagefile ipffile]{Encodes the given image file (.jpg, .png, .bmp, .tga) to the IPF format using the graphics hardware.}
|
|
||||||
\1\dossynopsis{false}{Returns errorlevel 1 upon execution.}
|
|
||||||
\1\dossynopsis{geturl}[url]{Reads contents on the web address and store it to the disk. Requires Internet adapter.}
|
|
||||||
\1\dossynopsis{hexdump}[file]{Prints out the contents of a file in hexadecimal view. Supports pipe.}
|
|
||||||
\1\dossynopsis{less}[file]{Allows user to read the long text, even if they are wider and/or taller than the screen. Supports pipe.}
|
|
||||||
\1\dossynopsis{playmov}[file]{Plays tsvmmov-formatted video. Use -i flag for playback control.}
|
|
||||||
\1\dossynopsis{playmp2}[file]{Plays MP2 (MPEG-1 Audio Layer II) formatted audio. Use -i flag for playback control.}
|
|
||||||
\1\dossynopsis{playpcm}[file]{Plays raw PCM audio. Use -i flag for playback control.}
|
|
||||||
\1\dossynopsis{playwav}[file]{Plays linear PCM/ADPCM audio. Use -i flag for playback control.}
|
|
||||||
\1\dossynopsis{printfile}[file]{Prints out the contents of a textfile with line numbers. Useful for making descriptive screenshots.}
|
|
||||||
\1\dossynopsis{touch}[file]{Updates a file's modification date. New file will be created if the specified file does not exist.}
|
|
||||||
\1\dossynopsis{true}{Returns errorlevel 0 upon execution.}
|
|
||||||
\1\dossynopsis{zfm}{Z File Manager. A two-panel graphical user interface to navigate the system using arrow keys. Hit Z to switch panels.}
|
|
||||||
\end{outline}
|
\end{outline}
|
||||||
|
|
||||||
|
\section{Files and Text}
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\dossynopsis{edit}[file]{The interactive full-screen text editor.}
|
||||||
|
\1\dossynopsis{zfm}{Z File Manager. A two-panel graphical interface for navigating the system with the arrow keys. Press Z to switch panels.}
|
||||||
|
\1\dossynopsis{less}[file]{Lets the user read long text, even when it is wider and/or taller than the screen. Supports pipes.}
|
||||||
|
\1\dossynopsis{hexdump}[file]{Prints the contents of a file in a hexadecimal view. Supports pipes.}
|
||||||
|
\1\dossynopsis{printfile}[file]{Prints the contents of a text file with line numbers. Useful for making descriptive screenshots.}
|
||||||
|
\1\dossynopsis{touch}[file]{Updates a file's modification date. The file is created if it does not exist.}
|
||||||
|
\1\dossynopsis{tee}[file]{In a pipe, copies the incoming stream to a file \emph{and} passes it on to the next command.}
|
||||||
|
\1\dossynopsis{writeto}[file]{In a pipe, writes the incoming stream to a file.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
\section{Storage and Archives}
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\dossynopsis{drives}{Lists the connected and mounted disk drives.}
|
||||||
|
\1\dossynopsis{defrag}{Defragments the current drive.}
|
||||||
|
\1\dossynopsis{gzip}[file]{Compresses a file in place, or decompresses it with \code{-d}, or writes to standard output with \code{-c}. As with the \code{gzip} library, the actual format used is Zstandard.}
|
||||||
|
\1\dossynopsis{lfs}{Creates and extracts \thedos\ Linear File Strip (\code{.lfs}) archives --- a simple way to bundle a directory tree into one file.}
|
||||||
|
\1\dossynopsis{autorun}[file]{Runs a program or plays a media file directly from a sequential (tape) device.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
\section{Networking and Packages}
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\dossynopsis{geturl}[url]{Reads the contents at a web address and stores it to the disk. Requires an Internet adapter.}
|
||||||
|
\1\dossynopsis{telcom}[port]{A terminal program for talking to a device or modem on a serial port.}
|
||||||
|
\1\dossynopsis{hopper}{The \thedos\ package manager, for installing and managing optional software. Alias: hop.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
\section{Miscellaneous}
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\dossynopsis{synopsis}[program]{Prints a command's one-line summary together with an auto-generated synopsis --- its usage line, arguments, options and constraints --- read from the command's \code{.synopsis} description. With no \code{program} it describes itself. Alias: help.}
|
||||||
|
\1\dossynopsis{color}{Changes the background and foreground colour of the active session.}
|
||||||
|
\1\dossynopsis{true}{Returns errorlevel 0 upon execution.}
|
||||||
|
\1\dossynopsis{false}{Returns errorlevel 1 upon execution.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
The music tracker and editor (invoked as \code{microtone}) is also bundled, but it --- together with the music format it authors --- is large enough to warrant its own manual and is not covered here.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
\chapter{Media Playback and Formats}
|
||||||
|
|
||||||
|
\index{media playback (DOS)}\thedos\ can display images, play video and play audio, in several formats. Decoding is hardware-accelerated by the graphics and audio adapters, so even the more sophisticated formats play back smoothly. This chapter is written for someone who wants to \emph{use} these formats; it describes what each is for and which command plays it, not how the codecs work internally.
|
||||||
|
|
||||||
|
Most players accept the \code{-i} flag, which turns on \textbf{interactive mode}: an on-screen progress bar, a visualiser where appropriate, and playback control. Without it, the player simply plays the file and exits. Across the players, holding \textbf{Backspace} stops playback.
|
||||||
|
|
||||||
|
\section{Still Images}
|
||||||
|
|
||||||
|
\index{iPF}The machine's native still-image format is \textbf{iPF} (Interchangeable Picture Format). The graphics hardware can also decode ordinary JPEG, PNG and TGA images directly.
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\dossynopsis{decodeipf}[file]{Decodes an iPF image onto the framebuffer.}
|
||||||
|
\1\dossynopsis{encodeipf}[1/2 imagefile ipffile]{Encodes an image file (\code{.jpg}, \code{.png}, \code{.bmp}, \code{.tga}) into iPF, using the graphics hardware. The first argument selects the iPF type (1 or 2).}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
The TAV video format below also have still-picture variants, used for high-fidelity images.
|
||||||
|
|
||||||
|
\section{Video}
|
||||||
|
|
||||||
|
\thedos\ supports three families of video, in rough order of age and sophistication:
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\textbf{MV1} --- the legacy movie format, carrying iPF (or plain palette) frames with MP2 audio. Created on the machine with \code{encodemov} / \code{encodemov2}.
|
||||||
|
\1\textbf{TEV} (\thismachine\ Enhanced Video) --- a modern format with markedly better compression than iPF movies, taking full advantage of the 4096-colour hardware.
|
||||||
|
\1\textbf{TAV} (\thismachine\ Advanced Video) --- the current format and successor to TEV, offering the best compression and image quality.
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
TEV and TAV files are prepared on a host computer and copied to the disk for playback; MOV and iPF content can also be produced on the machine itself. Both TEV and TAV are encoded at a chosen \emph{quality level} when they are made, trading file size against fidelity --- as a viewer you simply play the result.
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\dossynopsis{playmov}[file]{Plays any movie --- MV1, TEV or TAV, including TAV still pictures --- detecting the format from the file itself. This is the recommended player; see below.}
|
||||||
|
\1\dossynopsis{playmv1}[file]{Plays a MV1-format movie.}
|
||||||
|
\1\dossynopsis{playtev}[file]{Plays a TEV-format video.}
|
||||||
|
\1\dossynopsis{playtav}[file]{Plays a TAV-format video.}
|
||||||
|
\1\dossynopsis{movprobe}[file]{Prints a movie's properties (dimensions, frame rate, audio, \dots) without playing it.}
|
||||||
|
\1\dossynopsis{playucf}[file]{Plays a chaptered movie (UCF), presenting a chapter selector.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
\subsection{The All-in-One Player}
|
||||||
|
|
||||||
|
\index{playmov}\code{playmov} plays every video format above --- MV1, TEV and TAV, including TAV still pictures --- from a single command, choosing the right decoder by inspecting the file. It is the recommended way to play video; the format-specific players remain available for compatibility. Subtitles are shown automatically for files that carry them, and chaptered (UCF) streams are navigable.
|
||||||
|
|
||||||
|
Run with \code{-i} for interactive playback. The controls are:
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\textbf{Backspace} --- stop and exit (hold).
|
||||||
|
\1\textbf{Space} --- pause and resume.
|
||||||
|
\1\textbf{Left} / \textbf{Right} --- seek backward / forward.
|
||||||
|
\1\textbf{Up} / \textbf{Down} --- volume up / down.
|
||||||
|
\1\textbf{Page Up} / \textbf{Page Down} --- previous / next chapter, for chaptered files.
|
||||||
|
\1\textbf{A} --- toggle ASCII-render mode.
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
\index{ASCII-render mode}In \textbf{ASCII-render mode} the picture is drawn as text-mode character art rather than pixels --- a novelty, low-fidelity view of the same playing video. Begin in this mode with \code{playmov -ascii file}, or press \textbf{A} to switch in and out at any time, including mid-playback.
|
||||||
|
|
||||||
|
\section{Audio}
|
||||||
|
|
||||||
|
\index{MP2}\index{TAD}For sound, \thedos\ plays standard \textbf{MP2} (MPEG-1 Audio Layer II), raw and wave-wrapped PCM, and its own compressed format, \textbf{TAD} (\thismachine\ Advanced Audio). All audio plays back at the hardware's 32\,kHz stereo. As with video, TAD files are prepared on a host computer; MP2 is a widely interchangeable format, and PCM/WAV are uncompressed.
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\dossynopsis{playmp2}[file]{Plays MP2 (MPEG-1 Audio Layer II) audio.}
|
||||||
|
\1\dossynopsis{playtad}[file]{Plays TAD audio.}
|
||||||
|
\1\dossynopsis{playpcm}[file]{Plays raw PCM audio.}
|
||||||
|
\1\dossynopsis{playwav}[file]{Plays linear-PCM or ADPCM \code{.wav} audio.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
\section{Music}
|
||||||
|
|
||||||
|
\index{Taud}\thismachine\ has a native tracker music format, \textbf{Taud}, played by the built-in tracker engine (see the \emph{Audio} chapter in the \thismachine\ part). Unlike the streamed audio formats above, a Taud file is a compact \emph{score} --- samples plus the patterns that sequence them --- so a whole piece of music occupies very little space.
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\dossynopsis{playtaud}[file]{Plays a Taud module, with a text-mode visualiser. An optional second argument selects which song within the file to play. Hold Backspace to exit.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
Authoring Taud music, and the format itself, are the subject of a separate manual and are not described here.
|
||||||
|
|
||||||
|
|
||||||
\chapter{Writing Your Own Apps}
|
\chapter{Writing Your Own Apps}
|
||||||
@@ -110,6 +254,15 @@ The command line arguments are given via the array of strings named `exec\_args`
|
|||||||
Index zero holds the name used to invoke the app, and the rest hold the actual arguments.
|
Index zero holds the name used to invoke the app, and the rest hold the actual arguments.
|
||||||
|
|
||||||
|
|
||||||
|
\section{Describing Your App: the Synopsis File}
|
||||||
|
|
||||||
|
\index{synopsis file}Every command should ship a machine-readable description of its own command-line interface, written in the \thedos\ Synopsis Format (TSF) and described in full in the \emph{Command Synopsis Format} chapter.
|
||||||
|
|
||||||
|
The rule is simple: the synopsis lives \emph{next to} the program, with the program's full filename plus a \code{.synopsis} extension. An app installed as \code{cp.js} in \code{\rs{}tvdos\rs{}bin} therefore ships alongside it as \code{\rs{}tvdos\rs{}bin\rs{}cp.js.synopsis}. \thedos\ uses these files to generate help text and tab-completion, so providing one makes your app a first-class citizen of the system.
|
||||||
|
|
||||||
|
Built-in coreutils have no on-disk executable to sit beside, so their synopses live together in \code{\rs{}tvdos\rs{}synopsis}, named for the command (for example \code{\rs{}tvdos\rs{}synopsis\rs{}cp.synopsis}). Either way, once a synopsis is in place the \code{synopsis} command --- and its \code{help} alias --- will display it, and the shell will tab-complete against it.
|
||||||
|
|
||||||
|
|
||||||
\section{Invoking Coreutils on the user Apps}
|
\section{Invoking Coreutils on the user Apps}
|
||||||
|
|
||||||
DOS coreutils and some of the internal functions can be used on Javascript program.
|
DOS coreutils and some of the internal functions can be used on Javascript program.
|
||||||
@@ -135,6 +288,254 @@ Due to the non-preemptive nature of the virtual machine, the termination\footnot
|
|||||||
While- and For-loops are always have such checks injected, but the `read()` is not checked for the termination.
|
While- and For-loops are always have such checks injected, but the `read()` is not checked for the termination.
|
||||||
|
|
||||||
|
|
||||||
|
\chapter{Command Synopsis Format}
|
||||||
|
\label{ch:tsf}
|
||||||
|
|
||||||
|
\index{TSF}\index{synopsis format}The \thedos\ Synopsis Format (TSF) is the language in which a command describes its own command-line interface. Every command is expected to ship a TSF document --- a file with the command's full name plus a \code{.synopsis} extension, stored in the same directory as the program. The command \code{cp.js} in \code{\rs{}tvdos\rs{}bin}, for instance, is accompanied by \code{cp.js.synopsis} in that same directory. \thedos\ reads these documents to drive shell completion, help generation, and argument validation.
|
||||||
|
|
||||||
|
The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, NOT RECOMMENDED, MAY and OPTIONAL in this chapter are to be interpreted as described in RFC 2119 and RFC 8174 when, and only when, they appear in all capitals.
|
||||||
|
|
||||||
|
\section{Scope}
|
||||||
|
|
||||||
|
A TSF document describes a command's grammar: its options and flags, positional arguments, subcommands, argument types, completion sources and validation constraints.
|
||||||
|
|
||||||
|
A TSF document MUST be valid JSON, and MUST be encoded so that its byte stream contains only ASCII characters: any character outside the ASCII range (U+0000--U+007F) MUST be written as a JSON \texttt{\textbackslash u} escape (a backslash, \texttt{u}, and four hexadecimal digits) rather than as a literal multibyte character. Consumers MUST decode such escapes per the JSON specification.
|
||||||
|
|
||||||
|
\section{Design Goals}
|
||||||
|
|
||||||
|
TSF SHALL be machine-readable, human-authorable, and able to support automatic shell completion, automatic help generation, parser generation and GUI generation.
|
||||||
|
|
||||||
|
The structured synopsis grammar SHALL be the sole normative description of command syntax; every other representation, including human-readable usage strings, is treated as output generated from it.
|
||||||
|
|
||||||
|
\section{Document Structure}
|
||||||
|
|
||||||
|
A TSF document SHALL contain one JSON object.
|
||||||
|
|
||||||
|
\begin{lstlisting}
|
||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "cp",
|
||||||
|
"summary": "Copy files and directories",
|
||||||
|
"symbols": {},
|
||||||
|
"synopsis": {}
|
||||||
|
}
|
||||||
|
\end{lstlisting}
|
||||||
|
|
||||||
|
Its fields are:
|
||||||
|
|
||||||
|
\begin{tabulary}{\textwidth}{lclL}
|
||||||
|
Field & Req. & Type & Notes \\
|
||||||
|
\hline
|
||||||
|
tsfVersion & yes & string & Version of TSF this document targets. \\
|
||||||
|
name & yes & string & Command name as invoked. \\
|
||||||
|
summary & yes & string & One-line description. \\
|
||||||
|
symbols & yes & object & The symbol table. \\
|
||||||
|
synopsis & yes & object & The synopsis grammar root node. \\
|
||||||
|
description & no & string & Free-form long description for help generation. \\
|
||||||
|
constraints & no & array & Constraint objects. \\
|
||||||
|
metadata & no & object & Free-form, non-normative data for authors and hosts. \\
|
||||||
|
\end{tabulary}
|
||||||
|
|
||||||
|
\section{The Symbol Table}
|
||||||
|
|
||||||
|
All command elements SHALL be declared in the symbol table, and the synopsis grammar SHALL reference them by identifier.
|
||||||
|
|
||||||
|
\begin{lstlisting}
|
||||||
|
"symbols": {
|
||||||
|
"recursive": { "kind": "option", "long": "--recursive", "short": "-r" },
|
||||||
|
"source": { "kind": "positional", "type": "path" }
|
||||||
|
}
|
||||||
|
\end{lstlisting}
|
||||||
|
|
||||||
|
Every symbol has a \code{kind}, one of \code{option}, \code{positional}, \code{subcommand} or \code{group}.
|
||||||
|
|
||||||
|
\section{Argument Descriptors}
|
||||||
|
|
||||||
|
An \emph{argument descriptor} describes a single consumed value. The same shape is used in two places: directly on a \code{positional} symbol, and as the \code{value} of an \code{option} symbol.
|
||||||
|
|
||||||
|
\begin{tabulary}{\textwidth}{lclL}
|
||||||
|
Field & Req. & Type & Notes \\
|
||||||
|
\hline
|
||||||
|
type & no & string & One of the built-in types. Defaults to \code{string}. \\
|
||||||
|
name & no & string & Metavar shown in generated usage (e.g.\ FILE, WHEN). \\
|
||||||
|
values & cond. & array & Permitted values; REQUIRED when \code{type} is \code{enum}. \\
|
||||||
|
default & no & any & Default value, for help and GUI prefill. \\
|
||||||
|
validation & no & object & Value-level validation (below). \\
|
||||||
|
completion & no & object & Completion override. \\
|
||||||
|
summary & no & string & Short description of the value. \\
|
||||||
|
\end{tabulary}
|
||||||
|
|
||||||
|
Each entry in \code{values} SHALL be either a bare JSON value or an object \code{\{ "value": v, "summary": s \}}; the optional per-value summary feeds completion hints and help.
|
||||||
|
|
||||||
|
\subsection{Validation}
|
||||||
|
|
||||||
|
The \code{validation} object expresses checks the grammar cannot:
|
||||||
|
|
||||||
|
\begin{tabulary}{\textwidth}{lL}
|
||||||
|
Field & Notes \\
|
||||||
|
\hline
|
||||||
|
pattern & A regular expression the value MUST match (string-like types). \\
|
||||||
|
minimum & Inclusive lower bound (numeric types). \\
|
||||||
|
maximum & Inclusive upper bound (numeric types). \\
|
||||||
|
minLength & Minimum length in characters (string-like types). \\
|
||||||
|
maxLength & Maximum length in characters (string-like types). \\
|
||||||
|
\end{tabulary}
|
||||||
|
|
||||||
|
The regular-expression flavour for \code{pattern} is implementation-defined; authors are RECOMMENDED to keep to a portable subset. As the document is ASCII-only, any non-ASCII character within a pattern MUST be escaped.
|
||||||
|
|
||||||
|
\section{Option Symbols}
|
||||||
|
|
||||||
|
\begin{lstlisting}
|
||||||
|
"output": {
|
||||||
|
"kind": "option",
|
||||||
|
"long": "--output",
|
||||||
|
"short": "-o",
|
||||||
|
"summary": "Write output to FILE",
|
||||||
|
"value": { "name": "FILE", "type": "file", "required": true }
|
||||||
|
}
|
||||||
|
\end{lstlisting}
|
||||||
|
|
||||||
|
\begin{tabulary}{\textwidth}{lclL}
|
||||||
|
Field & Req. & Type & Notes \\
|
||||||
|
\hline
|
||||||
|
kind & yes & string & \code{option}. \\
|
||||||
|
long & cond. & string & Long form, e.g.\ \code{--recursive}. \\
|
||||||
|
short & cond. & string & Short form, e.g.\ \code{-r}. \\
|
||||||
|
summary & no & string & One-line description. \\
|
||||||
|
value & no & object & Argument descriptor. Omit for a bare flag. \\
|
||||||
|
negatable & no & boolean & If true, a \code{--no-<long>} form is also accepted. \\
|
||||||
|
\end{tabulary}
|
||||||
|
|
||||||
|
At least one of \code{long} or \code{short} SHALL exist. The \code{value} object MAY carry a \code{required} field (default true): when false, the argument is optional, as in \code{--color} with or without \code{=WHEN}. How often an option may repeat is expressed in the grammar (via \code{repeat} or \code{oneOrMore}), not by a field on the symbol.
|
||||||
|
|
||||||
|
\section{Positional Symbols}
|
||||||
|
|
||||||
|
A positional symbol is an argument descriptor plus its \code{kind}.
|
||||||
|
|
||||||
|
\begin{lstlisting}
|
||||||
|
"source": { "kind": "positional", "type": "path", "summary": "Source file" }
|
||||||
|
\end{lstlisting}
|
||||||
|
|
||||||
|
Its fields are \code{kind} (\code{positional}) followed by the argument-descriptor fields (\code{type}, \code{name}, \code{values}, \code{default}, \code{validation}, \code{completion}, \code{summary}). Whether a positional is required or optional is expressed in the grammar by wrapping it in \code{optional} or not, keeping a single source of truth.
|
||||||
|
|
||||||
|
\section{Subcommand Symbols}
|
||||||
|
|
||||||
|
\begin{lstlisting}
|
||||||
|
"clone": { "kind": "subcommand", "summary": "Clone repository", "tsf": "git.clone" }
|
||||||
|
\end{lstlisting}
|
||||||
|
|
||||||
|
A subcommand MAY reference an embedded or an external TSF document through its \code{tsf} field; resolution of that reference is implementation-specific.
|
||||||
|
|
||||||
|
\section{Group Symbols}
|
||||||
|
|
||||||
|
A group collects related symbols --- typically options --- so the grammar can refer to them collectively. A group is what backs the conventional \code{[OPTION...]} slot.
|
||||||
|
|
||||||
|
\begin{lstlisting}
|
||||||
|
"commonOptions": {
|
||||||
|
"kind": "group",
|
||||||
|
"summary": "Common options",
|
||||||
|
"members": ["recursive", "force", "verbose"]
|
||||||
|
}
|
||||||
|
\end{lstlisting}
|
||||||
|
|
||||||
|
A group has \code{kind} (\code{group}), a \code{members} array of symbol identifiers, and an optional \code{summary}. A reference to a group is equivalent to a \code{choice} over its members; wrapping that reference in \code{repeat} yields the familiar ``any number of these options, in any order'' behaviour.
|
||||||
|
|
||||||
|
\section{The Synopsis Grammar}
|
||||||
|
|
||||||
|
The \code{synopsis} object describes valid invocations as a tree of nodes. Every node has a \code{type}:
|
||||||
|
|
||||||
|
\begin{tabulary}{\textwidth}{lL}
|
||||||
|
Node & Meaning \\
|
||||||
|
\hline
|
||||||
|
sequence & All children appear in order. Has a \code{children} array. (\code{A B C}) \\
|
||||||
|
choice & Exactly one child appears. Has a \code{children} array. (\code{(A | B | C)}) \\
|
||||||
|
optional & The single \code{child} appears zero or one time. (\code{[A]}) \\
|
||||||
|
repeat & The single \code{child} appears zero or more times. (\code{A...}) \\
|
||||||
|
oneOrMore & The single \code{child} appears at least once; sugar for \code{sequence[A, repeat[A]]}. (\code{A [A...]}) \\
|
||||||
|
reference & References a \code{symbol} by identifier. A reference to a group expands to a \code{choice} over its members. \\
|
||||||
|
\end{tabulary}
|
||||||
|
|
||||||
|
\section{A Complete Example}
|
||||||
|
|
||||||
|
The human-readable form \code{cp [OPTION...] SOURCE DEST} is expressed as:
|
||||||
|
|
||||||
|
\begin{lstlisting}
|
||||||
|
"symbols": {
|
||||||
|
"recursive": { "kind": "option", "long": "--recursive", "short": "-r" },
|
||||||
|
"force": { "kind": "option", "long": "--force", "short": "-f" },
|
||||||
|
"options": { "kind": "group", "members": ["recursive", "force"] },
|
||||||
|
"source": { "kind": "positional", "type": "path", "name": "SOURCE" },
|
||||||
|
"destination": { "kind": "positional", "type": "path", "name": "DEST" }
|
||||||
|
},
|
||||||
|
"synopsis": {
|
||||||
|
"type": "sequence",
|
||||||
|
"children": [
|
||||||
|
{ "type": "repeat", "child": { "type": "reference", "symbol": "options" } },
|
||||||
|
{ "type": "reference", "symbol": "source" },
|
||||||
|
{ "type": "reference", "symbol": "destination" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
\end{lstlisting}
|
||||||
|
|
||||||
|
Because \code{options} is a declared symbol, the \code{[OPTION...]} slot satisfies the rule that every referenced element exists in the symbol table.
|
||||||
|
|
||||||
|
\section{Argument Types}
|
||||||
|
|
||||||
|
The built-in primitive types are \code{string}, \code{integer}, \code{float}, \code{boolean}, \code{path}, \code{file}, \code{directory}, \code{url}, \code{hostname}, \code{user}, \code{group}, \code{command} and \code{enum}.
|
||||||
|
|
||||||
|
A descriptor whose \code{type} is \code{enum} SHALL provide a \code{values} array. The \code{values} array MAY also be supplied for non-\code{enum} types as a soft suggestion list that informs completion without restricting valid input. Unknown types SHALL be interpreted as \code{string}; implementations are RECOMMENDED to emit a diagnostic when they do, since an unknown type is usually an authoring error. Each type carries a default completion behaviour --- \code{path}, \code{file} and \code{directory} complete against the filesystem, \code{user} and \code{group} against the account databases, \code{enum} against its \code{values} --- which a \code{completion} block overrides.
|
||||||
|
|
||||||
|
\section{Completion}
|
||||||
|
|
||||||
|
If a descriptor has no \code{completion} block, completion is derived automatically from its \code{type}. A block overrides that default and names a \code{method}:
|
||||||
|
|
||||||
|
\begin{tabulary}{\textwidth}{lL}
|
||||||
|
method & Notes \\
|
||||||
|
\hline
|
||||||
|
type & Use the default completion implied by the \code{type}. (Implicit when no block is present.) \\
|
||||||
|
enum & Complete from the descriptor's \code{values}. (Implicit when \code{type} is \code{enum}.) \\
|
||||||
|
internal & Use a named provider resolved by the host (a \code{provider} field names it). \\
|
||||||
|
command & Run a command whose output supplies the candidates. \\
|
||||||
|
list & Offer a static inline list of suggestions, without restricting input. \\
|
||||||
|
none & Suppress completion for this value. \\
|
||||||
|
\end{tabulary}
|
||||||
|
|
||||||
|
\section{Constraints}
|
||||||
|
|
||||||
|
Constraints describe relationships not expressible in the grammar, and are listed in the root \code{constraints} array. Symmetric constraints use a \code{symbols} field; asymmetric constraints use \code{subject} and \code{targets}.
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\inlinesynopsis{conflicts}{symmetric --- the listed \code{symbols} are mutually exclusive.}
|
||||||
|
\1\inlinesynopsis{requires}{asymmetric --- if \code{subject} is present, every symbol in \code{targets} MUST also be present.}
|
||||||
|
\1\inlinesynopsis{implies}{asymmetric derivation --- if \code{subject} is present, every symbol in \code{targets} is implicitly set (a side effect, not a rejection).}
|
||||||
|
\1\inlinesynopsis{cardinality}{symmetric --- constrains how many of the listed \code{symbols} may appear, via \code{minimum} and \code{maximum}.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
Three of these (\code{conflicts}, \code{requires}, \code{cardinality}) are validation predicates that decide whether an invocation is well-formed; \code{implies} instead sets a value.
|
||||||
|
|
||||||
|
\begin{lstlisting}
|
||||||
|
"constraints": [
|
||||||
|
{ "type": "conflicts", "symbols": ["stdout", "output"] },
|
||||||
|
{ "type": "cardinality", "symbols": ["create", "extract", "list"],
|
||||||
|
"minimum": 1, "maximum": 1 }
|
||||||
|
]
|
||||||
|
\end{lstlisting}
|
||||||
|
|
||||||
|
\section{Generated Usage and Compatibility}
|
||||||
|
|
||||||
|
Implementations SHOULD generate human-readable usage text from the synopsis grammar; that text is non-authoritative output, the grammar remaining the sole normative description.
|
||||||
|
|
||||||
|
TSF distinguishes additive content, which may be ignored safely, from structural content, which may not. Unknown \emph{fields} on otherwise-valid objects SHALL be ignored, so future minor versions MAY add fields without breaking existing documents or consumers. Unknown \emph{grammar node types} and unknown \emph{symbol kinds} SHALL cause the document to be rejected or to enter an explicitly defined degraded mode, because ignoring them would silently change the set of accepted invocations.
|
||||||
|
|
||||||
|
Authors and hosts that need to attach implementation-specific data SHOULD do so inside the root \code{metadata} object or under field names prefixed with \code{x-}; names without that prefix are reserved for future versions of this specification. Consumers SHOULD report the highest \code{tsfVersion} they support so producers can downgrade gracefully.
|
||||||
|
|
||||||
|
\section{How \thedos\ Uses Synopsis Documents}
|
||||||
|
|
||||||
|
A synopsis document is put to work in three ways. The shell uses it for \emph{tab-completion}: when you press Tab part-way through an argument, the grammar tells the shell whether to offer option flags, a fixed set of \code{enum} values, a subcommand name, or filesystem entries (for the \code{path}, \code{file} and \code{directory} types). The \code{synopsis} command --- and its \code{help} alias --- uses it for \emph{help}, printing the summary, a generated usage line, and the arguments, options and constraints. Authors may further use the \code{validation} and \code{constraints} sections to check an invocation before acting on it.
|
||||||
|
|
||||||
|
\thedos\ finds a command's document the same way it finds the command itself: an app's synopsis sits next to its executable (\code{geturl.js} beside \code{geturl.js.synopsis}), while the built-in coreutils, having no file of their own, keep theirs in \code{\rs{}tvdos\rs{}synopsis}. Aliases resolve to the canonical command's document, so \code{ls} is described by \code{dir.synopsis}. Parsed documents are cached under \code{\rs{}tvdos\rs{}cache\rs{}synopsis} so that repeated completions and help lookups stay fast; the cache is rebuilt on demand and may be deleted at any time.
|
||||||
|
|
||||||
|
|
||||||
\chapter{Pipes}
|
\chapter{Pipes}
|
||||||
|
|
||||||
\index{pipe (DOS)}Pipe is a way to chain the IO of the one program/command into the different programs/commands in series.
|
\index{pipe (DOS)}Pipe is a way to chain the IO of the one program/command into the different programs/commands in series.
|
||||||
@@ -230,6 +631,7 @@ Functions:
|
|||||||
\2\argsynopsis{sread}{returns an empty string}
|
\2\argsynopsis{sread}{returns an empty string}
|
||||||
\1\inlinesynopsis{ZERO}{returns zero upon reading}
|
\1\inlinesynopsis{ZERO}{returns zero upon reading}
|
||||||
\2\argsynopsis{pread}{returns the specified number of zeros}
|
\2\argsynopsis{pread}{returns the specified number of zeros}
|
||||||
|
\1\inlinesynopsis{TMP}{a scratch area for temporary files, addressed as \code{\$:\rs{}TMP\rs{}\dots}. Files written here are not expected to persist.}
|
||||||
\1\inlinesynopsis{CON}{manipulates the screen text buffer, disregarding the colours}
|
\1\inlinesynopsis{CON}{manipulates the screen text buffer, disregarding the colours}
|
||||||
\2\argsynopsis{pread}{reads the texts as bytes.}
|
\2\argsynopsis{pread}{reads the texts as bytes.}
|
||||||
\2\argsynopsis{bread}{reads the texts as bytes.}
|
\2\argsynopsis{bread}{reads the texts as bytes.}
|
||||||
@@ -256,24 +658,34 @@ Functions:
|
|||||||
|
|
||||||
\subsection{Input Events}
|
\subsection{Input Events}
|
||||||
|
|
||||||
Input events are Javascript array of: $$ [\mathrm{event\ name,\ arg_1,\ arg_2 \cdots arg_n}] $$, where:
|
An input event is a Javascript array of the form $$ [\mathrm{event\ name,\ arg_1,\ arg_2 \cdots arg_n}] $$ where the event name is one of \textbf{key\_down}, \textbf{key\_change}, \textbf{mouse\_down}, \textbf{mouse\_up}, \textbf{mouse\_move} or \textbf{mouse\_wheel}. Every event ends with the current key-press buffer (\code{keycode0} through \code{keycode7}), so a handler can detect modifier keys held during a mouse action.
|
||||||
|
|
||||||
\begin{outline}
|
\begin{outline}
|
||||||
\1event name --- one of following: \textbf{key\_down}, \textbf{mouse\_down}, \textbf{mouse\_move}
|
|
||||||
\1arguments for \textbf{key\_down}:
|
\1arguments for \textbf{key\_down}:
|
||||||
\2\argsynopsis{\argN{1}}{Key Symbol (string) of the head key}
|
\2\argsynopsis{\argN{1}}{Key symbol (string) of the head key}
|
||||||
\2\argsynopsis{\argN{2}}{Repeat count of the key event}
|
\2\argsynopsis{\argN{2}}{Repeat count of the key event}
|
||||||
\2\argsynopsis{\argN{3}..\argN{10}}{The keycodes of the pressed keys}
|
\2\argsynopsis{\argN{3}..\argN{10}}{The keycodes of the pressed keys}
|
||||||
\1arguments for \textbf{mouse\_down}:
|
\1arguments for \textbf{key\_change} (a key was released):
|
||||||
|
\2\argsynopsis{\argN{1}}{Key symbol (string) of the key that went up}
|
||||||
|
\2\argsynopsis{\argN{2}}{0}
|
||||||
|
\2\argsynopsis{\argN{3}..\argN{10}}{The keycodes of the keys still held down}
|
||||||
|
\1arguments for \textbf{mouse\_down} / \textbf{mouse\_up}:
|
||||||
\2\argsynopsis{\argN{1}}{X-position of the mouse cursor}
|
\2\argsynopsis{\argN{1}}{X-position of the mouse cursor}
|
||||||
\2\argsynopsis{\argN{2}}{Y-position of the mouse cursor}
|
\2\argsynopsis{\argN{2}}{Y-position of the mouse cursor}
|
||||||
\2\argsynopsis{\argN{3}}{Always the integer 1.}
|
\2\argsynopsis{\argN{3}}{Button mask: 1 = left, 2 = right, 4 = middle (for \textbf{mouse\_up}, the button that was released)}
|
||||||
|
\2\argsynopsis{\argN{4}..}{The key-press buffer}
|
||||||
\1arguments for \textbf{mouse\_move}:
|
\1arguments for \textbf{mouse\_move}:
|
||||||
\2\argsynopsis{\argN{1}}{X-position of the mouse cursor}
|
\2\argsynopsis{\argN{1}}{X-position of the mouse cursor}
|
||||||
\2\argsynopsis{\argN{2}}{Y-position of the mouse cursor}
|
\2\argsynopsis{\argN{2}}{Y-position of the mouse cursor}
|
||||||
\2\argsynopsis{\argN{3}}{1 if the mouse button is held down (i.e. dragging), 0 otherwise}
|
\2\argsynopsis{\argN{3}}{Currently-held button mask (non-zero while dragging)}
|
||||||
\2\argsynopsis{\argN{4}}{X-position of the mouse cursor on the previous frame (previous V-blank of the screen)}
|
\2\argsynopsis{\argN{4}}{X-position of the mouse cursor on the previous frame}
|
||||||
\2\argsynopsis{\argN{5}}{Y-position of the mouse cursor on the previous frame}
|
\2\argsynopsis{\argN{5}}{Y-position of the mouse cursor on the previous frame}
|
||||||
|
\2\argsynopsis{\argN{6}..}{The key-press buffer}
|
||||||
|
\1arguments for \textbf{mouse\_wheel}:
|
||||||
|
\2\argsynopsis{\argN{1}}{X-position of the mouse cursor}
|
||||||
|
\2\argsynopsis{\argN{2}}{Y-position of the mouse cursor}
|
||||||
|
\2\argsynopsis{\argN{3}}{$-1$ for a wheel notch up, $+1$ for a notch down}
|
||||||
|
\2\argsynopsis{\argN{4}..}{The key-press buffer}
|
||||||
\end{outline}
|
\end{outline}
|
||||||
|
|
||||||
|
|
||||||
@@ -328,7 +740,8 @@ External libraries are packaged codes with the intention of being re-used by oth
|
|||||||
External libraries can be stored in following locations:
|
External libraries can be stored in following locations:
|
||||||
|
|
||||||
\begin{enumerate}
|
\begin{enumerate}
|
||||||
\item \code{A:\rs{}tvdos\rs{}include}
|
\item \code{A:\rs{}tvdos\rs{}include}, the home of the libraries bundled with \thedos
|
||||||
|
\item any directory listed in the \code{INCLPATH} variable (configured in \code{commandrc})
|
||||||
\item a path relative to the user program
|
\item a path relative to the user program
|
||||||
\item an absolute path that can be anywhere
|
\item an absolute path that can be anywhere
|
||||||
\end{enumerate}
|
\end{enumerate}
|
||||||
@@ -336,7 +749,7 @@ External libraries can be stored in following locations:
|
|||||||
and can be loaded by:
|
and can be loaded by:
|
||||||
|
|
||||||
\begin{enumerate}
|
\begin{enumerate}
|
||||||
\item \code{let name = require(libraryname)} // no .mjs extension
|
\item \code{let name = require(libraryname)} // no .mjs extension; searches the include directory and INCLPATH
|
||||||
\item \code{let name = require(./libraryname)} // the relative path must start with a dot-slash
|
\item \code{let name = require(./libraryname)} // the relative path must start with a dot-slash
|
||||||
\item \code{let name = require(A:/path/to/library.mjs)} // full path WITH the .mjs extension
|
\item \code{let name = require(A:/path/to/library.mjs)} // full path WITH the .mjs extension
|
||||||
\end{enumerate}
|
\end{enumerate}
|
||||||
@@ -357,3 +770,240 @@ const BAR = 127
|
|||||||
// following line exports the function and the variable
|
// following line exports the function and the variable
|
||||||
exports = { foo, BAR }
|
exports = { foo, BAR }
|
||||||
\end{lstlisting}
|
\end{lstlisting}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
\chapter{Bundled Libraries}
|
||||||
|
|
||||||
|
\index{bundled libraries (DOS)}\thedos\ ships a set of ready-made libraries in \code{A:\rs{}tvdos\rs{}include}. Each is loaded with \code{require} using its base name --- \code{require("fs")}, \code{require("psg")}, and so on --- and returns the object the library exports. This chapter documents each in turn.
|
||||||
|
|
||||||
|
\section{fs --- Filesystem (NodeJS-compatible)}
|
||||||
|
|
||||||
|
\index{fs (library)}\code{fs} wraps the \thedos\ file interface in the familiar NodeJS \code{fs} API. Synchronous (\code{*Sync}) calls, callback-style asynchronous calls, and a \code{promises} namespace are all provided; because the machine has no real concurrency, the ``asynchronous'' calls execute immediately and then invoke the callback or resolve the promise. Binary data is exchanged as \code{Uint8Array}; supplying an encoding (\code{"utf8"}, \code{"binary"}, \dots) exchanges strings instead.
|
||||||
|
|
||||||
|
\begin{lstlisting}
|
||||||
|
let fs = require("fs")
|
||||||
|
let txt = fs.readFileSync("A:/etc/motd", "utf8")
|
||||||
|
fs.writeFileSync("A:/tmp/hello.txt", "hi", "utf8")
|
||||||
|
fs.readdirSync("A:/").forEach(println)
|
||||||
|
\end{lstlisting}
|
||||||
|
|
||||||
|
Frequently used members:
|
||||||
|
\begin{outline}
|
||||||
|
\1\inlinesynopsis{readFileSync}[path, options]{reads an entire file.}
|
||||||
|
\1\inlinesynopsis{writeFileSync}[path, data, options]{writes (and truncates) a file.}
|
||||||
|
\1\inlinesynopsis{appendFileSync}[path, data, options]{appends to a file.}
|
||||||
|
\1\inlinesynopsis{existsSync}[path]{whether a path exists.}
|
||||||
|
\1\inlinesynopsis{statSync}[path]{returns a \code{Stats} object.}
|
||||||
|
\1\inlinesynopsis{readdirSync}[path]{lists a directory.}
|
||||||
|
\1\inlinesynopsis{mkdirSync}[path]{creates a directory.}
|
||||||
|
\1\inlinesynopsis{unlinkSync, rmSync, rmdirSync}[path]{remove files / directories.}
|
||||||
|
\1\inlinesynopsis{renameSync, copyFileSync, cpSync}[src, dest]{rename and copy.}
|
||||||
|
\1\inlinesynopsis{openSync, readSync, writeSync, closeSync}{the low-level descriptor calls.}
|
||||||
|
\end{outline}
|
||||||
|
The corresponding callback forms (\code{readFile}, \code{writeFile}, \dots), the \code{promises} namespace, the \code{Stats} and \code{Dirent} classes, and the usual \code{constants} are also exported.
|
||||||
|
|
||||||
|
\section{getopt --- Option Parsing}
|
||||||
|
|
||||||
|
\index{getopt (library)}\code{getopt} is a port of the POSIX \code{getopt()} routine. Construct a \code{BasicParser} from an option string and the argument vector, then call \code{getopt()} repeatedly; each call returns an object \code{\{ option, optarg \}} for the next option, or \code{undefined} at the end. A colon after a letter in the option string means that option takes an argument; long aliases are written in parentheses.
|
||||||
|
|
||||||
|
\begin{lstlisting}
|
||||||
|
let getopt = require("getopt")
|
||||||
|
let parser = new getopt.BasicParser("r(recursive)o:(output)", exec_args)
|
||||||
|
let o
|
||||||
|
while ((o = parser.getopt()) !== undefined) {
|
||||||
|
switch (o.option) {
|
||||||
|
case 'r': /* recursive */ break
|
||||||
|
case 'o': let target = o.optarg; break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
\end{lstlisting}
|
||||||
|
|
||||||
|
\section{gl --- Graphics Library}
|
||||||
|
|
||||||
|
\index{gl (library)}\code{gl} is the same graphics-drawing library installed at boot as the \code{GL} namespace; \code{require("gl")} returns the same set of textures, sprite-sheets and drawing functions. It is documented in full in the \emph{The Graphics Library} chapter.
|
||||||
|
|
||||||
|
\section{net --- Internet Text Fetch}
|
||||||
|
|
||||||
|
\index{net (library)}\code{net} fetches text over the network through an attached HTTP modem, translating ordinary URLs into the form the modem expects.
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\inlinesynopsis{isAvailable}[]{true if an HTTP modem is attached.}
|
||||||
|
\1\inlinesynopsis{getHttpDrive}[]{the drive letter bound to the HTTP modem, or null.}
|
||||||
|
\1\inlinesynopsis{fetchText}[url]{fetches a URL and returns the body as a string, or null on failure.}
|
||||||
|
\1\inlinesynopsis{fetchTextOrThrow}[url]{as \code{fetchText}, but throws on failure.}
|
||||||
|
\1\inlinesynopsis{open}[url]{returns a file descriptor backed by the modem, whose \code{sread}/\code{bread} trigger the fetch.}
|
||||||
|
\1\inlinesynopsis{toModemPath}[url]{returns the \code{<drive>:\rs{}<url>} path the fetch would use.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
\section{pcm --- PCM and ADPCM Decoding}
|
||||||
|
|
||||||
|
\index{pcm (library)}\code{pcm} decodes linear PCM and Microsoft ADPCM into the audio hardware's 8-bit format, resampling to the 32\,kHz hardware rate as needed. It underpins the \code{playpcm} and \code{playwav} players.
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\inlinesynopsis{decodeLPCM}[inPtr, outPtr, inputLen, config]{decodes linear PCM. \code{config} gives \code{nChannels}, \code{bitsPerSample}, \code{samplingRate} and \code{blockSize}.}
|
||||||
|
\1\inlinesynopsis{decodeMS\_ADPCM}[inPtr, outPtr, blockSize, config]{decodes one Microsoft-ADPCM block.}
|
||||||
|
\1\inlinesynopsis{HW\_SAMPLING\_RATE}{the hardware sampling rate, 32000.}
|
||||||
|
\end{outline}
|
||||||
|
The sample-conversion helpers \code{s8Tou8}, \code{s16Tou8}, \code{u16Tos16} and \code{randomRound} are exported too.
|
||||||
|
|
||||||
|
\section{psg --- Programmable Sound Generator}
|
||||||
|
|
||||||
|
\index{psg (library)}\code{psg} is a software sound generator: it synthesises classic chiptune waveforms into a buffer and sends the result to a playhead as PCM. It is handy for sound effects and simple music without authoring a full tracker module.
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\inlinesynopsis{makeBuffer}[length]{allocates a mixing buffer; \code{makeBufferNative} / \code{freeBufferNative} use native memory.}
|
||||||
|
\1\inlinesynopsis{clearBuffer}[buf, offsetSec, lengthSec]{silences part of a buffer.}
|
||||||
|
\1\inlinesynopsis{makeSquare}{writes a square wave (with duty cycle) into a buffer.}
|
||||||
|
\1\inlinesynopsis{makeTriangle, makeAliasedTriangle, makeAliasedTriangleNES}{triangle-wave variants.}
|
||||||
|
\1\inlinesynopsis{makeNoise}{writes an LFSR noise waveform.}
|
||||||
|
\1\inlinesynopsis{sendBuffer, sendBufferFast}[buf, playhead, offsetSec, lengthSec]{uploads a buffer to a playhead for playback.}
|
||||||
|
\end{outline}
|
||||||
|
The wave generators take an offset, frequency, amplitude, pan and a mixing operation, so several voices can be layered into one buffer.
|
||||||
|
|
||||||
|
\section{taud --- Tracker Module Loading}
|
||||||
|
|
||||||
|
\index{taud (library)}\code{taud} moves Taud music between disk and the tracker hardware. It is intentionally small; authoring Taud music belongs to the separate Taud manual.
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\inlinesynopsis{uploadTaudFile}[inFile, songIndex, playhead]{loads one song from a Taud file into the tracker hardware and prepares the given playhead to play it.}
|
||||||
|
\1\inlinesynopsis{captureTrackerDataToFile}[outFile]{writes the current tracker state (samples, instruments, patterns and cue sheet) out to a single-song Taud file.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
\section{mediadec --- Movie and Still Playback}
|
||||||
|
|
||||||
|
\index{mediadec (library)}\code{mediadec} is the decoding engine behind \code{playmov}. Most apps will not need it --- to simply play a file, run \code{playmov}. It is useful when you want to show a \textbf{short intro movie} or a \textbf{still picture} from inside your own program, in any of the machine's formats (MV1, TEV, TAV, and TAV still pictures), without writing a decoder yourself. It detects the format from the file, sets up the graphics and audio hardware, and decodes one frame at a time under your control.
|
||||||
|
|
||||||
|
\code{open} returns a \emph{decoder}; you then call \code{step} in a loop. Each call advances the stream and returns an event describing what happened; when the event is a \code{frame}, call \code{blit} to put it on screen. Always \code{close} the decoder when done --- it frees its buffers and stops audio. The loop below plays an intro the viewer can skip with Backspace:
|
||||||
|
|
||||||
|
\begin{lstlisting}
|
||||||
|
let mediadec = require("mediadec")
|
||||||
|
let dec = mediadec.open("A:/intro.tav")
|
||||||
|
let ev
|
||||||
|
while ((ev = dec.step()).type != "eof") {
|
||||||
|
if (ev.type == "frame") dec.blit()
|
||||||
|
else sys.sleep(1)
|
||||||
|
sys.poke(-40, 1)
|
||||||
|
if (sys.peek(-41) == 67) break // Backspace: skip the intro
|
||||||
|
}
|
||||||
|
dec.close()
|
||||||
|
\end{lstlisting}
|
||||||
|
|
||||||
|
A \textbf{still picture} (a TAV still, played the same way) produces a single frame and then keeps reporting \code{idle}, so you decode it once, show it, and hold it on screen for as long as you like:
|
||||||
|
|
||||||
|
\begin{lstlisting}
|
||||||
|
let dec = mediadec.open("A:/splash.tap")
|
||||||
|
if (dec.step().type == "frame") dec.blit() // decode and show
|
||||||
|
// ...hold it on screen, then:
|
||||||
|
dec.close()
|
||||||
|
\end{lstlisting}
|
||||||
|
|
||||||
|
The library exports \code{open}; everything else is a method on the decoder it returns (called \code{dec} here):
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\inlinesynopsis{open}[path, options]{opens a movie or still and returns a decoder. \code{path} is a fully-qualified path; \code{options} is an optional settings object, rarely needed.}
|
||||||
|
\1\inlinesynopsis[dec]{step}[]{advances the stream and returns \code{\{ type \}}: \code{"frame"} (a frame is ready --- call \code{blit}), \code{"idle"} (nothing to show yet), \code{"eof"} (finished), or \code{"newfile"} (a concatenated next title started).}
|
||||||
|
\1\inlinesynopsis[dec]{blit}[]{draws the current frame to the screen.}
|
||||||
|
\1\inlinesynopsis[dec]{close}[]{frees buffers, stops audio and restores hardware state.}
|
||||||
|
\1\inlinesynopsis[dec]{pause}[on]{pauses (\code{true}) or resumes (\code{false}) playback.}
|
||||||
|
\1\inlinesynopsis[dec]{setVolume}[v]{sets playback volume, 0--255.}
|
||||||
|
\1\inlinesynopsis[dec]{seekSeconds}[n]{seeks \code{n} seconds forward (positive) or back (negative); best-effort, TAV only.}
|
||||||
|
\1\inlinesynopsis[dec]{cue}[dir]{jumps to the previous (\code{-1}) or next (\code{1}) chapter of a chaptered file.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
The decoder also exposes \code{dec.info} --- a description of the file, with \code{width}, \code{height}, \code{fps}, \code{totalFrames}, \code{isStill}, \code{hasAudio}, \code{hasSubtitles} and more --- and \code{dec.subtitle}, the currently-active subtitle for files that carry one.
|
||||||
|
|
||||||
|
\section{typesetter --- Rich-text Layout}
|
||||||
|
|
||||||
|
\index{typesetter (library)}\code{typesetter} wraps, aligns and justifies text for the console using a small markup language. It returns an array of strings, each padded to the requested width, ready to print.
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\inlinesynopsis{typeset}[text, width, opts]{lays out text to the given width (default: the rest of the current row). \code{opts.defaultAlign} is one of \code{l}, \code{c}, \code{r} or \code{j} (justified).}
|
||||||
|
\1\inlinesynopsis{typesetText}[text, width, defaultAlign]{the same, with the width always given explicitly.}
|
||||||
|
\1\inlinesynopsis{expandEntities}[s]{expands the named entities (glyphs, accidentals, arrows, \code{\ }, \dots).}
|
||||||
|
\end{outline}
|
||||||
|
The markup understands \code{<b>}/\code{<s>} for emphasis, \code{<c>}/\code{<r>}/\code{<l>} for per-line alignment, and \code{<o>} for a hanging-indent box anchored at the cursor column.
|
||||||
|
|
||||||
|
\section{wintex --- TUI Windows}
|
||||||
|
|
||||||
|
\index{wintex (library)}\code{wintex} provides a small text-mode window toolkit: framed, titled windows with their own input and drawing handlers, plus scrolling helpers and a ready-made dialog box.
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\inlinesynopsis{WindowObject}[x, y, w, h, inputProcessor, drawContents, title, drawFrame]{constructs a window; the two handler functions receive input events and redraw the contents.}
|
||||||
|
\1\inlinesynopsis{showDialog}[opts]{displays a modal dialog (message, buttons, \dots) and returns the user's choice.}
|
||||||
|
\1\inlinesynopsis{scrollVert, scrollHorz}{compute new cursor and scroll positions for scrollable lists and fields.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
\section{lfs --- Archive Extraction}
|
||||||
|
|
||||||
|
\index{lfs (library)}\code{lfs} extracts \thedos\ Linear File Strip (\code{.lfs}) archives programmatically, transparently inflating any compressed entries.
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\inlinesynopsis{extractOne}[archive, filename]{extracts a single named entry and returns its file descriptor (under \code{\$:\rs{}TMP}).}
|
||||||
|
\1\inlinesynopsis{extractAll}[archive]{unpacks the whole archive and returns the directory descriptor.}
|
||||||
|
\end{outline}
|
||||||
|
Both take an optional \code{autoDecompress} flag (default true) and require a relative-path archive.
|
||||||
|
|
||||||
|
\section{mload --- Bulk File Loading}
|
||||||
|
|
||||||
|
\index{mload (library)}\code{mload} is a single function for packaged apps that need to pre-load resources into memory. Given an array of absolute paths, it loads each into memory and returns an array of the corresponding pointers (or \code{null} where a file could not be loaded).
|
||||||
|
|
||||||
|
\begin{lstlisting}
|
||||||
|
let mload = require("mload")
|
||||||
|
let [fontPtr, sheetPtr] = mload(["A:/app/font.bin", "A:/app/sheet.bin"])
|
||||||
|
\end{lstlisting}
|
||||||
|
|
||||||
|
\section{seqread --- Sequential Disk Reading}
|
||||||
|
|
||||||
|
\index{seqread (library)}\code{seqread} reads a file from a serial-connected disk drive as a stream, which is more efficient than random access for whole-file consumption (media players, archive readers).
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\inlinesynopsis{prepare}[fullPath]{opens a file for sequential reading.}
|
||||||
|
\1\inlinesynopsis{readBytes}[length, ptr]{reads bytes into memory (allocating a buffer if no pointer is given).}
|
||||||
|
\1\inlinesynopsis{readInt, readShort, readOneByte, readFourCC, readString}{read typed values from the stream.}
|
||||||
|
\1\inlinesynopsis{skip}[n]{skips ahead; \code{seek}/\code{rewind} reposition the stream.}
|
||||||
|
\1\inlinesynopsis{getReadCount}[]{the number of bytes read so far.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
\section{seqreadtape --- Sequential Tape Reading}
|
||||||
|
|
||||||
|
\index{seqreadtape (library)}\code{seqreadtape} is the counterpart to \code{seqread} for high-speed tape devices, addressed as \code{\$:\rs{}TAPE0}\dots\code{\$:\rs{}TAPE3}. It reads in large chunks rather than being limited to the serial block size, and exposes the same reading interface (\code{prepare}, \code{readBytes}, \code{readInt}, \dots, \code{skip}, \code{seek}, \code{rewind}) plus \code{close}, \code{isReady} and \code{getCurrentTapeDevice}.
|
||||||
|
|
||||||
|
\section{font --- Character ROM}
|
||||||
|
|
||||||
|
\index{font (library)}\code{font} replaces the displayed character set by uploading a font file into the graphics adapter's character ROM.
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\inlinesynopsis{setLowRom}[fullPath]{loads characters 0--127 from a font file.}
|
||||||
|
\1\inlinesynopsis{setHighRom}[fullPath]{loads characters 128--255 from a font file.}
|
||||||
|
\1\inlinesynopsis{resetLowRom, resetHighRom}[]{restore the default character set.}
|
||||||
|
\end{outline}
|
||||||
|
|
||||||
|
\section{keysym --- Key Symbol Constants}
|
||||||
|
|
||||||
|
\index{keysym (library)}\code{keysym} is a table of named key codes (\code{A}, \code{ENTER}, \code{UP}, \code{SHIFT\_LEFT}, \dots) for use with the \code{input} library's events. Note that these are the keycodes carried by \code{input.withEvent} events, which differ from the character codes returned by \code{con.getch}.
|
||||||
|
|
||||||
|
\begin{lstlisting}
|
||||||
|
let key = require("keysym")
|
||||||
|
input.withEvent((e) => {
|
||||||
|
if (e[0] === "key_down" && e.includes(key.ESCAPE)) quit()
|
||||||
|
})
|
||||||
|
\end{lstlisting}
|
||||||
|
|
||||||
|
\section{tbas --- Terran BASIC Runtime}
|
||||||
|
|
||||||
|
\index{tbas (library)}\code{tbas} is the runtime support library for compiled Terran BASIC programs. It supplies the BASIC built-in functions (\code{PRINT}, \code{SIN}, \code{LEFT}, \dots) and the helpers the BASIC compiler emits. It is loaded automatically by a compiled program and is not normally used by hand; it is documented here only for completeness.
|
||||||
|
|
||||||
|
\section{synopsis --- Command Synopsis Loading}
|
||||||
|
|
||||||
|
\index{synopsis (library)}\code{synopsis} loads and caches TSF \code{.synopsis} documents (see the \emph{Command Synopsis Format} chapter) and answers the questions the shell and the \code{synopsis} command ask of them. It resolves a command name to its document --- an app's sits beside the executable, a coreutil's in \code{A:\rs{}tvdos\rs{}synopsis} --- parses it into a model, and caches the result both in memory and on disk.
|
||||||
|
|
||||||
|
\begin{outline}
|
||||||
|
\1\inlinesynopsis{getCompletion}[command, priorArgs, word]{returns the completion candidates for the word being typed: option flags, enum values, subcommand names, and whether the shell should additionally offer filesystem entries.}
|
||||||
|
\1\inlinesynopsis{getModel}[command]{the parsed model --- summary, options, positional arguments, subcommands and constraints --- or null when the command has no synopsis.}
|
||||||
|
\1\inlinesynopsis{getSummary}[command]{the one-line summary, or null.}
|
||||||
|
\1\inlinesynopsis{getUsage}[command]{a generated usage string, such as \code{cp SOURCE DEST}.}
|
||||||
|
\1\inlinesynopsis{resolveSynopsisPath}[command]{the full path of the command's synopsis document, or null.}
|
||||||
|
\1\inlinesynopsis{registerProvider}[name, fn]{registers an \code{internal} completion provider that \code{fn(word)} supplies candidates for; \code{commands} and \code{envvars} are built in.}
|
||||||
|
\1\inlinesynopsis{clearCache}[]{drops the in-memory caches.}
|
||||||
|
\end{outline}
|
||||||
|
|||||||
@@ -2218,7 +2218,7 @@ from source.
|
|||||||
* FastTracker2 has instrumentwise config (0..255)
|
* FastTracker2 has instrumentwise config (0..255)
|
||||||
* The spec follows FastTracker2, and conversion must be performed when importing from FastTracker2
|
* The spec follows FastTracker2, and conversion must be performed when importing from FastTracker2
|
||||||
176 Uint8 Vibrato sweep
|
176 Uint8 Vibrato sweep
|
||||||
* FastTracker2 instrument config
|
* FastTracker2 instrument config (0..255)
|
||||||
177 Uint8 Default pan value (0..255 full range, see offset 17 for the enable flag)
|
177 Uint8 Default pan value (0..255 full range, see offset 17 for the enable flag)
|
||||||
* ImpulseTracker has samplewise default pan and instrumentwise default pan, and they must be taken into account because Taud has no samplewise config
|
* ImpulseTracker has samplewise default pan and instrumentwise default pan, and they must be taken into account because Taud has no samplewise config
|
||||||
178 Uint16 Pitch-pan centre (4096-TET note value)
|
178 Uint16 Pitch-pan centre (4096-TET note value)
|
||||||
@@ -2226,7 +2226,7 @@ from source.
|
|||||||
181 Uint8 Pan swing (0..255 full range)
|
181 Uint8 Pan swing (0..255 full range)
|
||||||
182 Uint8 Default cutoff (0..254 full range, 255 to off (-1 on IT). Effect range equals to that of ImpulseTracker -- 127 in IT is equal to 254 in Taud)
|
182 Uint8 Default cutoff (0..254 full range, 255 to off (-1 on IT). Effect range equals to that of ImpulseTracker -- 127 in IT is equal to 254 in Taud)
|
||||||
183 Uint8 Default resonance (0..254 full range, 255 to off (-1 on IT). Effect range equals to that of ImpulseTracker -- 127 in IT is equal to 254 in Taud)
|
183 Uint8 Default resonance (0..254 full range, 255 to off (-1 on IT). Effect range equals to that of ImpulseTracker -- 127 in IT is equal to 254 in Taud)
|
||||||
184 Uint16 Sample detune (in 4096-TET unit) (FT2 finetune scale need to be rescaled accordingly)
|
184 Sint16 Sample detune (in 4096-TET unit) (FT2 finetune scale need to be rescaled accordingly)
|
||||||
186 Bit8 Instrument Flag
|
186 Bit8 Instrument Flag
|
||||||
0b 000 www nn
|
0b 000 www nn
|
||||||
n: New note action. 00: note off, 01: note cut, 10: continue, 11: note fade (arranged differently to IT)
|
n: New note action. 00: note off, 01: note cut, 10: continue, 11: note fade (arranged differently to IT)
|
||||||
|
|||||||
@@ -67,6 +67,17 @@ class AudioJSR223Delegate(private val vm: VM) {
|
|||||||
fun stop(playhead: Int) { getPlayhead(playhead)?.isPlaying = false }
|
fun stop(playhead: Int) { getPlayhead(playhead)?.isPlaying = false }
|
||||||
fun isPlaying(playhead: Int) = getPlayhead(playhead)?.isPlaying
|
fun isPlaying(playhead: Int) = getPlayhead(playhead)?.isPlaying
|
||||||
|
|
||||||
|
/** Lowest-numbered playhead that is not currently playing, so a player app can
|
||||||
|
* "occupy" an idle playhead instead of always clobbering playhead 0. Returns
|
||||||
|
* [fallback] when every playhead is busy (or no audio device is present). */
|
||||||
|
fun getFreePlayhead(fallback: Int): Int {
|
||||||
|
val playheads = getFirstSnd()?.playheads ?: return fallback
|
||||||
|
for (i in playheads.indices) {
|
||||||
|
if (!playheads[i].isPlaying) return i
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
// fun setPosition(playhead: Int, pos: Int) { getPlayhead(playhead)?.position = pos and 65535 }
|
// fun setPosition(playhead: Int, pos: Int) { getPlayhead(playhead)?.position = pos and 65535 }
|
||||||
fun getPosition(playhead: Int) = getPlayhead(playhead)?.position
|
fun getPosition(playhead: Int) = getPlayhead(playhead)?.position
|
||||||
|
|
||||||
|
|||||||
@@ -426,6 +426,20 @@ class GraphicsJSR223Delegate(private val vm: VM) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun clearPixelsAll(col1: Int, col2: Int, col3: Int, col4: Int) {
|
||||||
|
getFirstGPU()?.let {
|
||||||
|
it.poke(250884L, col1.toByte())
|
||||||
|
it.poke(250883L, 2)
|
||||||
|
it.poke(250884L, col2.toByte())
|
||||||
|
it.poke(250883L, 4)
|
||||||
|
it.poke(250884L, col3.toByte())
|
||||||
|
it.poke(250883L, 6)
|
||||||
|
it.poke(250884L, col4.toByte())
|
||||||
|
it.poke(250883L, 8)
|
||||||
|
it.applyDelay()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* prints a char as-is; won't interpret them as an escape sequence
|
* prints a char as-is; won't interpret them as an escape sequence
|
||||||
*/
|
*/
|
||||||
@@ -7088,22 +7102,52 @@ class GraphicsJSR223Delegate(private val vm: VM) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload interlaced GOP frame from videoBuffer with deinterlacing.
|
* Copy a single progressive GOP frame out of videoBuffer (Java heap) into a
|
||||||
* Handles field extraction and temporal deinterlacing for GOP frames.
|
* JS-addressable RGB888 RAM buffer, without dithering or uploading. Lets the
|
||||||
|
* mediadec library expose every decoded frame as a generic RAM frame: the
|
||||||
|
* caller then uploads it with uploadRGBToFramebuffer (graphics path) or
|
||||||
|
* samples it for the ASCII renderer — never going through the display planes
|
||||||
|
* just to read the pixels back.
|
||||||
|
*
|
||||||
|
* @param frameIndex Which frame in the GOP to copy (0-based)
|
||||||
|
* @param width Frame width
|
||||||
|
* @param height Frame height
|
||||||
|
* @param bufferOffset Byte offset of the GOP slot in videoBuffer
|
||||||
|
* @param dstRGBAddr Destination RGB888 buffer in VM user memory (width*height*3 bytes)
|
||||||
|
*/
|
||||||
|
fun tavCopyGopFrameToRGB(frameIndex: Int, width: Int, height: Int, bufferOffset: Long, dstRGBAddr: Long) {
|
||||||
|
val gpu = (vm.peripheralTable[1].peripheral as GraphicsAdapter)
|
||||||
|
val frameSize = width * height * 3L
|
||||||
|
val videoBufferOffset = bufferOffset + (frameIndex * frameSize)
|
||||||
|
UnsafeHelper.memcpyRaw(
|
||||||
|
null,
|
||||||
|
gpu.videoBuffer.ptr + videoBufferOffset,
|
||||||
|
null,
|
||||||
|
vm.usermem.ptr + dstRGBAddr,
|
||||||
|
frameSize
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract three consecutive fields of an interlaced GOP frame from videoBuffer
|
||||||
|
* and deinterlace them into an RGB888 RAM buffer — the field-copy + deinterlace
|
||||||
|
* half of uploadInterlacedGopFrameToFramebuffer, WITHOUT the final upload. Used
|
||||||
|
* by the mediadec library to land the decoded frame in RAM (where it can then be
|
||||||
|
* uploaded or sampled), mirroring tavCopyGopFrameToRGB for the progressive case.
|
||||||
*
|
*
|
||||||
* @param frameIndex Current frame index in GOP (0-based)
|
* @param frameIndex Current frame index in GOP (0-based)
|
||||||
* @param gopSize Total number of frames in GOP
|
* @param gopSize Total number of frames in GOP
|
||||||
* @param width Frame width
|
* @param width Frame width
|
||||||
* @param fieldHeight Height of each field (half of display height)
|
* @param fieldHeight Height of each field (half of display height)
|
||||||
* @param fullHeight Full display height (2 * fieldHeight)
|
* @param fullHeight Full display height (2 * fieldHeight) — unused here, kept for call symmetry
|
||||||
* @param frameCount Global frame counter for dithering
|
* @param frameCount Global frame counter (deinterlacer cadence)
|
||||||
* @param bufferOffset Start offset of GOP in videoBuffer
|
* @param bufferOffset Start offset of GOP in videoBuffer
|
||||||
* @param prevFieldAddr Memory address for previous field buffer
|
* @param prevFieldAddr Memory address for previous field buffer (scratch)
|
||||||
* @param currentFieldAddr Memory address for current field buffer
|
* @param currentFieldAddr Memory address for current field buffer (scratch)
|
||||||
* @param nextFieldAddr Memory address for next field buffer
|
* @param nextFieldAddr Memory address for next field buffer (scratch)
|
||||||
* @param deinterlaceOutputAddr Memory address for deinterlaced output
|
* @param deinterlaceOutputAddr Destination RGB888 buffer (width*fullHeight*3 bytes)
|
||||||
*/
|
*/
|
||||||
fun uploadInterlacedGopFrameToFramebuffer(
|
fun tavDeinterlaceGopFrameToRGB(
|
||||||
frameIndex: Int,
|
frameIndex: Int,
|
||||||
gopSize: Int,
|
gopSize: Int,
|
||||||
width: Int,
|
width: Int,
|
||||||
@@ -7158,8 +7202,43 @@ class GraphicsJSR223Delegate(private val vm: VM) {
|
|||||||
prevFieldAddr, currentFieldAddr, nextFieldAddr,
|
prevFieldAddr, currentFieldAddr, nextFieldAddr,
|
||||||
deinterlaceOutputAddr, "yadif"
|
deinterlaceOutputAddr, "yadif"
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Upload deinterlaced full-height frame
|
/**
|
||||||
|
* Upload interlaced GOP frame from videoBuffer with deinterlacing.
|
||||||
|
* Handles field extraction and temporal deinterlacing for GOP frames.
|
||||||
|
*
|
||||||
|
* @param frameIndex Current frame index in GOP (0-based)
|
||||||
|
* @param gopSize Total number of frames in GOP
|
||||||
|
* @param width Frame width
|
||||||
|
* @param fieldHeight Height of each field (half of display height)
|
||||||
|
* @param fullHeight Full display height (2 * fieldHeight)
|
||||||
|
* @param frameCount Global frame counter for dithering
|
||||||
|
* @param bufferOffset Start offset of GOP in videoBuffer
|
||||||
|
* @param prevFieldAddr Memory address for previous field buffer
|
||||||
|
* @param currentFieldAddr Memory address for current field buffer
|
||||||
|
* @param nextFieldAddr Memory address for next field buffer
|
||||||
|
* @param deinterlaceOutputAddr Memory address for deinterlaced output
|
||||||
|
*/
|
||||||
|
fun uploadInterlacedGopFrameToFramebuffer(
|
||||||
|
frameIndex: Int,
|
||||||
|
gopSize: Int,
|
||||||
|
width: Int,
|
||||||
|
fieldHeight: Int,
|
||||||
|
fullHeight: Int,
|
||||||
|
frameCount: Int,
|
||||||
|
bufferOffset: Long,
|
||||||
|
prevFieldAddr: Long,
|
||||||
|
currentFieldAddr: Long,
|
||||||
|
nextFieldAddr: Long,
|
||||||
|
deinterlaceOutputAddr: Long
|
||||||
|
) {
|
||||||
|
// Field copy + deinterlace into the RGB output buffer ...
|
||||||
|
tavDeinterlaceGopFrameToRGB(
|
||||||
|
frameIndex, gopSize, width, fieldHeight, fullHeight, frameCount, bufferOffset,
|
||||||
|
prevFieldAddr, currentFieldAddr, nextFieldAddr, deinterlaceOutputAddr
|
||||||
|
)
|
||||||
|
// ... then upload the deinterlaced full-height frame.
|
||||||
uploadRGBToFramebuffer(deinterlaceOutputAddr, width, fullHeight, frameCount, false)
|
uploadRGBToFramebuffer(deinterlaceOutputAddr, width, fullHeight, frameCount, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
653
tvdos_synopsis_format_draft.md
Normal file
653
tvdos_synopsis_format_draft.md
Normal file
@@ -0,0 +1,653 @@
|
|||||||
|
# TVDOS Synopsis Format (TSF) Version 1.0 Draft
|
||||||
|
|
||||||
|
## 1. Scope
|
||||||
|
|
||||||
|
The TVDOS Synopsis Format (TSF) is a machine-readable command interface description language.
|
||||||
|
|
||||||
|
A TSF document describes:
|
||||||
|
|
||||||
|
* Command grammar
|
||||||
|
* Options and flags
|
||||||
|
* Positional arguments
|
||||||
|
* Subcommands
|
||||||
|
* Argument types
|
||||||
|
* Completion sources
|
||||||
|
* Validation constraints
|
||||||
|
|
||||||
|
The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, **SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **NOT RECOMMENDED**, **MAY**, and **OPTIONAL** in this document are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119) and [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174) when, and only when, they appear in all capitals. Lowercase uses of these words carry their ordinary English meaning and impose no normative requirement.
|
||||||
|
|
||||||
|
TSF lives next to the program, with the program's full filename plus a .synopsis extension. An app installed as `cp.js` in `\tvdos\bin` therefore ships alongside it as `\tvdos\bin\cp.js.synopsis`.
|
||||||
|
|
||||||
|
TSF MUST be valid JSON. A TSF document MUST be encoded such that its byte stream contains only ASCII characters: any character outside the ASCII range (U+0000–U+007F) MUST be represented using a JSON `\uXXXX` escape sequence rather than emitted as a literal multibyte character. Consumers MUST decode such escapes per the JSON specification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. Design Goals
|
||||||
|
|
||||||
|
TSF SHALL:
|
||||||
|
|
||||||
|
* Be machine-readable.
|
||||||
|
* Be human-authorable.
|
||||||
|
* Support automatic shell completion.
|
||||||
|
* Support automatic help generation.
|
||||||
|
* Support parser generation.
|
||||||
|
* Support GUI generation.
|
||||||
|
|
||||||
|
The structured synopsis grammar SHALL be the sole normative description of command syntax; every other representation, including human-readable usage strings, is treated as generated output derived from it. This principle is referenced rather than restated elsewhere in this document.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. Root Object
|
||||||
|
|
||||||
|
A TSF document SHALL contain one JSON object.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tsfVersion": "1.0",
|
||||||
|
"name": "cp",
|
||||||
|
"summary": "Copy files and directories",
|
||||||
|
"symbols": {},
|
||||||
|
"synopsis": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. Root Fields
|
||||||
|
|
||||||
|
| Field | Required | Type | Notes |
|
||||||
|
| ----------- | -------- | ------ | ------------------------------------------------------------ |
|
||||||
|
| tsfVersion | yes | string | Version of TSF this document targets. |
|
||||||
|
| name | yes | string | Command name as invoked. |
|
||||||
|
| summary | yes | string | One-line description. |
|
||||||
|
| symbols | yes | object | The symbol table (§5). |
|
||||||
|
| synopsis | yes | object | The synopsis grammar root node (§12). |
|
||||||
|
| description | no | string | Free-form long description for help generation. |
|
||||||
|
| constraints | no | array | Constraint objects (§18). |
|
||||||
|
| metadata | no | object | Free-form, non-normative data reserved for authors and hosts (§20). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. Symbol Table
|
||||||
|
|
||||||
|
All command elements SHALL be declared in the symbol table. The synopsis grammar SHALL reference symbols by identifier.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"symbols": {
|
||||||
|
"recursive": {
|
||||||
|
"kind": "option",
|
||||||
|
"long": "--recursive",
|
||||||
|
"short": "-r"
|
||||||
|
},
|
||||||
|
|
||||||
|
"source": {
|
||||||
|
"kind": "positional",
|
||||||
|
"type": "path"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 6. Symbol Kinds
|
||||||
|
|
||||||
|
Valid symbol kinds:
|
||||||
|
|
||||||
|
```text
|
||||||
|
option
|
||||||
|
positional
|
||||||
|
subcommand
|
||||||
|
group
|
||||||
|
```
|
||||||
|
|
||||||
|
Each kind is defined in §8, §9, §10, and §11 respectively.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 7. Argument Descriptors
|
||||||
|
|
||||||
|
An *argument descriptor* describes a single consumed value. The same descriptor shape is used in two places: directly on a `positional` symbol (§9), and as the `value` of an `option` symbol (§8). Defining it once keeps the two consistent.
|
||||||
|
|
||||||
|
## 7.1 Argument Descriptor Fields
|
||||||
|
|
||||||
|
| Field | Required | Type | Notes |
|
||||||
|
| ---------- | -------- | -------------- | ---------------------------------------------------------------- |
|
||||||
|
| type | no | string | One of the built-in types (§15). Defaults to `string`. |
|
||||||
|
| name | no | string | Metavar shown in generated usage (e.g. `FILE`, `WHEN`). |
|
||||||
|
| values | cond. | array | Permitted values; **REQUIRED** when `type` is `enum`, otherwise **OPTIONAL** (§15). |
|
||||||
|
| default | no | any | Default value used for help generation and GUI prefill. |
|
||||||
|
| validation | no | object | Value-level validation (§7.2). |
|
||||||
|
| completion | no | object | Completion override (§16). |
|
||||||
|
| summary | no | string | Short description of the value. |
|
||||||
|
|
||||||
|
Each entry in `values` SHALL be either a bare JSON value, or an object of the form `{ "value": <value>, "summary": <string> }`. The optional per-value `summary` is used for completion hints and help generation.
|
||||||
|
|
||||||
|
## 7.2 Validation Object
|
||||||
|
|
||||||
|
`validation` expresses value-level checks that the grammar cannot:
|
||||||
|
|
||||||
|
| Field | Applies to | Notes |
|
||||||
|
| --------- | ----------------- | -------------------------------------------------- |
|
||||||
|
| pattern | string-like types | A regular expression the value MUST match. |
|
||||||
|
| minimum | numeric types | Inclusive lower bound. |
|
||||||
|
| maximum | numeric types | Inclusive upper bound. |
|
||||||
|
| minLength | string-like types | Minimum length in characters. |
|
||||||
|
| maxLength | string-like types | Maximum length in characters. |
|
||||||
|
|
||||||
|
`pattern` is a regular expression; the supported flavour is implementation-defined, and authors are **RECOMMENDED** to restrict patterns to a portable subset. Because the document is ASCII-only (§1), any non-ASCII character within a pattern MUST be `\u`-escaped.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 8. Option Symbols
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"recursive": {
|
||||||
|
"kind": "option",
|
||||||
|
"long": "--recursive",
|
||||||
|
"short": "-r",
|
||||||
|
"summary": "Copy directories recursively"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
An option carrying an argument declares it via `value`, which is an argument descriptor (§7):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"output": {
|
||||||
|
"kind": "option",
|
||||||
|
"long": "--output",
|
||||||
|
"short": "-o",
|
||||||
|
"summary": "Write output to FILE",
|
||||||
|
"value": {
|
||||||
|
"name": "FILE",
|
||||||
|
"type": "file",
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
An enumerated option value (the common `--color=WHEN` idiom) makes completion trivial:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"color": {
|
||||||
|
"kind": "option",
|
||||||
|
"long": "--color",
|
||||||
|
"summary": "Colourise output",
|
||||||
|
"value": {
|
||||||
|
"name": "WHEN",
|
||||||
|
"type": "enum",
|
||||||
|
"values": [
|
||||||
|
{ "value": "always", "summary": "Always colourise" },
|
||||||
|
{ "value": "never", "summary": "Never colourise" },
|
||||||
|
{ "value": "auto", "summary": "Colourise when stdout is a terminal" }
|
||||||
|
],
|
||||||
|
"default": "auto",
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8.1 Option Fields
|
||||||
|
|
||||||
|
| Field | Required | Type | Notes |
|
||||||
|
| --------- | -------- | ------- | --------------------------------------------------------------------- |
|
||||||
|
| kind | yes | string | `option`. |
|
||||||
|
| long | cond. | string | Long form, e.g. `--recursive`. |
|
||||||
|
| short | cond. | string | Short form, e.g. `-r`. |
|
||||||
|
| summary | no | string | One-line description. |
|
||||||
|
| value | no | object | Argument descriptor (§7). Omit for a bare flag. |
|
||||||
|
| negatable | no | boolean | If `true`, a `--no-<long>` form is also accepted. Defaults to `false`. |
|
||||||
|
|
||||||
|
At least one of `long` or `short` SHALL exist.
|
||||||
|
|
||||||
|
The `value` object MAY additionally carry a `required` field (default `true`). When `required` is `true`, the option consumes a mandatory argument; when `false`, the argument is optional (as in `--color` with or without `=WHEN`).
|
||||||
|
|
||||||
|
How many times an option may appear (for example a repeated `-v`) is expressed in the grammar via `repeat` or `oneOrMore` (§13), not by a field on the symbol; this keeps multiplicity in a single place.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 9. Positional Symbols
|
||||||
|
|
||||||
|
A positional symbol is an argument descriptor (§7) plus its `kind`.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"source": {
|
||||||
|
"kind": "positional",
|
||||||
|
"type": "path",
|
||||||
|
"summary": "Source file"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9.1 Positional Fields
|
||||||
|
|
||||||
|
| Field | Required | Type | Notes |
|
||||||
|
| ---------- | -------- | ------ | ------------------------------------------- |
|
||||||
|
| kind | yes | string | `positional`. |
|
||||||
|
| type | no | string | Built-in type (§15). Defaults to `string`. |
|
||||||
|
| name | no | string | Metavar shown in generated usage. |
|
||||||
|
| values | cond. | array | **REQUIRED** when `type` is `enum`. |
|
||||||
|
| default | no | any | Default value. |
|
||||||
|
| validation | no | object | Value-level validation (§7.2). |
|
||||||
|
| completion | no | object | Completion override (§16). |
|
||||||
|
| summary | no | string | One-line description. |
|
||||||
|
|
||||||
|
Whether a positional is required or optional is expressed in the grammar (by wrapping it in `optional` or not), not by a field here; this keeps a single source of truth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 10. Subcommand Symbols
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"clone": {
|
||||||
|
"kind": "subcommand",
|
||||||
|
"summary": "Clone repository",
|
||||||
|
"tsf": "git.clone"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Subcommands MAY reference:
|
||||||
|
|
||||||
|
* embedded TSF documents
|
||||||
|
* external TSF documents
|
||||||
|
|
||||||
|
Implementation-specific resolution of the `tsf` reference is permitted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 11. Group Symbols
|
||||||
|
|
||||||
|
A group collects related symbols, typically options, so that the grammar can refer to them collectively. A group is what backs the conventional `[OPTION...]` slot.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"commonOptions": {
|
||||||
|
"kind": "group",
|
||||||
|
"summary": "Common options",
|
||||||
|
"members": [
|
||||||
|
"recursive",
|
||||||
|
"force",
|
||||||
|
"verbose"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11.1 Group Fields
|
||||||
|
|
||||||
|
| Field | Required | Type | Notes |
|
||||||
|
| ------- | -------- | ------ | ------------------------------------------------ |
|
||||||
|
| kind | yes | string | `group`. |
|
||||||
|
| members | yes | array | Identifiers of the symbols this group contains. |
|
||||||
|
| summary | no | string | One-line description. |
|
||||||
|
|
||||||
|
A `reference` to a group (§13) is equivalent to a `choice` over its members. Wrapping that reference in `repeat` yields the familiar "any number of these options, in any order" behaviour of `[OPTION...]`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 12. Synopsis Grammar
|
||||||
|
|
||||||
|
The synopsis object SHALL describe valid command invocations.
|
||||||
|
|
||||||
|
Every node SHALL contain:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "<node-type>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 13. Grammar Node Types
|
||||||
|
|
||||||
|
## sequence
|
||||||
|
|
||||||
|
All children must appear in order.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "sequence",
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Equivalent:
|
||||||
|
|
||||||
|
```text
|
||||||
|
A B C
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## choice
|
||||||
|
|
||||||
|
Exactly one child must appear.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "choice",
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Equivalent:
|
||||||
|
|
||||||
|
```text
|
||||||
|
(A | B | C)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## optional
|
||||||
|
|
||||||
|
Child may appear zero or one time.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "optional",
|
||||||
|
"child": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Equivalent:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[A]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## repeat
|
||||||
|
|
||||||
|
Child may appear zero or more times.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "repeat",
|
||||||
|
"child": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Equivalent:
|
||||||
|
|
||||||
|
```text
|
||||||
|
A...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## oneOrMore
|
||||||
|
|
||||||
|
Child must appear at least once. This node is sugar for `sequence[A, repeat[A]]` and carries no semantics beyond that combination; it exists for authoring convenience.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "oneOrMore",
|
||||||
|
"child": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Equivalent:
|
||||||
|
|
||||||
|
```text
|
||||||
|
A [A...]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## reference
|
||||||
|
|
||||||
|
References a symbol. When the referenced symbol is a group, the reference expands to a `choice` over the group's members (§11).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "reference",
|
||||||
|
"symbol": "recursive"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 14. Example Synopsis
|
||||||
|
|
||||||
|
Human form:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cp [OPTION...] SOURCE DEST
|
||||||
|
```
|
||||||
|
|
||||||
|
Symbol table (abbreviated):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"symbols": {
|
||||||
|
"recursive": { "kind": "option", "long": "--recursive", "short": "-r" },
|
||||||
|
"force": { "kind": "option", "long": "--force", "short": "-f" },
|
||||||
|
"options": { "kind": "group", "members": ["recursive", "force"] },
|
||||||
|
"source": { "kind": "positional", "type": "path", "name": "SOURCE" },
|
||||||
|
"destination": { "kind": "positional", "type": "path", "name": "DEST" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Synopsis:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"synopsis": {
|
||||||
|
"type": "sequence",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "repeat",
|
||||||
|
"child": {
|
||||||
|
"type": "reference",
|
||||||
|
"symbol": "options"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "reference",
|
||||||
|
"symbol": "source"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "reference",
|
||||||
|
"symbol": "destination"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `options` group is a declared symbol, so the `[OPTION...]` slot now satisfies the rule in §5 that every referenced element exists in the symbol table.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 15. Argument Types
|
||||||
|
|
||||||
|
Built-in primitive types:
|
||||||
|
|
||||||
|
```text
|
||||||
|
string
|
||||||
|
integer
|
||||||
|
float
|
||||||
|
boolean
|
||||||
|
path
|
||||||
|
file
|
||||||
|
directory
|
||||||
|
url
|
||||||
|
hostname
|
||||||
|
user
|
||||||
|
group
|
||||||
|
command
|
||||||
|
enum
|
||||||
|
```
|
||||||
|
|
||||||
|
`enum` restricts a value to one of an explicit set; a descriptor whose `type` is `enum` SHALL provide a `values` array (§7.1). The `values` array MAY also be supplied for non-`enum` types as a soft suggestion list, in which case it informs completion but does not restrict valid input.
|
||||||
|
|
||||||
|
Unknown types SHALL be interpreted as `string`. Implementations are **RECOMMENDED** to emit a diagnostic when they do so, since an unknown type is usually an authoring error rather than an intentional fallback.
|
||||||
|
|
||||||
|
Each type carries a default completion behaviour (§16): for example `path`, `file`, and `directory` complete against the filesystem, `user` and `group` against the host's account databases, and `enum` against its `values`. A `completion` block overrides this default.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 16. Completion
|
||||||
|
|
||||||
|
If a descriptor has no `completion` block, completion is derived automatically from its `type` (and from `values` when the type is `enum`). A `completion` block overrides that default.
|
||||||
|
|
||||||
|
| method | Notes |
|
||||||
|
| -------- | --------------------------------------------------------------------------- |
|
||||||
|
| type | Use the default completion implied by the descriptor's `type`. (Implicit when no block is present.) |
|
||||||
|
| enum | Complete from the descriptor's `values`. (Implicit when `type` is `enum`.) |
|
||||||
|
| internal | Use a named provider resolved by the host. |
|
||||||
|
| command | Run a command whose output supplies the candidates. |
|
||||||
|
| list | Offer a static inline list of suggestions, without restricting input. |
|
||||||
|
| none | Suppress completion for this value. |
|
||||||
|
|
||||||
|
Example using a named internal provider:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"branch": {
|
||||||
|
"kind": "positional",
|
||||||
|
"type": "string",
|
||||||
|
|
||||||
|
"completion": {
|
||||||
|
"method": "internal",
|
||||||
|
"provider": "branches"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 17. Constraints
|
||||||
|
|
||||||
|
Constraints describe relationships not expressible in the grammar. They are listed in the root `constraints` array (§4).
|
||||||
|
|
||||||
|
Three of the four constraint types below (`conflicts`, `requires`, `cardinality`) are validation predicates: they describe whether an invocation is well-formed. The fourth, `implies`, is a derivation: it sets a value as a side effect rather than rejecting input. Consumers should treat it accordingly.
|
||||||
|
|
||||||
|
Field naming is uniform: symmetric constraints use `symbols`; asymmetric constraints use `subject` and `targets`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## conflicts
|
||||||
|
|
||||||
|
Symmetric. The listed symbols are mutually exclusive.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "conflicts",
|
||||||
|
"symbols": [
|
||||||
|
"stdout",
|
||||||
|
"output"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Meaning:
|
||||||
|
|
||||||
|
```text
|
||||||
|
--stdout conflicts with --output
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## requires
|
||||||
|
|
||||||
|
Asymmetric. If `subject` is present, every symbol in `targets` MUST also be present.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "requires",
|
||||||
|
"subject": "output",
|
||||||
|
"targets": [
|
||||||
|
"format"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## implies
|
||||||
|
|
||||||
|
Asymmetric derivation. If `subject` is present, every symbol in `targets` is implicitly set.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "implies",
|
||||||
|
"subject": "verbose",
|
||||||
|
"targets": [
|
||||||
|
"log"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## cardinality
|
||||||
|
|
||||||
|
Symmetric. Constrains how many of the listed symbols may appear.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "cardinality",
|
||||||
|
"symbols": [
|
||||||
|
"create",
|
||||||
|
"extract",
|
||||||
|
"list"
|
||||||
|
],
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Equivalent:
|
||||||
|
|
||||||
|
```text
|
||||||
|
exactly one of:
|
||||||
|
create
|
||||||
|
extract
|
||||||
|
list
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 18. Generated Usage
|
||||||
|
|
||||||
|
Implementations SHOULD generate usage text from the synopsis grammar. Per §2, that text is non-authoritative output; the grammar remains the sole normative description.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 19. Extensibility and Compatibility
|
||||||
|
|
||||||
|
TSF distinguishes between additive content, which may be ignored safely, and structural content, which may not.
|
||||||
|
|
||||||
|
* **Unknown fields** on otherwise-valid objects SHALL be ignored. Future minor versions MAY add fields without invalidating existing documents or consumers.
|
||||||
|
* **Unknown grammar node types (§13) and unknown symbol kinds (§6)** SHALL cause the document to be rejected, or to enter an explicitly defined degraded mode. They cannot be ignored, because doing so would silently change the set of accepted invocations.
|
||||||
|
|
||||||
|
Authors and hosts that need to attach implementation-specific data SHOULD do so either inside the root `metadata` object or under field names prefixed with `x-`. Names without that prefix are reserved for future versions of this specification.
|
||||||
|
|
||||||
|
Future TSF versions MAY introduce additional grammar node types, symbol kinds, types, and constraint types. Consumers SHOULD report the highest `tsfVersion` they support so that producers can downgrade gracefully.
|
||||||
Reference in New Issue
Block a user