Compare commits

...

3 Commits

Author SHA1 Message Date
minjaesong
1e482e32a8 attempting to fix VM reboot bug(2) 2026-05-04 16:00:39 +09:00
minjaesong
4ff48bba1c attempting to fix VM reboot bug 2026-05-04 15:44:59 +09:00
minjaesong
2dcdff83c8 long overdue README update 2026-05-04 15:09:57 +09:00
12 changed files with 506 additions and 88 deletions

224
README.md
View File

@@ -1,8 +1,222 @@
![tsvm](tsvm_screenshot.png)
**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 016 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`.

View File

@@ -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()

View File

@@ -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)
}

View File

@@ -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() }
}
}
}
/**

View File

@@ -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() }

View File

@@ -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

View File

@@ -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

View File

@@ -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) })
}
}

View File

@@ -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

View File

@@ -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()

View File

@@ -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