mirror of
https://github.com/curioustorvald/tsvm.git
synced 2026-06-06 13:38:30 +09:00
Compare commits
3 Commits
89d3c5d776
...
1e482e32a8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e482e32a8 | ||
|
|
4ff48bba1c | ||
|
|
2dcdff83c8 |
224
README.md
224
README.md
@@ -1,8 +1,222 @@
|
||||

|
||||
|
||||
**tsvm** /tiː.ɛs.viː.ɛm/ is a virtual machine with the architecture that mimics the 8-bit era of
|
||||
computers, and runs programs written in Javascript.
|
||||
# tsvm
|
||||
|
||||
**tsvm** repository includes the virtual machine itself, the reference BIOS
|
||||
implementation and a DOS; BASIC is provided by the [TerranBASIC](https://github.com/curioustorvald/TerranBASIC)
|
||||
repository.
|
||||
**tsvm** /tiː.ɛs.viː.ɛm/ is a fantasy computer platform: a virtual machine whose
|
||||
architecture is inspired by the 8-bit and early 16-bit home computers, built
|
||||
from the ground up around running JavaScript as its native machine code.
|
||||
|
||||
What started as "an 8-bit-flavoured VM that runs JS" has grown into a complete,
|
||||
self-hosted retro computing ecosystem — with its own BIOS, operating system,
|
||||
filesystem, video and audio codecs, video display coprocessor with its own
|
||||
assembly language, tracker music format, and a stack of userland tools that
|
||||
together come closer to a small alternate-history computer line than a
|
||||
single-binary emulator.
|
||||
|
||||
This repository contains the virtual machine core, the reference BIOS
|
||||
implementations, the **TVDOS** operating system, the **Videotron2K** video
|
||||
display controller, hardware-accelerated codec backends for the **TEV / TAV /
|
||||
TAD** media formats, and the multi-platform packaging scripts. The
|
||||
[TerranBASIC](https://github.com/curioustorvald/TerranBASIC) repository
|
||||
provides the matching BASIC dialect that ships on the system disk.
|
||||
|
||||
## What's actually in here
|
||||
|
||||
### The virtual machine
|
||||
|
||||
- **VM core** (`tsvm_core/`) — memory model, peripheral bus, MMIO, JS
|
||||
sandboxing through GraalVM, watchdog, DMA engine, and cooperative scheduling.
|
||||
Up to 8 hot-pluggable peripheral slots, each with a dedicated MMIO window
|
||||
and memory-space window mapped into the VM's negative address range.
|
||||
- **Multiple BIOS implementations** (`assets/bios/`) — including the reference
|
||||
`tsvmbios.js`, an OpenBIOS variant, the TBM-BIOS for TerranBASIC machines,
|
||||
and the Pip-Boy-style `pipboot.rom`. BIOSes are first-class swappable
|
||||
components, not a fixed boot blob.
|
||||
- **Reference monitor / debugger** (`mon.js`) for poking at memory and
|
||||
peripherals from a running machine.
|
||||
- **Multi-platform packaging** (`buildapp/`) — scripts to produce Linux x86_64
|
||||
/ ARM64 AppImages, macOS Intel / Apple Silicon bundles, and Windows builds,
|
||||
each with its own `jlink`-trimmed JDK 21 runtime.
|
||||
|
||||
### Peripherals (the "hardware")
|
||||
|
||||
Living under `tsvm_core/src/net/torvald/tsvm/peripheral/`:
|
||||
|
||||
- **Graphics adapters** — the standard `GraphicsAdapter`, plus `TexticsAdapter`
|
||||
for text-mode framebuffers, `ExtDisp` for external displays, and a
|
||||
`RemoteGraphicsAdapter` for networked rendering.
|
||||
- **Audio devices** — `AudioAdapter` (the main programmable sound chip with
|
||||
PCM channels, an Impulse Tracker-style resonant low-pass filter, and a
|
||||
hardware-accelerated **TAD** decoder), `OpenALBufferedAudioDevice`, and the
|
||||
`MP2Env` MPEG audio environment.
|
||||
- **Disk drives** — `TevdDiskDrive` (TEVD custom filesystem),
|
||||
`ClusteredDiskDrive`, `TestDiskDrive`, and a latency-simulator script for
|
||||
testing slow-storage behaviour.
|
||||
- **Networking and serial** — `HttpModem`, `HSDPA` / `HostFileHSDPA` for
|
||||
high-speed packet I/O, `SerialStdioHost`, `BlockTransferInterface` /
|
||||
`BlockTransferPort`.
|
||||
- **Terminals and displays** — `TTY`, `GlassTty`, `TermSim`, and a
|
||||
`CharacterLCDdisplay` for HD44780-flavoured projects.
|
||||
- **Memory expansion** — `RamBank` for bank-switched memory, plus a
|
||||
programmable `TestFunctionGenerator`.
|
||||
|
||||
### Videotron2K — the video coprocessor
|
||||
|
||||
Videotron2K is a programmable video display controller with its **own
|
||||
assembly-like language**, six general registers (`r1`–`r6`), special registers
|
||||
(`tmr`, `frm`, `px`, `py`, `c1`–`c6`), a scene-based programming model, and
|
||||
conditional postfixes (`zr`, `nz`, `gt`, `ls`, `ge`, `le`). Programs declare
|
||||
`SCENE` blocks and dispatch them with `perform`. Drawing primitives include
|
||||
`plot`, `fillin`, `fillscr`, and `goto`. See `Videotron2K.md` and the VDC
|
||||
implementation under `tsvm_core/.../vdc/`.
|
||||
|
||||
### TVDOS — the operating system
|
||||
|
||||
`assets/disk0/tvdos/` is a complete DOS-style userland:
|
||||
|
||||
- **Kernel and drivers** — `TVDOS.SYS`, `HSDPADRV.SYS`, `hyve.SYS`,
|
||||
installable drivers under `moviedev/` and `tuidev/`.
|
||||
- **Custom filesystem** — TEVD, with the on-disk format documented in
|
||||
`tvdos/filesystem.md`.
|
||||
- **Internationalisation** — Colemak / Dvorak / QWERTY keymaps and an `i18n/`
|
||||
resource tree.
|
||||
- **Userland binaries** (`tvdos/bin/`) — a shell (`command.js`), file tools
|
||||
(`hexdump`, `less`, `tee`, `touch`, `printfile`, `writeto`, `defrag`,
|
||||
`lfs`, `drives`), an editor (`edit.js`), a file manager (`zfm.js`), a
|
||||
network fetcher (`geturl`), gzip/Zstd helpers, palette tools, and a battery
|
||||
of media players (`playmp2`, `playpcm`, `playwav`, `playmv1`, `playtev`,
|
||||
`playtav`, `playtad`, `playucf`).
|
||||
- **Taut tracker** — a full in-VM tracker (`taut.js`,
|
||||
`taut_instredit.js`, `taut_sampleedit.js`, `taut_notationedit.js`,
|
||||
`taut_fileop.js`) with its own font and chrome assets.
|
||||
|
||||
### Codecs and media formats
|
||||
|
||||
tsvm ships a small but serious codec lab. Encoders are written in C and live
|
||||
in `video_encoder/`; decoders are split between JavaScript players in TVDOS
|
||||
and hardware-accelerated Kotlin backends in the VM core.
|
||||
|
||||
- **iPF (Type 1 / 2 / 1-delta)** — picture and legacy movie format. Encoders:
|
||||
`encodeipf.js`, `encodemov.js`, `encodemov2.js`. Documented in
|
||||
`terranmon.txt`.
|
||||
- **TEV (TSVM Enhanced Video)** — modern DCT codec with motion compensation,
|
||||
16×16 blocks, YCoCg-R 4:2:0, and either quality-mode or bitrate-mode rate
|
||||
control. Encoder: `video_encoder/encoder_tev.c`. Decoder: `playtev.js`,
|
||||
with `tevDecode` / `tevIdct8x8` / `tevMotionCopy8x8` accelerated in
|
||||
`GraphicsJSR223Delegate.kt`.
|
||||
- **TAV (TSVM Advanced Video)** — successor to TEV based on the Discrete
|
||||
Wavelet Transform. Five wavelet types (5/3 reversible, 9/7 irreversible,
|
||||
CDF 13/7, DD-4, Haar), 6-level decomposition, EZBC sparsity coding,
|
||||
perceptual quantisation, and an optional **3D temporal DWT** that encodes
|
||||
whole groups of pictures as one unified wavelet tree. Includes a packet
|
||||
inspector (`tav_inspector.c`) and coefficient visualiser
|
||||
(`tav_visualise_coefficients.c`).
|
||||
- **TAD (TSVM Advanced Audio)** — perceptual audio codec at 32 kHz stereo,
|
||||
using CDF 9/7 wavelets, M/S decorrelation, gamma compression, pre-emphasis,
|
||||
EZBC, and Zstd. Achieves ~2.5:1 compression vs. PCMu8 at quality 3 while
|
||||
preserving the full 0–16 kHz band. Designed to be embeddable inside TAV so
|
||||
audio chunks can align with video GOP boundaries.
|
||||
- **Taud** — tracker module format with conversion tools from
|
||||
the major formats: `it2taud.py` (Impulse Tracker), `mod2taud.py`
|
||||
(ProTracker / FastTracker), `s3m2taud.py` (Scream Tracker 3), plus
|
||||
`2taud.sh` and shared helpers in `taud_common.py`. Note effects are
|
||||
documented in `TAUD_NOTE_EFFECTS.md`. The `AudioAdapter` runs the same
|
||||
IIR-only 2-pole resonant low-pass topology used by Impulse Tracker /
|
||||
OpenMPT / Schism.
|
||||
- **MP2** — reference MPEG-1 Layer II environment via `MP2Env.kt` and
|
||||
`playmp2.js`.
|
||||
|
||||
### Languages and runtimes
|
||||
|
||||
- **JavaScript** is the VM's native code, executed by GraalVM in a sandboxed
|
||||
context with a curated set of host bindings (graphics, audio, filesystem,
|
||||
DMA, compression, networking, low-level peek/poke).
|
||||
- **TerranBASIC** is provided by the
|
||||
[TerranBASIC](https://github.com/curioustorvald/TerranBASIC) repository and
|
||||
shipped as `tbas` on the system disk. The `TerranBASICexecutable/` subproject
|
||||
packages a BASIC-only flavour of the machine.
|
||||
- **Videotron2K assembly** for VDC programs.
|
||||
|
||||
### Documentation
|
||||
|
||||
- `terranmon.txt` — the architecture reference (memory map, peripheral
|
||||
protocol, codec bitstreams).
|
||||
- `doc/*.tex` — machine-readable LaTeX sources for the TSVM and TVDOS manuals,
|
||||
built with `doc/makepdf.sh`.
|
||||
- `Videotron2K.md` — VDC programming guide.
|
||||
- `TAUD_NOTE_EFFECTS.md` — tracker effect reference.
|
||||
- `CLAUDE.md` — a condensed map of the project for collaborators (and
|
||||
language-model assistants) working in the tree.
|
||||
|
||||
## Building and running
|
||||
|
||||
### Prerequisites
|
||||
|
||||
JDK 21 runtimes laid out under `~/Documents/openjdk/` with platform-specific
|
||||
names:
|
||||
|
||||
- `jdk-21.0.1-x86` — Linux AMD64
|
||||
- `jdk-21.0.1-arm` — Linux Aarch64
|
||||
- `jdk-21.0.1-windows` — Windows AMD64
|
||||
- `jdk-21.0.1.jdk-x86` — macOS Intel
|
||||
- `jdk-21.0.1.jdk-arm` — macOS Apple Silicon
|
||||
|
||||
`jlink` is then used to produce trimmed runtimes under `out/runtime-*`.
|
||||
|
||||
### Common entry points
|
||||
|
||||
- **Run the emulator** — `TsvmEmulator.java` (in `tsvm_executable/`).
|
||||
- **Run TerranBASIC-only build** — `TerranBASIC.java` (in
|
||||
`TerranBASICexecutable/`).
|
||||
- **Package an installable bundle** — pick the right script in `buildapp/`:
|
||||
- `build_app_linux_x86.sh`
|
||||
- `build_app_linux_arm.sh`
|
||||
- `build_app_mac_x86.sh`
|
||||
- `build_app_mac_arm.sh`
|
||||
- `build_app_windows_x86.sh`
|
||||
- **Build C encoders** — in `video_encoder/`: `make` (TEV), `make tav`,
|
||||
`make tad`.
|
||||
|
||||
### Encoding sample media
|
||||
|
||||
```bash
|
||||
# Quality-mode TEV encode
|
||||
./encoder_tev -i input.mp4 -o clip.tev -q 3
|
||||
|
||||
# TAV with 9/7 wavelet, quality 4
|
||||
./encoder_tav -i input.mp4 -w 1 -q 4 -o clip.tav
|
||||
|
||||
# TAV with 3D temporal DWT (GOP-unified encoding)
|
||||
./encoder_tav -i input.mp4 --temporal-dwt -o clip.tav
|
||||
|
||||
# TAD audio at the highest quality
|
||||
./encoder_tad -i input.mp4 -o track.tad -q 5
|
||||
```
|
||||
|
||||
Then, inside TVDOS:
|
||||
|
||||
```
|
||||
A:\> playtev clip.tev
|
||||
A:\> playtav clip.tav
|
||||
A:\> playtad track.tad
|
||||
```
|
||||
|
||||
## Repository layout
|
||||
|
||||
```
|
||||
tsvm_core/ VM core, peripherals, VDC, JS bindings (Kotlin)
|
||||
tsvm_executable/ Main emulator GUI (LibGDX)
|
||||
TerranBASICexecutable/ For creatingTerranBASIC executable
|
||||
assets/bios/ BIOS ROMs and source
|
||||
assets/disk0/ Boot disk image, including all of TVDOS
|
||||
video_encoder/ C encoders, decoder libs, inspectors (TEV / TAV / TAD)
|
||||
ipf_encoder/ Reference iPF encoder
|
||||
doc/ LaTeX sources for the TSVM / TVDOS manuals
|
||||
buildapp/ Per-platform packaging scripts
|
||||
My_BASIC_Programs/ Example BASIC programs
|
||||
*.py, *.sh, *.kts Conversion tools and ad-hoc utilities
|
||||
```
|
||||
|
||||
## Licence
|
||||
|
||||
See `COPYING`.
|
||||
|
||||
@@ -122,8 +122,39 @@ class VMGUI(val loaderInfo: EmulInstance, val viewportWidth: Int, val viewportHe
|
||||
private var rebootRequested = false
|
||||
|
||||
private fun reboot() {
|
||||
vmRunner.close()
|
||||
coroutineJob.interrupt()
|
||||
// Order is critical: stop ALL execution first, then dispose peripherals
|
||||
// before re-initialising. Without this, the old JS thread races the new
|
||||
// one on shared VM memory / IO state and can SIGSEGV on disposed peripherals.
|
||||
|
||||
// 1. Stop parallel/child contexts. park() interrupts and joins them.
|
||||
vm.park()
|
||||
vm.poke(-90L, -128)
|
||||
|
||||
// 2. Interrupt the main runner thread and cancel the GraalVM context.
|
||||
if (::coroutineJob.isInitialized) coroutineJob.interrupt()
|
||||
try { if (::vmRunner.isInitialized) vmRunner.close() } catch (_: Throwable) {}
|
||||
|
||||
// 3. Wait for the main runner thread to actually finish.
|
||||
if (::coroutineJob.isInitialized && coroutineJob !== Thread.currentThread()) {
|
||||
try {
|
||||
coroutineJob.join(2000L)
|
||||
if (coroutineJob.isAlive) {
|
||||
System.err.println("[VMGUI] runner ${vm.id} did not exit within 2s; proceeding anyway")
|
||||
coroutineJob.interrupt()
|
||||
}
|
||||
}
|
||||
catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Now it's safe to release native resources held by peripherals.
|
||||
for (i in 1 until vm.peripheralTable.size) {
|
||||
try {
|
||||
vm.peripheralTable[i].peripheral?.dispose()
|
||||
}
|
||||
catch (_: Throwable) {}
|
||||
}
|
||||
|
||||
vm.init()
|
||||
init()
|
||||
|
||||
@@ -29,8 +29,10 @@ internal object UnsafeHelper {
|
||||
return UnsafePtr(ptr, size, caller)
|
||||
}
|
||||
|
||||
fun memcpy(src: UnsafePtr, fromIndex: Long, dest: UnsafePtr, toIndex: Long, copyLength: Long) =
|
||||
fun memcpy(src: UnsafePtr, fromIndex: Long, dest: UnsafePtr, toIndex: Long, copyLength: Long) {
|
||||
if (src.destroyed || dest.destroyed) return
|
||||
unsafe.copyMemory(src.ptr + fromIndex, dest.ptr + toIndex, copyLength)
|
||||
}
|
||||
fun memcpy(srcAddress: Long, destAddress: Long, copyLength: Long) =
|
||||
unsafe.copyMemory(srcAddress, destAddress, copyLength)
|
||||
fun memcpyRaw(srcObj: Any?, srcPos: Long, destObj: Any?, destPos: Long, len: Long) =
|
||||
@@ -84,81 +86,96 @@ internal class UnsafePtr(pointer: Long, allocSize: Long, private val caller: Any
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun checkNullPtr(index: Long) { // ignore what IDEA says and do inline this
|
||||
//// commenting out because of the suspected (or minor?) performance impact.
|
||||
//// You may break the glass and use this tool when some fucking incomprehensible bugs ("vittujen vitun bugit")
|
||||
//// appear (e.g. getting garbage values when it fucking shouldn't)
|
||||
|
||||
// if (destroyed) { throw DanglingPointerException("The pointer is already destroyed ($this)") }
|
||||
// if (index !in 0 until size) throw AddressOverflowException("Index: $index; alloc size: $size; pointer: ${this}\n${Thread.currentThread().stackTrace.joinToString("\n", limit=10) { " $it" }}")
|
||||
/**
|
||||
* Returns true when the operation should proceed; false when the pointer is destroyed
|
||||
* (so the caller short-circuits to a safe no-op / zero return).
|
||||
*
|
||||
* Why no exception: a JS worker thread that survives killVMenv (because it wasn't
|
||||
* tracked in vm.contexts, e.g. raw java.lang.Thread spawned by JS code) will keep
|
||||
* poking peripheral memory for one or more iterations after dispose(). Letting it
|
||||
* actually call unsafe.putByte on freed memory corrupts the malloc heap and crashes
|
||||
* the JVM with `free_list_checksum_botch`. Returning quietly turns the race into a
|
||||
* harmless no-op until the thread drains.
|
||||
*/
|
||||
private inline fun aliveAt(index: Long): Boolean {
|
||||
if (destroyed) return false
|
||||
if (index < 0 || index >= size) return false
|
||||
return true
|
||||
}
|
||||
|
||||
operator fun get(index: Long): Byte {
|
||||
checkNullPtr(index)
|
||||
if (!aliveAt(index)) return 0
|
||||
return UnsafeHelper.unsafe.getByte(ptr + index)
|
||||
}
|
||||
|
||||
operator fun set(index: Long, value: Byte) {
|
||||
checkNullPtr(index)
|
||||
if (!aliveAt(index)) return
|
||||
UnsafeHelper.unsafe.putByte(ptr + index, value)
|
||||
}
|
||||
|
||||
|
||||
fun getFloatFree(index: Long): Float {
|
||||
checkNullPtr(index)
|
||||
if (!aliveAt(index + 3)) return 0f
|
||||
return UnsafeHelper.unsafe.getFloat(ptr + index)
|
||||
}
|
||||
fun getFloat(unit: Long): Float {
|
||||
checkNullPtr(unit * 4L)
|
||||
return UnsafeHelper.unsafe.getFloat(ptr + (unit * 4L))
|
||||
val idx = unit * 4L
|
||||
if (!aliveAt(idx + 3)) return 0f
|
||||
return UnsafeHelper.unsafe.getFloat(ptr + idx)
|
||||
}
|
||||
|
||||
fun getIntFree(index: Long): Int {
|
||||
checkNullPtr(index)
|
||||
if (!aliveAt(index + 3)) return 0
|
||||
return UnsafeHelper.unsafe.getInt(ptr + index)
|
||||
}
|
||||
fun getInt(unit: Long): Int {
|
||||
checkNullPtr(unit * 4L)
|
||||
return UnsafeHelper.unsafe.getInt(ptr + (unit * 4L))
|
||||
val idx = unit * 4L
|
||||
if (!aliveAt(idx + 3)) return 0
|
||||
return UnsafeHelper.unsafe.getInt(ptr + idx)
|
||||
}
|
||||
|
||||
fun getShortFree(index: Long): Short {
|
||||
checkNullPtr(index)
|
||||
if (!aliveAt(index + 1)) return 0
|
||||
return UnsafeHelper.unsafe.getShort(ptr + index)
|
||||
}
|
||||
fun getShort(unit: Long): Short {
|
||||
checkNullPtr(unit * 2L)
|
||||
return UnsafeHelper.unsafe.getShort(ptr + (unit * 2L))
|
||||
val idx = unit * 2L
|
||||
if (!aliveAt(idx + 1)) return 0
|
||||
return UnsafeHelper.unsafe.getShort(ptr + idx)
|
||||
}
|
||||
|
||||
fun setFloatFree(index: Long, value: Float) {
|
||||
checkNullPtr(index)
|
||||
if (!aliveAt(index + 3)) return
|
||||
UnsafeHelper.unsafe.putFloat(ptr + index, value)
|
||||
}
|
||||
fun setFloat(unit: Long, value: Float) {
|
||||
checkNullPtr(unit * 4L)
|
||||
UnsafeHelper.unsafe.putFloat(ptr + (unit * 4L), value)
|
||||
val idx = unit * 4L
|
||||
if (!aliveAt(idx + 3)) return
|
||||
UnsafeHelper.unsafe.putFloat(ptr + idx, value)
|
||||
}
|
||||
|
||||
fun setIntFree(index: Long, value: Int) {
|
||||
checkNullPtr(index)
|
||||
if (!aliveAt(index + 3)) return
|
||||
UnsafeHelper.unsafe.putInt(ptr + index, value)
|
||||
}
|
||||
fun setInt(unit: Long, value: Int) {
|
||||
checkNullPtr(unit * 4L)
|
||||
UnsafeHelper.unsafe.putInt(ptr + (unit * 4L), value)
|
||||
val idx = unit * 4L
|
||||
if (!aliveAt(idx + 3)) return
|
||||
UnsafeHelper.unsafe.putInt(ptr + idx, value)
|
||||
}
|
||||
|
||||
fun setShortFree(index: Long, value: Short) {
|
||||
checkNullPtr(index)
|
||||
if (!aliveAt(index + 1)) return
|
||||
UnsafeHelper.unsafe.putShort(ptr + index, value)
|
||||
}
|
||||
fun setShortUnit(unit: Long, value: Short) {
|
||||
checkNullPtr(unit * 2L)
|
||||
UnsafeHelper.unsafe.putShort(ptr + (unit * 2L), value)
|
||||
val idx = unit * 2L
|
||||
if (!aliveAt(idx + 1)) return
|
||||
UnsafeHelper.unsafe.putShort(ptr + idx, value)
|
||||
}
|
||||
|
||||
fun fillWith(byte: Byte) {
|
||||
if (destroyed) return
|
||||
UnsafeHelper.unsafe.setMemory(ptr, size, byte)
|
||||
}
|
||||
|
||||
|
||||
@@ -325,8 +325,37 @@ class VM(
|
||||
}
|
||||
|
||||
fun killAllContexts() {
|
||||
contexts.forEach { it.interrupt() }
|
||||
// Snapshot first: interrupt() can race with the worker thread mutating `contexts`
|
||||
// (see Parallel.kill / attachProgram) and we want to wait on every one of them.
|
||||
val snapshot = contexts.toList()
|
||||
snapshot.forEach { it.interrupt() }
|
||||
snapshot.forEach {
|
||||
try { it.join(500L) } catch (_: InterruptedException) { Thread.currentThread().interrupt() }
|
||||
}
|
||||
contexts.clear()
|
||||
|
||||
// Some JS code (e.g. TVDOS) spawns workers that aren't routed through Parallel.attachProgram,
|
||||
// so they never land in `contexts`. We can still find them by walking the JVM thread set and
|
||||
// matching the per-VM suffix that VMRunnerFactory uses for thread names ("…!<vmId>").
|
||||
val suffix = "!${id.text}"
|
||||
val all = arrayOfNulls<Thread>(Thread.activeCount() * 2)
|
||||
val n = Thread.enumerate(all)
|
||||
for (i in 0 until n) {
|
||||
val t = all[i] ?: continue
|
||||
if (t === Thread.currentThread()) continue
|
||||
val name = t.name
|
||||
if (name.endsWith(suffix) || name == "VmRunner:${id.text}") {
|
||||
t.interrupt()
|
||||
}
|
||||
}
|
||||
for (i in 0 until n) {
|
||||
val t = all[i] ?: continue
|
||||
if (t === Thread.currentThread()) continue
|
||||
val name = t.name
|
||||
if (name.endsWith(suffix) || name == "VmRunner:${id.text}") {
|
||||
try { t.join(500L) } catch (_: InterruptedException) { Thread.currentThread().interrupt() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,6 +22,16 @@ object VMSetupBroker {
|
||||
* @param coroutineJobs Hashmap on the host of VMs that holds the coroutine-job object for the currently running VM-instance. Key: Int(VM's identifier), value: [kotlin.coroutines.Job]
|
||||
*/
|
||||
fun initVMenv(vm: VM, profileJson: JsonValue, profileName: String, gpu: GraphicsAdapter, vmRunners: HashMap<VmId, VMRunner>, coroutineJobs: HashMap<VmId, Thread>, whatToDoOnVmException: (Throwable) -> Unit) {
|
||||
// Refuse to start a new runner while the previous one is still alive:
|
||||
// running both concurrently would race on the VM's memory / IO and lead
|
||||
// to mixed text input, garbled rendering, and SIGSEGV on disposed peripherals.
|
||||
coroutineJobs[vm.id]?.let { old ->
|
||||
if (old.isAlive) {
|
||||
System.err.println("[VMSetupBroker] previous runner for ${vm.id} is still alive; tearing it down before re-init")
|
||||
killVMenv(vm, vmRunners, coroutineJobs)
|
||||
}
|
||||
}
|
||||
|
||||
vm.init()
|
||||
|
||||
try {
|
||||
@@ -61,9 +71,38 @@ object VMSetupBroker {
|
||||
*/
|
||||
fun killVMenv(vm: VM, vmRunners: HashMap<VmId, VMRunner>, coroutineJobs: HashMap<VmId, Thread>) {
|
||||
|
||||
// Order is critical: stop ALL execution first, then dispose peripherals.
|
||||
// If we disposed peripherals while the runner thread is still alive, the
|
||||
// thread would touch destroyed UnsafePtrs and SIGSEGV.
|
||||
|
||||
// 1. Stop parallel/child contexts. park() interrupts and joins them.
|
||||
vm.park()
|
||||
vm.poke(-90L, -128)
|
||||
|
||||
// 2. Interrupt the main runner thread and cancel the GraalVM context.
|
||||
// context.close(true) cancels in-flight script evaluation.
|
||||
val runnerThread = coroutineJobs[vm.id]
|
||||
runnerThread?.interrupt()
|
||||
try { vmRunners[vm.id]?.close() } catch (_: Throwable) {}
|
||||
|
||||
// 3. Wait for the main runner thread to actually finish.
|
||||
if (runnerThread != null && runnerThread !== Thread.currentThread()) {
|
||||
try {
|
||||
runnerThread.join(2000L)
|
||||
if (runnerThread.isAlive) {
|
||||
// Last resort: re-interrupt and accept that disposal will
|
||||
// happen with the thread still alive. This is logged so
|
||||
// diagnostics surface a stuck VM rather than failing silently.
|
||||
System.err.println("[VMSetupBroker] runner ${vm.id} did not exit within 2s; proceeding anyway")
|
||||
runnerThread.interrupt()
|
||||
}
|
||||
}
|
||||
catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Now it's safe to release native resources held by peripherals.
|
||||
for (i in 1 until vm.peripheralTable.size) {
|
||||
try {
|
||||
vm.peripheralTable[i].peripheral?.dispose()
|
||||
@@ -71,8 +110,9 @@ object VMSetupBroker {
|
||||
catch (_: Throwable) {}
|
||||
}
|
||||
|
||||
coroutineJobs[vm.id]?.interrupt()
|
||||
vmRunners[vm.id]?.close()
|
||||
// 5. Drop runner / job handles so a subsequent initVMenv won't see stale entries.
|
||||
vmRunners.remove(vm.id)
|
||||
coroutineJobs.remove(vm.id)
|
||||
|
||||
vm.getPrintStream = { TODO() }
|
||||
vm.getErrorStream = { TODO() }
|
||||
|
||||
@@ -45,6 +45,7 @@ class CLCDDisplay(assetsRoot: String, vm: VM) : GraphicsAdapter(assetsRoot, vm,
|
||||
shader: ShaderProgram?,
|
||||
uiFBO: FrameBuffer?
|
||||
) {
|
||||
if (disposed) return
|
||||
batch.shader = null
|
||||
batch.inUse {
|
||||
batch.color = Color.WHITE
|
||||
|
||||
@@ -40,6 +40,7 @@ class CharacterLCDdisplay(assetsRoot: String, vm: VM) : GraphicsAdapter(assetsRo
|
||||
shader: ShaderProgram?,
|
||||
uiFBO: FrameBuffer?
|
||||
) {
|
||||
if (disposed) return
|
||||
batch.shader = null
|
||||
batch.inUse {
|
||||
batch.color = Color.WHITE
|
||||
|
||||
@@ -942,7 +942,11 @@ open class GraphicsAdapter(private val assetsRoot: String, val vm: VM, val confi
|
||||
try { this.dispose() } catch (_: GdxRuntimeException) {} catch (_: IllegalArgumentException) {}
|
||||
}
|
||||
|
||||
@Volatile var disposed = false; private set
|
||||
|
||||
override fun dispose() {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
//testTex.dispose()
|
||||
// paletteShader.tryDispose()
|
||||
// textShader.tryDispose()
|
||||
@@ -986,6 +990,11 @@ open class GraphicsAdapter(private val assetsRoot: String, val vm: VM, val confi
|
||||
private val isRefSize = (WIDTH == 560 && HEIGHT == 448)
|
||||
|
||||
open fun render(delta: Float, uiBatch: SpriteBatch, xoff: Float, yoff: Float, flipY: Boolean = false, shader: ShaderProgram? = null, uiFBO: FrameBuffer? = null) {
|
||||
// Bail out if the adapter has already been torn down. Otherwise touching
|
||||
// any of the disposed Pixmaps / Textures / native buffers below would
|
||||
// raise a GdxRuntimeException or SIGSEGV.
|
||||
if (disposed) return
|
||||
|
||||
uiFBO?.end()
|
||||
|
||||
|
||||
@@ -1362,8 +1371,12 @@ open class GraphicsAdapter(private val assetsRoot: String, val vm: VM, val confi
|
||||
textCursorIsOn = !textCursorIsOn
|
||||
}
|
||||
|
||||
// force light cursor up while typing
|
||||
textCursorIsOn = textCursorIsOn || ((1..254).any { Gdx.input.isKeyPressed(it) })
|
||||
// force light cursor up while typing -- only honour global key state when
|
||||
// this VM is the focused viewport; otherwise hidden VMs would react to
|
||||
// keypresses meant for the focused one.
|
||||
if (Gdx.input.inputProcessor === vm.getIO()) {
|
||||
textCursorIsOn = textCursorIsOn || ((1..254).any { Gdx.input.isKeyPressed(it) })
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -285,24 +285,35 @@ class IOSpace(val vm: VM) : PeriBase("io"), InputProcessor {
|
||||
private var rtc = 0L
|
||||
|
||||
fun update(delta: Float) {
|
||||
// Only the VM whose IOSpace is wired up as the active InputProcessor (i.e. the
|
||||
// currently focused viewport) may observe global keyboard/mouse state. Otherwise
|
||||
// hidden VMs would all see the same keypresses as the focused one.
|
||||
val isFocused = Gdx.input.inputProcessor === this
|
||||
|
||||
if (rawInputFunctionLatched) {
|
||||
rawInputFunctionLatched = false
|
||||
|
||||
// store mouse info
|
||||
mouseX = (Gdx.input.x + guiPosX).toShort()
|
||||
mouseY = (Gdx.input.y + guiPosY).toShort()
|
||||
mouseDown = Gdx.input.isTouched
|
||||
|
||||
// strobe keys to fill the key read buffer
|
||||
var keysPushed = 0
|
||||
keyEventBuffers.fill(0)
|
||||
for (k in 1..254) {
|
||||
if (Gdx.input.isKeyPressed(k)) {
|
||||
keyEventBuffers[keysPushed] = k.toByte()
|
||||
keysPushed += 1
|
||||
}
|
||||
|
||||
if (keysPushed >= 8) break
|
||||
if (isFocused) {
|
||||
// store mouse info
|
||||
mouseX = (Gdx.input.x + guiPosX).toShort()
|
||||
mouseY = (Gdx.input.y + guiPosY).toShort()
|
||||
mouseDown = Gdx.input.isTouched
|
||||
|
||||
// strobe keys to fill the key read buffer
|
||||
var keysPushed = 0
|
||||
for (k in 1..254) {
|
||||
if (Gdx.input.isKeyPressed(k)) {
|
||||
keyEventBuffers[keysPushed] = k.toByte()
|
||||
keysPushed += 1
|
||||
}
|
||||
|
||||
if (keysPushed >= 8) break
|
||||
}
|
||||
}
|
||||
else {
|
||||
mouseDown = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,26 +327,33 @@ class IOSpace(val vm: VM) : PeriBase("io"), InputProcessor {
|
||||
rtc = vm.worldInterface.currentTimeInMills()
|
||||
}
|
||||
|
||||
// SIGTERM key combination: Ctrl+Shift+T+R
|
||||
vm.stopDown = (Gdx.input.isKeyPressed(Input.Keys.SHIFT_LEFT) &&
|
||||
Gdx.input.isKeyPressed(Input.Keys.CONTROL_LEFT) &&
|
||||
Gdx.input.isKeyPressed(Input.Keys.T) &&
|
||||
Gdx.input.isKeyPressed(Input.Keys.R)) || Gdx.input.isKeyPressed(Input.Keys.PAUSE)
|
||||
if (vm.stopDown) println("[VM-${vm.id}] SIGTERM requested")
|
||||
if (isFocused) {
|
||||
// SIGTERM key combination: Ctrl+Shift+T+R
|
||||
vm.stopDown = (Gdx.input.isKeyPressed(Input.Keys.SHIFT_LEFT) &&
|
||||
Gdx.input.isKeyPressed(Input.Keys.CONTROL_LEFT) &&
|
||||
Gdx.input.isKeyPressed(Input.Keys.T) &&
|
||||
Gdx.input.isKeyPressed(Input.Keys.R)) || Gdx.input.isKeyPressed(Input.Keys.PAUSE)
|
||||
if (vm.stopDown) println("[VM-${vm.id}] SIGTERM requested")
|
||||
|
||||
// RESET key combination: Ctrl+Shift+R+S
|
||||
vm.resetDown = Gdx.input.isKeyPressed(Input.Keys.SHIFT_LEFT) &&
|
||||
Gdx.input.isKeyPressed(Input.Keys.CONTROL_LEFT) &&
|
||||
Gdx.input.isKeyPressed(Input.Keys.R) &&
|
||||
Gdx.input.isKeyPressed(Input.Keys.S)
|
||||
if (vm.resetDown) println("[VM-${vm.id}] RESET requested")
|
||||
// RESET key combination: Ctrl+Shift+R+S
|
||||
vm.resetDown = Gdx.input.isKeyPressed(Input.Keys.SHIFT_LEFT) &&
|
||||
Gdx.input.isKeyPressed(Input.Keys.CONTROL_LEFT) &&
|
||||
Gdx.input.isKeyPressed(Input.Keys.R) &&
|
||||
Gdx.input.isKeyPressed(Input.Keys.S)
|
||||
if (vm.resetDown) println("[VM-${vm.id}] RESET requested")
|
||||
|
||||
// SYSRQ key combination: Ctrl+Shift+S+Q
|
||||
vm.sysrqDown = (Gdx.input.isKeyPressed(Input.Keys.SHIFT_LEFT) &&
|
||||
Gdx.input.isKeyPressed(Input.Keys.CONTROL_LEFT) &&
|
||||
Gdx.input.isKeyPressed(Input.Keys.Q) &&
|
||||
Gdx.input.isKeyPressed(Input.Keys.S)) || Gdx.input.isKeyPressed(Input.Keys.PRINT_SCREEN)
|
||||
if (vm.sysrqDown) println("[VM-${vm.id}] SYSRQ requested")
|
||||
// SYSRQ key combination: Ctrl+Shift+S+Q
|
||||
vm.sysrqDown = (Gdx.input.isKeyPressed(Input.Keys.SHIFT_LEFT) &&
|
||||
Gdx.input.isKeyPressed(Input.Keys.CONTROL_LEFT) &&
|
||||
Gdx.input.isKeyPressed(Input.Keys.Q) &&
|
||||
Gdx.input.isKeyPressed(Input.Keys.S)) || Gdx.input.isKeyPressed(Input.Keys.PRINT_SCREEN)
|
||||
if (vm.sysrqDown) println("[VM-${vm.id}] SYSRQ requested")
|
||||
}
|
||||
else {
|
||||
vm.stopDown = false
|
||||
vm.resetDown = false
|
||||
vm.sysrqDown = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun touchUp(p0: Int, p1: Int, p2: Int, p3: Int): Boolean {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.6 KiB |
@@ -90,6 +90,11 @@ class VMEmuExecutable(val windowWidth: Int, val windowHeight: Int, var panelsX:
|
||||
var vmRunners = HashMap<VmId, VMRunner>() // <VM's identifier, VMRunner>
|
||||
var coroutineJobs = HashMap<VmId, Thread>() // <VM's identifier, Job>
|
||||
|
||||
// Per-VM rising-edge latch for the RESET key combo (Ctrl+Shift+R+S). The reboot
|
||||
// only fires when the user releases the keys, otherwise we'd restart-spam every
|
||||
// frame while the combo is held.
|
||||
private val rebootLatched = HashMap<VmId, Boolean>()
|
||||
|
||||
internal val whatToDoOnVmExceptionQueue = ArrayList<() -> Unit>()
|
||||
|
||||
companion object {
|
||||
@@ -288,15 +293,20 @@ class VMEmuExecutable(val windowWidth: Int, val windowHeight: Int, var panelsX:
|
||||
}
|
||||
|
||||
private fun reboot(profileName: String) {
|
||||
val vm = currentlyLoadedProfiles[profileName]!!
|
||||
val vm = currentlyLoadedProfiles[profileName] ?: return
|
||||
|
||||
/*vmRunners[vm.id]!!.close()
|
||||
coroutineJobs[vm.id]!!.interrupt()
|
||||
// Tear down the old session (joins the runner thread, then disposes
|
||||
// peripherals) before spinning up a new one. Without the join, the old
|
||||
// JS thread races the new one on shared VM memory / IO state.
|
||||
killVMenv(vm)
|
||||
initVMenv(vm, profileName)
|
||||
|
||||
vm.init()
|
||||
initVMenv(vm, profileName)*/
|
||||
|
||||
// hypervisor will take over by monitoring MMIO addr 48
|
||||
// The old IOSpace was kept (peripheralTable[0] survives init/kill), so
|
||||
// the InputProcessor reference is still valid; just make sure the
|
||||
// currently focused viewport is still wired to it.
|
||||
if (currentVMselection != null && vms[currentVMselection!!]?.vm?.id == vm.id) {
|
||||
Gdx.input.inputProcessor = vm.getIO()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateGame(delta: Float) {
|
||||
@@ -312,8 +322,27 @@ class VMEmuExecutable(val windowWidth: Int, val windowHeight: Int, var panelsX:
|
||||
}
|
||||
|
||||
vms.forEachIndexed { index, it ->
|
||||
if (it?.vm?.resetDown == true && index == currentVMselection) { reboot(it.profileName) }
|
||||
if (it?.vm?.isRunning == true) it?.vm?.update(delta)
|
||||
if (it == null) return@forEachIndexed
|
||||
val vmId = it.vm.id
|
||||
|
||||
// Trigger reboot on the *release* edge of the RESET key combo, and
|
||||
// only for the focused viewport (resetDown for a hidden VM is
|
||||
// already kept false by IOSpace.update; this guard is belt-and-braces).
|
||||
if (index == currentVMselection) {
|
||||
if (it.vm.resetDown) {
|
||||
rebootLatched[vmId] = true
|
||||
}
|
||||
else if (rebootLatched[vmId] == true) {
|
||||
rebootLatched[vmId] = false
|
||||
reboot(it.profileName)
|
||||
return@forEachIndexed // VM was just rebuilt; skip the update tick
|
||||
}
|
||||
}
|
||||
else {
|
||||
rebootLatched[vmId] = false
|
||||
}
|
||||
|
||||
if (it.vm.isRunning) it.vm.update(delta)
|
||||
}
|
||||
|
||||
updateMenu()
|
||||
|
||||
@@ -172,8 +172,34 @@ class VMGUI(val loaderInfo: EmulInstance, val viewportWidth: Int, val viewportHe
|
||||
private fun killVMenv() {
|
||||
if (vmKilled.compareAndSet(0, System.currentTimeMillis())) {
|
||||
System.err.println("VMGUI is killing VM environment...")
|
||||
|
||||
// Order is critical: stop ALL execution first, then dispose peripherals.
|
||||
// If we disposed peripherals while the runner thread is still alive, the
|
||||
// thread would touch destroyed UnsafePtrs and SIGSEGV.
|
||||
|
||||
// 1. Stop parallel/child contexts. park() interrupts and joins them.
|
||||
vm.park()
|
||||
vm.poke(-90L, -128)
|
||||
|
||||
// 2. Interrupt the main runner thread and cancel the GraalVM context.
|
||||
if (::coroutineJob.isInitialized) coroutineJob.interrupt()
|
||||
try { if (::vmRunner.isInitialized) vmRunner.close() } catch (_: Throwable) {}
|
||||
|
||||
// 3. Wait for the main runner thread to actually finish.
|
||||
if (::coroutineJob.isInitialized && coroutineJob !== Thread.currentThread()) {
|
||||
try {
|
||||
coroutineJob.join(2000L)
|
||||
if (coroutineJob.isAlive) {
|
||||
System.err.println("[VMGUI] runner ${vm.id} did not exit within 2s; proceeding anyway")
|
||||
coroutineJob.interrupt()
|
||||
}
|
||||
}
|
||||
catch (_: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Now it's safe to release native resources held by peripherals.
|
||||
for (i in 1 until vm.peripheralTable.size) {
|
||||
try {
|
||||
vm.peripheralTable[i].peripheral?.dispose()
|
||||
@@ -181,8 +207,7 @@ class VMGUI(val loaderInfo: EmulInstance, val viewportWidth: Int, val viewportHe
|
||||
catch (_: Throwable) {
|
||||
}
|
||||
}
|
||||
coroutineJob.interrupt()
|
||||
vmRunner.close()
|
||||
|
||||
vm.getPrintStream = { TODO() }
|
||||
vm.getErrorStream = { TODO() }
|
||||
vm.getInputStream = { TODO() }
|
||||
@@ -195,12 +220,12 @@ class VMGUI(val loaderInfo: EmulInstance, val viewportWidth: Int, val viewportHe
|
||||
private var rebootRequested = false
|
||||
|
||||
private fun reboot() {
|
||||
/*vmRunner.close()
|
||||
coroutineJob.interrupt()
|
||||
|
||||
init()*/
|
||||
|
||||
// hypervisor will take over by monitoring MMIO addr 48
|
||||
// Tear down the old session (joins the runner thread, then disposes
|
||||
// peripherals) before re-initialising. Without the join, the old JS
|
||||
// thread races the new one on shared VM memory / IO state and can
|
||||
// SIGSEGV on disposed peripherals.
|
||||
killVMenv()
|
||||
init()
|
||||
}
|
||||
|
||||
private var updateAkku = 0.0
|
||||
|
||||
Reference in New Issue
Block a user