diff --git a/.gitignore b/.gitignore index 1f2bafddf..e0149e4d5 100644 --- a/.gitignore +++ b/.gitignore @@ -148,3 +148,6 @@ gradle-app.setting # Local History for Visual Studio Code .history/ + +# Generated architecture documentation (./gradlew doc) +docs/output/ diff --git a/AGENTS.md b/AGENTS.md index 78a5a30f1..70a0728d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,64 +1,27 @@ -# Working Guidelines - -## Principles -- Start with a short plan before editing. -- Prefer correctness, safety, and minimal diffs. -- Ask before large refactors, new dependencies, public API changes, or cross-project behavior changes. -- Use `karpathy-guidelines` instructions: state assumptions, keep changes surgical, and define verification. -- Use `code-simplifier` instructions after edits to remove needless complexity without changing behavior. -- Talk like a caveman - -## Repo Map -- `application`: desktop app, CLI, bundled files, configs, and packaging. -- `plugins/compiler`: assemblers and compilers. -- `plugins/cpu`: CPU implementations. -- `plugins/memory`: memory plugins. -- `plugins/device`: device plugins. - -Related projects: -- `emuLib`: shared runtime and plugin API. -- `edigen`: instruction decoder/disassembler generator. -- `edigen-gradle-plugin`: Gradle integration for `edigen`. -- `cpu-testsuite`: CPU instruction test framework. -- `emustudio.github.io`: website and user/developer documentation. - -## Before Editing -- Identify the affected Gradle module and entrypoint. -- Read nearby tests, configs, README/docs, and existing patterns. -- Check related projects when changing plugin APIs, generated CPU code, CPU tests, or public docs. -- Treat changes as large if they touch many modules, exceed about 200 lines, add dependencies, or change shared behavior. - -## Editing Rules -- Keep behavior unchanged unless the task requires it. -- Follow `.editorconfig`; keep Java 11 compatibility. -- Preserve SPDX headers and GPL-3.0-or-later licensing. Warn and ask the user about further action if some code or files might violate this license. -- Avoid unrelated cleanup, broad formatting, generated-file churn, and speculative abstractions. -- Do not hardcode paths, versions, or plugin contracts when Gradle config or existing constants own them. - -## Java and Gradle -- Match existing package, module, and plugin structure. -- Keep application code in `application`; keep plugin code under the owning `plugins/...` module. -- Keep emulation timing, device I/O, CPU semantics, and binary formats explicit and tested. -- Update docs, configs, or examples when user-facing behavior or bundled computers change - modify user documentation in `emustudio.github.io` project. - -## Validation -- Run the narrowest practical check first: - - `./gradlew ::test` - - `./gradlew test` - - `./gradlew build` -- For docs-only changes, run `git diff --check -- AGENTS.md`. -- If tests cannot run, say why and name the command that should be run. - -## Final Response -- State what changed, files changed, tests run, docs/config impact, and related-project impact. - -# Commits - -- Commit messages must start with the Github issue -- based on changes, and known github open tickets, guess to what Github issue belongs the changes being commited. If not certain, guess it from the git branch name, which should be prefixed with `feature-XYZ` -- when asked to amend, check the commit message and format and change it accordingly. -- Use this format: - - first line: `[#XYZ] Add churn job` - - blank line - - bullet list of detailed changes, one short sentence each, max 80 chars -- Keep each commit scoped to one tightly related unit of change. +# emuStudio Repo Routing + +## Current Repository +- `emuStudio` owns the desktop application, CLI launcher, bundled official plugins, bundled virtual computers, configs, and distribution packaging. +- Main locations in this repository: `application`, `plugins/compiler`, `plugins/cpu`, `plugins/memory`, `plugins/device`, and `application/src/main/files/config`. + +## Sibling Repositories +- [emuLib](https://github.com/emustudio/emuLib): shared plugin API, runtime services, shared UI helpers, and reusable utilities. +- [edigen](https://github.com/emustudio/edigen): decoder/disassembler generator from `.eds` specifications. +- [emuStudio](https://github.com/emustudio/emuStudio): desktop application, bundled plugins, virtual computers, configs, and packaging. +- [emustudio.github.io](https://github.com/emustudio/emustudio.github.io): website, user documentation, developer documentation, and release-facing pages. +- [edigen-gradle-plugin](https://github.com/emustudio/edigen-gradle-plugin): Gradle task and DSL integration for Edigen source generation. +- [cpu-testsuite](https://github.com/emustudio/cpu-testsuite): shared CPU instruction test framework and reusable verification helpers. + +When a task calls for checking or updating a sibling repository, first look for it as a local checkout (typically alongside this repository). If it is present locally, work with it there. If it is not present locally, do not guess its location or assume changes were made; report that the repository is not available locally and continue with what can be done in this repository. + +## When To Update Which Repository +- Desktop app behavior, CLI behavior, plugin wiring, bundled virtual computers, bundled configs, or packaging: update `emuStudio`. +- Shared plugin API or runtime behavior used across plugins: update `emuLib`; then check `emuStudio`, `edigen`, and `cpu-testsuite`. +- Generated decoder or disassembler behavior for CPU plugins: update `edigen`; also check affected CPU plugins in `emuStudio` and Gradle integration in `edigen-gradle-plugin`. +- Shared CPU instruction testing support: update `cpu-testsuite`; check CPU plugin tests in `emuStudio`. +- User or developer documentation, website pages, or download/release pages: update `emustudio.github.io`. + +## Tickets And Commits +- Every change must have an existing GitHub ticket. +- Every commit subject must start with the ticket prefix: `[#123] Short summary`. +- If one task touches multiple emuStudio repositories, use the same ticket prefix in each related commit. diff --git a/build.gradle b/build.gradle index 6c8d3cab5..5ef77c491 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,10 @@ SPDX-License-Identifier: GPL-3.0-or-later */ import java.text.SimpleDateFormat +plugins { + id 'org.asciidoctor.jvm.convert' version '4.0.4' +} + apply from: 'test_report.gradle' ext.versions = [ @@ -96,3 +100,42 @@ subprojects { mavenCentral() } } + +// Architecture documentation (C4 model, AsciiDoc -> HTML) +repositories { + mavenCentral() +} + +asciidoctorj { + modules { + diagram.use() // enables PlantUML/C4 diagrams via asciidoctor-diagram + } +} + +tasks.register('doc', org.asciidoctor.gradle.jvm.AsciidoctorTask) { + group = 'documentation' + description = 'Generates HTML of the docs/ AsciiDoc documents into docs/output/' + + baseDirFollowsSourceDir() + sourceDir file('docs') + sources { + include '*.adoc' + } + outputDir file('docs/output') + + outputOptions { + backends 'html5' + separateOutputDirs = false + } + + attributes( + 'toc' : 'left', + 'icons' : 'font', + 'source-highlighter': 'rouge', + 'sectanchors' : '', + 'docdate' : new Date().format('yyyy-MM-dd'), + 'imagesdir' : 'images', + 'imagesoutdir' : file('docs/output/images') + ) +} + diff --git a/docs/code.adoc b/docs/code.adoc new file mode 100644 index 000000000..2ccab49f3 --- /dev/null +++ b/docs/code.adoc @@ -0,0 +1,167 @@ += C4 Level 4 — Code +:toc: left +:icons: font +:sectanchors: + +xref:index.adoc[← Back to overview] + +The *Code* level illustrates how a couple of the key responsibilities are +realized in classes. C4 recommends keeping this level selective, so it focuses on +the two contracts that make the platform extensible: *plugin loading / +construction* and *plugin communication via contexts*. + +== The plugin contract and type hierarchy + +Every plugin implements the emuLib `Plugin` interface (or one of its four +specializations) and is annotated with `@PluginRoot`. The application discovers +that root class, then instantiates it through a fixed three-argument +constructor. + +[plantuml,c4-code-plugin-model,svg] +---- +@startuml +skinparam classAttributeIconSize 0 +hide empty members + +package "emuLib (SDK)" { + interface Plugin { + +initialize() + +reset() + +destroy() + +getTitle() : String + +getVersion() : String + +isAutomationSupported() : boolean + } + interface Compiler + interface CPU + interface Memory + interface Device + + Plugin <|-- Compiler + Plugin <|-- CPU + Plugin <|-- Memory + Plugin <|-- Device + + interface ApplicationApi { + +getContextPool() : ContextPool + +getDialogs() : Dialogs + +getGUI() : GUI + +getDebuggerTable() : DebuggerTable + } + interface ContextPool { + +register(pluginId, context, type) + +getContext(pluginId, type) : Context + } + + annotation PluginRoot +} + +package "emuStudio application" { + class VirtualComputer { + +create(config, api, settings) : VirtualComputer + -constructPlugins(...) : Map + -createPluginInstance(id, class, api, settings) : Plugin + +initialize(contextPool) + +isConnected(a, b) : boolean + +getCompiler()/getCPU()/getMemory()/getDevices() + } + class PluginLoader { + +loadPlugins(files) : List> + -trustedPlugin(class) : boolean + } + class ContextPoolImpl + class ApplicationApiImpl + class "VirtualComputer.PluginMeta" as PluginMeta { + +pluginInstance : Plugin + +pluginConfig : PluginConfig + +pluginSettings : PluginSettings + } +} + +VirtualComputer --> PluginLoader : uses +VirtualComputer --> PluginMeta : holds * +PluginMeta --> Plugin : instance +VirtualComputer ..> ApplicationApi : passes to plugins +ApplicationApiImpl ..|> ApplicationApi +ContextPoolImpl ..|> ContextPool +PluginLoader ..> PluginRoot : requires on root class +Plugin ..> ApplicationApi : receives (constructor) +@enduml +---- + +The construction contract (as enforced in `VirtualComputer.createPluginInstance`) +is a single constructor with the signature: + +[source,java] +---- +@PluginRoot(type = PLUGIN_TYPE.CPU, title = "…") +public SamplePlugin(long pluginId, ApplicationApi emustudio, PluginSettings settings) { … } +---- + +`PluginLoader.trustedPlugin(...)` accepts a class only if it is not an interface, +is annotated with `@PluginRoot`, and implements `Plugin`. `VirtualComputer` then +additionally checks that the class implements the interface matching its declared +`PLUGIN_TYPE` before instantiating it. + +== Loading and initializing a virtual computer + +The following sequence traces the code path from the launcher to a running +machine. + +[plantuml,c4-code-load-sequence,svg] +---- +@startuml +autonumber +actor User +participant "Runner" as Runner +participant "VirtualComputer" as VC +participant "PluginLoader" as PL +participant "Plugin\n(root class)" as Plugin +participant "ContextPoolImpl" as CP + +User -> Runner : select configuration +Runner -> VC : create(config, applicationApi, appSettings) +VC -> PL : loadPlugins(jarFiles) +PL -> PL : findMainClass() + trustedPlugin() +PL --> VC : List> + +loop for each plugin (compiler, CPU, memory, devices) + VC -> VC : check type implements PLUGIN_TYPE interface + VC -> Plugin : new (pluginId, applicationApi, pluginSettings) + Plugin --> VC : instance (wrapped in PluginMeta) +end + +Runner -> VC : initialize(contextPool) +VC -> CP : setComputer(this) +loop order: compiler -> memory -> CPU -> devices + VC -> Plugin : initialize() + Plugin -> CP : register/getContext(...) + CP -> VC : isConnected(pluginA, pluginB) + VC --> CP : true / false +end +@enduml +---- + +=== Why the order and the connection check matter + +* Plugins are initialized *compiler → memory → CPU → devices* so that, by the + time a plugin asks for another plugin's context, that context has already been + registered. +* Plugins must not fetch contexts in their constructor — only in + `initialize()` — precisely because registration order is fixed and earlier + plugins may not have published their contexts yet. +* Before the `ContextPool` hands one plugin a context owned by another, it calls + `VirtualComputer.isConnected(a, b)`, which consults the *connections* declared + in the TOML configuration. This is what makes wiring explicit and prevents + accidental coupling between plugins. + +== Emulation control at the code level + +For interactive runs, `EmulationController` funnels every CPU operation through a +single-threaded executor and synchronizes each state transition with a +`CountDownLatch`, updating its view of `CPU.RunState` from a `CPUListener` +callback. Automation instead attaches its own `CPUListener`, calls +`cpu.execute()` directly and waits for a terminal run state before reporting the +outcome. + +xref:index.adoc[← Back to overview] · xref:components.adoc[← Components] diff --git a/docs/components.adoc b/docs/components.adoc new file mode 100644 index 000000000..5e45934c3 --- /dev/null +++ b/docs/components.adoc @@ -0,0 +1,144 @@ += C4 Level 3 — Components +:toc: left +:icons: font +:sectanchors: + +xref:index.adoc[← Back to overview] + +The *Component* diagram opens up the application process and shows the major +components (roughly, groups of related classes with a clear responsibility) and +how a virtual computer is loaded, wired and run. + +== Diagram + +[plantuml,c4-component,svg] +---- +@startuml +!include + +title Components — emuStudio application + +Person(user, "User") + +System_Boundary(app, "emuStudio application") { + + Component(runner, "Runner", "picocli command", "Entry point: parses args, chooses GUI vs. automation, selects a configuration") + Component(automationCmd, "AutomationCommand", "picocli subcommand", "Headless run of a configuration for a given input file") + + Component(configFiles, "ConfigFiles / Settings", "TOML (night-config)", "Reads/writes ComputerConfig, PluginConfig, connections and AppSettings") + + Component(virtualComputer, "VirtualComputer", "Java", "Holds the plugins of one machine by type; enforces connections; exposes compiler/CPU/memory/devices; initializes & resets them") + Component(pluginLoader, "PluginLoader", "Java, URLClassLoader", "Finds each plugin's @PluginRoot main class in its JAR and its declared dependencies") + Component(contextPool, "ContextPool", "Java", "Registry of plugin contexts; hands out contexts only between connected plugins") + Component(appApi, "ApplicationApi", "Java", "Facade given to every plugin: context pool, dialogs, GUI, debugger table, program location") + + Component(emuController, "EmulationController", "Java, single-thread executor", "run / step / timed-step / pause / stop / reset; tracks CPU RunState") + Component(automation, "Automation", "Java", "compile → reset → execute → collect result state; logs outcome") + + Component(guiMain, "EmuStudioGui (main window)", "Swing, FlatLaf", "Main window, toolbars, look & feel; implements emuLib GUI service") + Component(schema, "Schema Editor", "Swing", "Draw/inspect the virtual computer as connected elements") + Component(editor, "Source Editor", "RSyntaxTextArea", "Edit & compile source code") + Component(debugger, "Debugger", "Swing", "Debug table / disassembly, breakpoints, step controls") + Component(dialogs, "Dialogs", "Swing", "Implements emuLib Dialogs service (messages, prompts, file choosers)") +} + +Container(plugins, "Plugins", "JARs", "Compiler / CPU / Memory / Device") +System_Ext(emulib, "emuLib", "Plugin API & runtime contracts") +System_Ext(fs, "Host File System", "Config, source, output") + +Rel(user, runner, "Starts", "CLI") +Rel(user, guiMain, "Uses", "GUI") + +Rel(runner, configFiles, "Lists & loads configurations") +Rel(runner, automationCmd, "Delegates automation to") +Rel(runner, guiMain, "Builds & shows (interactive mode)") +Rel(automationCmd, automation, "Runs") + +Rel(runner, virtualComputer, "Creates via VirtualComputer.create()") +Rel(virtualComputer, pluginLoader, "Loads plugin main classes with") +Rel(virtualComputer, configFiles, "Reads plugin list, settings & connections from") +Rel(pluginLoader, plugins, "Reads JARs & resolves classes") +Rel(virtualComputer, plugins, "Instantiates (pluginId, ApplicationApi, PluginSettings)") + +Rel(virtualComputer, appApi, "Passes to each plugin") +Rel(appApi, contextPool, "Exposes") +Rel(appApi, dialogs, "Exposes") +Rel(appApi, guiMain, "Exposes") +Rel(plugins, contextPool, "Register & fetch contexts", "via ApplicationApi") +Rel(contextPool, virtualComputer, "Checks isConnected() before sharing a context") + +Rel(guiMain, schema, "Hosts") +Rel(guiMain, editor, "Hosts") +Rel(guiMain, debugger, "Hosts") +Rel(guiMain, emuController, "Controls emulation via") +Rel(automation, emuController, "Drives CPU without a controller (direct execute)") +Rel(emuController, plugins, "cpu.execute()/step()/reset(); memory.reset(); device.reset()") + +Rel(configFiles, fs, "TOML read/write") +Rel(plugins, emulib, "Implement API") + +@enduml +---- + +== Components + +Runner:: The picocli command that boots the platform. It lists configurations, +resolves the selected virtual computer (by name/index/file or via a GUI +chooser), builds the `ContextPool`, loads the `VirtualComputer` and either shows +the main window or hands off to automation. + +AutomationCommand / Automation:: The headless path. `AutomationCommand` is the +`automation` subcommand; `Automation` performs the unattended sequence — compile +the input file, reset the CPU (optionally at a start address), execute, wait for +completion and log the resulting CPU run state. + +ConfigFiles / Settings:: Reads and writes the TOML configuration and settings +model — `ComputerConfig` (a machine and its `PluginConfig` entries plus +`connections`) and `AppSettings` (global options). Backed by the night-config +TOML library. + +VirtualComputer:: The heart of Level 3. It groups a machine's plugins by type +(`COMPILER`, `MEMORY`, `CPU`, `DEVICE`), constructs them, initializes them in a +fixed order and resets them. It also implements `PluginConnections#isConnected`, +the authority the context pool consults before letting two plugins share a +context. + +PluginLoader:: Loads plugin JARs into a dedicated `URLClassLoader` (including +each JAR's declared `Class-Path` dependencies), scans the classes and returns +the single *trusted* main class — the one that is annotated `@PluginRoot` and +implements the `Plugin` contract. + +ContextPool:: The mediator through which plugins communicate. Plugins *register* +the contexts they provide and *request* the contexts they need; the pool matches +them by an interface hash and only shares a context between plugins that are +connected in the current configuration (emuStudio itself is always allowed). + +ApplicationApi:: The facade handed to every plugin at construction time. It +exposes the `ContextPool`, the `Dialogs` and `GUI` services, the `DebuggerTable` +and the current program location — the plugin's whole view of the host. + +EmulationController:: Serializes all CPU control onto a single worker thread and +mirrors the CPU's `RunState`. It offers start, single-step, timed-step, pause, +stop and reset, coordinating each transition with the CPU via a latch. Used by +the interactive debugger. + +GUI (EmuStudioGui, Schema editor, Source editor, Debugger, Dialogs):: The Swing +front end. The main window hosts the three work areas and provides the emuLib +`GUI` and `Dialogs` services that plugins may use to render settings dialogs and +messages. + +== How a virtual computer comes to life + +. `Runner` selects a `ComputerConfig` and creates a `ContextPool`. +. `VirtualComputer.create()` asks `PluginLoader` to load the plugin JARs and + find each `@PluginRoot` main class. +. Each plugin is constructed by reflection with `(pluginId, ApplicationApi, + PluginSettings)`. +. `initialize()` is called per plugin in the order *compiler → memory → CPU → + devices*; during initialization plugins publish and look up contexts in the + `ContextPool`. +. The GUI (or `Automation`) then drives emulation through + `EmulationController` (or a direct `cpu.execute()`), with the context pool + enforcing the configured connections throughout. + +Continue to xref:code.adoc[Level 4 — Code]. diff --git a/docs/containers.adoc b/docs/containers.adoc new file mode 100644 index 000000000..cfd34d9e9 --- /dev/null +++ b/docs/containers.adoc @@ -0,0 +1,116 @@ += C4 Level 2 — Containers +:toc: left +:icons: font +:sectanchors: + +xref:index.adoc[← Back to overview] + +The *Container* diagram zooms into emuStudio and shows the high-level parts that +make up the running system, the technologies they use and how they collaborate. +In C4, a _container_ is a separately runnable/deployable unit or a well-isolated +runtime boundary — here: the launcher process, the desktop UI, the emulation +runtime, the dynamically-loaded plugins and the configuration store. + +== Diagram + +[plantuml,c4-container,svg] +---- +@startuml +!include + +title Containers — emuStudio + +Person(user, "User", "Student, educator or developer") + +System_Boundary(emustudio, "emuStudio") { + Container(cli, "CLI Launcher", "Java, picocli", "Parses command-line arguments; lists computers; starts the GUI or headless automation") + Container(gui, "Desktop Application (GUI)", "Java, Swing, FlatLaf, MigLayout", "Schema editor, source-code editor and emulation debugger") + Container(runtime, "Virtual Computer Runtime", "Java", "Loads plugin JARs, constructs & initializes plugins, mediates plugin communication via the context pool") + Container(engine, "Emulation Engine", "Java", "Drives the CPU (run / step / pause / stop / reset); runs headless automation") + Container(plugins, "Plugins", "Java JARs (Compiler / CPU / Memory / Device)", "The building blocks of a virtual computer; loaded dynamically at runtime") + ContainerDb(config, "Configuration & Settings", "TOML files", "Per-virtual-computer configuration, connections and global application settings") +} + +System_Ext(emulib, "emuLib", "Java library", "Plugin API, ApplicationApi, ContextPool, UI helpers") +System_Ext(fs, "Host File System", "Source & output files") +System_Ext(hostio, "Host I/O", "Screen, keyboard, audio") + +Rel(user, cli, "Runs", "shell") +Rel(user, gui, "Interacts with", "keyboard/mouse") + +Rel(cli, gui, "Launches (interactive mode)") +Rel(cli, engine, "Launches (automation mode)") +Rel(cli, config, "Lists & loads configurations") + +Rel(gui, runtime, "Loads the selected virtual computer via") +Rel(gui, engine, "Controls emulation via") +Rel(runtime, plugins, "Discovers, loads & instantiates") +Rel(runtime, config, "Reads plugin wiring & settings from") +Rel(engine, plugins, "Executes CPU & resets memory/devices") + +Rel(plugins, emulib, "Implement API & publish/consume contexts", "in-JVM") +Rel(runtime, emulib, "Provides ApplicationApi & ContextPool", "in-JVM") +Rel(plugins, hostio, "Render output / read input / play audio") +Rel(engine, fs, "Reads source, writes output (automation)") + +@enduml +---- + +== Containers + +CLI Launcher (`net.emustudio.application.cmdline.Runner`):: +The application entry point, built with *picocli*. It parses arguments, can list +the available virtual computers, and selects a configuration by name, index or +file. Without a subcommand it starts the interactive GUI; with the `automation` +subcommand it runs a headless (or minimal-GUI) emulation. + +Desktop Application (GUI):: +A *Swing* UI themed with *FlatLaf* and laid out with *MigLayout*. It hosts the +three main work areas — the *schema editor* (draw/inspect the virtual computer), +the *source-code editor* (RSyntaxTextArea), and the *emulation debugger* +(disassembly / debug table with breakpoints). It also implements the emuLib +`GUI` and `Dialogs` services offered to plugins. + +Virtual Computer Runtime:: +Turns a configuration into a live machine. It resolves the plugin JARs, loads +them in a dedicated class loader, constructs each plugin via reflection, and +provides the `ApplicationApi` and `ContextPool` through which plugins publish +and consume *contexts*. It also enforces which plugins may talk to each other, +based on the declared connections. + +Emulation Engine:: +Owns the emulation lifecycle. `EmulationController` drives the CPU on a single +worker thread (start, step, timed-step, pause, stop, reset) and tracks the CPU +run state; `Automation` performs the unattended *compile → reset → execute → +report* flow used in headless runs. + +Plugins:: +The interchangeable parts of a virtual computer, shipped as JARs in the +`compiler/`, `cpu/`, `memory/` and `device/` folders of the distribution. Each +plugin implements one of the emuLib plugin interfaces and is annotated so the +runtime can discover its root class. The distribution bundles the official +compilers, CPUs, memories and devices for all supported machines. + +Configuration & Settings:: +*TOML* files. Each virtual computer has a configuration file describing its +plugins (type, JAR path, per-plugin settings, schema coordinates) and the +*connections* between them. A separate file holds global application settings. + +== External dependencies + +* *emuLib* — the shared API/runtime library, loaded into the same JVM. +* *Host File System* — source, configuration, settings and output files. +* *Host I/O* — screen, keyboard and audio driven by device plugins. + +== Notable design points + +* *Dynamic loading*: plugins are ordinary JARs discovered at runtime, not + compile-time dependencies of the application, which keeps the platform open + and extensible. +* *Indirect communication*: plugins interact only through contexts in the + context pool, gated by the configuration's connections — never by direct + references. +* *Two run modes*: the same runtime backs both the interactive GUI and the + headless automation used for testing and batch runs. + +Continue to xref:components.adoc[Level 3 — Components]. diff --git a/docs/context.adoc b/docs/context.adoc new file mode 100644 index 000000000..ca9299159 --- /dev/null +++ b/docs/context.adoc @@ -0,0 +1,83 @@ += C4 Level 1 — System Context +:toc: left +:icons: font +:sectanchors: + +xref:index.adoc[← Back to overview] + +The *System Context* diagram shows emuStudio as a single black box, the people +who use it and the external systems it depends on. + +== Diagram + +[plantuml,c4-context,svg] +---- +@startuml +!include + +title System Context — emuStudio + +Person(student, "Student / Learner", "Writes, compiles, loads and debugs programs for emulated computers; explores how historic machines work") +Person(educator, "Educator", "Uses emuStudio as a teaching tool for computer architecture and assembly programming") +Person(developer, "Emulator / Plugin Developer", "Builds new compilers, CPUs, memories and devices, or whole virtual computers, using the SDK") + +System(emustudio, "emuStudio", "Modular desktop platform for emulating historic and teaching computers (compile, load, emulate)") + +System_Ext(filesystem, "Host File System", "Source files, virtual-computer configurations (TOML), application settings, output files and plugin JARs") +System_Ext(hostio, "Host I/O (Screen, Keyboard, Audio)", "Terminal and graphical output, user input, and sound output for audio devices") +System_Ext(emulib, "emuLib SDK", "Shared plugin API, runtime services and UI helpers (sibling library)") + +Rel(student, emustudio, "Edits, compiles, runs and debugs programs", "GUI / CLI") +Rel(educator, emustudio, "Demonstrates & assigns exercises", "GUI") +Rel(developer, emustudio, "Extends with plugins & virtual computers", "emuLib SDK") + +Rel(emustudio, filesystem, "Reads source & config, writes output & settings") +Rel(emustudio, hostio, "Renders output, reads input, plays audio") +Rel_Back(emustudio, emulib, "Provides plugin API & runtime services to") + +SHOW_LEGEND() +@enduml +---- + +== Actors + +Student / Learner:: The primary end user. Loads a bundled virtual computer +(for example the MITS Altair 8800 or the Manchester SSEM), writes assembly or +high-level source, compiles it, loads it into memory and runs or single-steps +the emulation using the debugger. + +Educator:: Uses emuStudio in the classroom as a teaching aid for computer +architecture, assembly language and the fetch–decode–execute cycle. + +Emulator / Plugin Developer:: Extends the platform. Writes new plugins +(compiler, CPU, memory, device) against the emuLib API, or composes existing +plugins into new virtual computers. May also run emuStudio headlessly for +automated test/verification. + +== External systems + +Host File System:: Holds everything emuStudio reads and writes: user source +code, per–virtual-computer configuration files (TOML), global application +settings, plugin JARs (`compiler/`, `cpu/`, `memory/`, `device/`) and device +output files. + +Host I/O (Screen, Keyboard, Audio):: The operating-system facilities that +plugins ultimately drive — terminal/graphical windows, keyboard input, and +sound output used by audio devices (audiotape player, AY-3-8910 chip). + +emuLib SDK:: The shared library that defines the plugin contracts +(`Plugin`, `Compiler`, `CPU`, `Memory`, `Device`), the runtime services +(`ApplicationApi`, `ContextPool`) and UI helpers. It is maintained in the +sibling `emuLib` repository and consumed by both the application and every +plugin. + +== Key interactions + +* A user selects a virtual computer, edits source, and drives the + *compile → load → emulate* workflow through the GUI or the CLI. +* emuStudio loads the plugins referenced by the chosen configuration, wires + them together and runs the emulation. +* Developers plug new capabilities in by dropping additional plugin JARs and a + matching configuration into the distribution. + +Continue to xref:containers.adoc[Level 2 — Containers]. diff --git a/docs/index.adoc b/docs/index.adoc new file mode 100644 index 000000000..81ec29ac1 --- /dev/null +++ b/docs/index.adoc @@ -0,0 +1,74 @@ += emuStudio Architecture Documentation +:author: emuStudio project +:revdate: {docdate} +:toc: left +:icons: font +:sectanchors: +:experimental: + +This is the architecture documentation of *emuStudio* — a modular desktop +platform and framework for emulating historic and teaching-oriented computers, +built around the *compile, load, emulate* workflow. + +The documentation follows the https://c4model.com[C4 model], which describes a +software system at four increasing levels of detail: + +[cols="1,3,3",options="header"] +|=== +| Level | Question it answers | Document + +| 1. System Context +| How does emuStudio fit into the world? Who uses it and what does it talk to? +| xref:context.adoc[System Context] + +| 2. Containers +| What are the high-level runnable/deployable parts and how do they interact? +| xref:containers.adoc[Containers] + +| 3. Components +| What are the major building blocks inside the application and how are they wired? +| xref:components.adoc[Components] + +| 4. Code +| How are the key responsibilities realized in code (classes and contracts)? +| xref:code.adoc[Code] +|=== + +== What emuStudio is + +emuStudio is a Java desktop application with a command-line launcher. Emulated +machines are called *virtual computers* and are assembled from four kinds of +*plugins*: + +* *Compiler* — translates source code into machine code / memory image. +* *CPU* — executes instructions (often cycle-accurate). +* *Memory* — stores the program and data. +* *Device* — peripherals such as terminals, tapes, disks and sound chips. + +Plugins are packaged as JAR files, discovered and loaded dynamically at +runtime, and are wired together according to a *virtual computer configuration* +(a TOML file). Plugins never call each other directly; they communicate through +*contexts* published in a shared *context pool*, and only along connections +declared in the configuration. + +The plugin API, shared runtime services and UI helpers live in a separate +library, *emuLib*, which every plugin and the application depend on. + +== How to regenerate this documentation + +The sources are AsciiDoc files in the `docs/` folder. To render them to HTML: + +[source,bash] +---- +./gradlew doc +---- + +The generated site is written to `docs/output/` (open `docs/output/index.html`). + +== Scope + +This documentation covers the `emuStudio` repository: the desktop application, +the CLI launcher, the bundled official plugins and the virtual-computer +configurations shipped with the distribution. Sibling repositories +(`emuLib`, `edigen`, `cpu-testsuite`, `edigen-gradle-plugin`) are shown as +external dependencies where relevant. diff --git a/emuStudio.toml b/emuStudio.toml new file mode 100644 index 000000000..e69de29bb diff --git a/plugins/cpu/z80-cpu/src/test/java/net/emustudio/plugins/cpu/zilogZ80/suite/TimingMemoryStub.java b/plugins/cpu/z80-cpu/src/test/java/net/emustudio/plugins/cpu/zilogZ80/suite/TimingMemoryStub.java index f7b38382e..537bf3b54 100644 --- a/plugins/cpu/z80-cpu/src/test/java/net/emustudio/plugins/cpu/zilogZ80/suite/TimingMemoryStub.java +++ b/plugins/cpu/z80-cpu/src/test/java/net/emustudio/plugins/cpu/zilogZ80/suite/TimingMemoryStub.java @@ -81,8 +81,14 @@ public MemoryContextAnnotations annotations() { } public void clearCounters() { - readCounts.clear(); - passiveCycleCounts.clear(); + // AbstractMemoryStub's constructor calls the overridable clear(), which reaches here before + // this subclass's map fields are initialized, so guard against null during construction. + if (readCounts != null) { + readCounts.clear(); + } + if (passiveCycleCounts != null) { + passiveCycleCounts.clear(); + } totalCycles = 0; } diff --git a/plugins/device/audiotape-player/src/main/java/net/emustudio/plugins/device/audiotape_player/AutomationController.java b/plugins/device/audiotape-player/src/main/java/net/emustudio/plugins/device/audiotape_player/AutomationController.java new file mode 100644 index 000000000..297a3a428 --- /dev/null +++ b/plugins/device/audiotape-player/src/main/java/net/emustudio/plugins/device/audiotape_player/AutomationController.java @@ -0,0 +1,199 @@ +/* SPDX-FileCopyrightText: 2006-2026 Peter Jakubčo + SPDX-License-Identifier: GPL-3.0-or-later */ +package net.emustudio.plugins.device.audiotape_player; + +import net.emustudio.emulib.runtime.helpers.RadixUtils; +import net.jcip.annotations.GuardedBy; +import net.jcip.annotations.ThreadSafe; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.file.Path; +import java.util.List; +import java.util.Objects; +import java.util.Queue; +import java.util.concurrent.*; + +import static net.emustudio.emulib.runtime.helpers.SleepUtils.preciseSleepNanos; + +/** + * Executes a list of automation events sequentially. + * Designed to run on a background thread. + */ +@ThreadSafe +public class AutomationController implements AutoCloseable { + private static final Logger LOGGER = LoggerFactory.getLogger(AutomationController.class); + + public enum State { + PLAYING, + STOPPED, + CLOSED // terminal state + } + + public interface AutomationListener { + void stateChanged(State state); + + void currentIndexChanged(int index); + } + + private final ExecutorService pool = Executors.newFixedThreadPool(1); + private final Object lock = new Object(); + @GuardedBy("lock") + private State state = State.STOPPED; + @GuardedBy("lock") + private Future future; + + private final Queue stateNotifications = new ConcurrentLinkedQueue<>(); + + private AutomationListener listener; + private final RadixUtils radixUtils = RadixUtils.getInstance(); + private final TapePlaybackController controller; + + public AutomationController(TapePlaybackController controller) { + this.controller = Objects.requireNonNull(controller); + } + + public void reset() { + stop(); + } + + public void setListener(AutomationListener listener) { + this.listener = listener; + } + + public boolean isPlaying() { + synchronized (lock) { + return this.state == State.PLAYING; + } + } + + public void play(List events) { + AutomationListener tmpListener = listener; + + synchronized (lock) { + if (this.state == State.STOPPED) { + this.state = State.PLAYING; + + LOGGER.info("AudioTape started with {} events", events.size()); + this.future = pool.submit(() -> { + int currrentIndex = 0; + try { + for (; currrentIndex < events.size(); currrentIndex++) { + if (tmpListener != null) { + tmpListener.currentIndexChanged(currrentIndex); + } + + AutomationEvent event = events.get(currrentIndex); + LOGGER.info("AudioTape event [{}]: {}", currrentIndex, event.getDescription()); + executeEvent(event); + } + LOGGER.info("AudioTape finished"); + } catch (InterruptedException e) { + LOGGER.info("AudioTape interrupted at event [{}]", currrentIndex); + controller.stop(false); + Thread.currentThread().interrupt(); + } finally { + synchronized (lock) { + this.state = this.state == State.CLOSED ? this.state : State.STOPPED; + stateNotifications.add(this.state); + } + notifyStateChange(); + } + }); + } + } + } + + public void stop() { + synchronized (lock) { + if (this.state == State.PLAYING) { + Future tmpFuture = this.future; + this.future = null; + if (tmpFuture != null) { + tmpFuture.cancel(true); + } + this.state = State.STOPPED; + } + stateNotifications.add(this.state); + } + notifyStateChange(); + } + + @Override + public void close() { + synchronized (lock) { + this.state = State.CLOSED; + Future tmpFuture = this.future; + this.future = null; + if (tmpFuture != null) { + tmpFuture.cancel(true); + } + pool.shutdown(); + stateNotifications.add(this.state); + } + notifyStateChange(); + try { + if (!pool.awaitTermination(5, TimeUnit.SECONDS)) { + pool.shutdownNow(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private void executeEvent(AutomationEvent event) throws InterruptedException { + switch (event.getType()) { + case LOAD_TAPE: + String path = event.getParameter(); + if (!path.isEmpty()) { + controller.load(Path.of(path)); + } else { + LOGGER.warn("LOAD_TAPE event has no path, skipping"); + } + break; + case DELAY: + int seconds = radixUtils.parseRadix(event.getParameter()); + if (seconds > 0) { + preciseSleepNanos(TimeUnit.SECONDS.toNanos(seconds)); + } + break; + case PLAY: + controller.play(); + waitForPlaybackEnd(); + break; + case STOP: + controller.stop(false); + break; + case RESET: + controller.reset(); + break; + case UNLOAD: + controller.stop(true); + break; + } + } + + private void waitForPlaybackEnd() throws InterruptedException { + do { + TapePlaybackController.CassetteState state = controller.getState(); + if (state != TapePlaybackController.CassetteState.PLAYING) { + break; + } + preciseSleepNanos(TimeUnit.MILLISECONDS.toNanos(100)); + } while (getState() == State.PLAYING); + } + + private State getState() { + synchronized (lock) { + return this.state; + } + } + + private void notifyStateChange() { + State notification = stateNotifications.poll(); + AutomationListener tmpListener = listener; + if (notification != null && tmpListener != null) { + tmpListener.stateChanged(notification); + } + } +} diff --git a/plugins/device/audiotape-player/src/main/java/net/emustudio/plugins/device/audiotape_player/AutomationRunner.java b/plugins/device/audiotape-player/src/main/java/net/emustudio/plugins/device/audiotape_player/AutomationRunner.java deleted file mode 100644 index 641acda7d..000000000 --- a/plugins/device/audiotape-player/src/main/java/net/emustudio/plugins/device/audiotape_player/AutomationRunner.java +++ /dev/null @@ -1,138 +0,0 @@ -/* SPDX-FileCopyrightText: 2006-2026 Peter Jakubčo - SPDX-License-Identifier: GPL-3.0-or-later */ -package net.emustudio.plugins.device.audiotape_player; - -import net.jcip.annotations.ThreadSafe; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Objects; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.IntConsumer; - -/** - * Executes a list of automation events sequentially. - * Designed to run on a background thread. - */ -@ThreadSafe -public class AutomationRunner implements Runnable { - private static final Logger LOGGER = LoggerFactory.getLogger(AutomationRunner.class); - private final TapePlaybackController controller; - private final List events; - private final AtomicBoolean cancelled = new AtomicBoolean(false); - private final AtomicInteger currentEventIndex = new AtomicInteger(-1); - private volatile IntConsumer eventIndexListener; - - public AutomationRunner(TapePlaybackController controller, List events) { - this.controller = Objects.requireNonNull(controller); - this.events = Collections.unmodifiableList(new ArrayList<>(events)); - } - - public void setEventIndexListener(IntConsumer listener) { - this.eventIndexListener = listener; - } - - public List getEvents() { - return events; - } - - public int getCurrentEventIndex() { - return currentEventIndex.get(); - } - - public void cancel() { - cancelled.set(true); - } - - public boolean isCancelled() { - return cancelled.get(); - } - - @Override - public void run() { - LOGGER.info("Automation started with {} events", events.size()); - for (int i = 0; i < events.size() && !cancelled.get(); i++) { - currentEventIndex.set(i); - notifyListener(i); - AutomationEvent event = events.get(i); - LOGGER.info("Automation event [{}]: {}", i, event.getDescription()); - try { - executeEvent(event); - } catch (InterruptedException e) { - LOGGER.info("Automation interrupted at event [{}]", i); - Thread.currentThread().interrupt(); - break; - } - } - if (!cancelled.get()) { - currentEventIndex.set(events.size()); - notifyListener(events.size()); - LOGGER.info("Automation completed"); - } else { - LOGGER.info("Automation cancelled"); - } - } - - private void executeEvent(AutomationEvent event) throws InterruptedException { - switch (event.getType()) { - case LOAD_TAPE: - String path = event.getParameter(); - if (!path.isEmpty()) { - controller.load(Path.of(path)); - } else { - LOGGER.warn("LOAD_TAPE event has no path, skipping"); - } - break; - case DELAY: - int seconds = parseSeconds(event.getParameter()); - if (seconds > 0) { - Thread.sleep(seconds * 1000L); - } - break; - case PLAY: - controller.play(); - waitForPlaybackEnd(); - break; - case STOP: - controller.stop(false); - break; - case RESET: - controller.reset(); - break; - case UNLOAD: - controller.stop(true); - break; - } - } - - private void waitForPlaybackEnd() throws InterruptedException { - while (!cancelled.get()) { - TapePlaybackController.CassetteState state = controller.getState(); - if (state != TapePlaybackController.CassetteState.PLAYING) { - break; - } - Thread.sleep(100); - } - } - - private int parseSeconds(String param) { - try { - return Integer.parseInt(param); - } catch (NumberFormatException e) { - LOGGER.warn("Invalid delay value: '{}', using 0", param); - return 0; - } - } - - private void notifyListener(int index) { - IntConsumer listener = this.eventIndexListener; - if (listener != null) { - listener.accept(index); - } - } -} diff --git a/plugins/device/audiotape-player/src/main/java/net/emustudio/plugins/device/audiotape_player/DeviceImpl.java b/plugins/device/audiotape-player/src/main/java/net/emustudio/plugins/device/audiotape_player/DeviceImpl.java index c759cc768..73a9b0da7 100644 --- a/plugins/device/audiotape-player/src/main/java/net/emustudio/plugins/device/audiotape_player/DeviceImpl.java +++ b/plugins/device/audiotape-player/src/main/java/net/emustudio/plugins/device/audiotape_player/DeviceImpl.java @@ -30,8 +30,7 @@ public class DeviceImpl extends AbstractDevice { private TapePlayerGui gui; private TapePlaybackController controller; private TapePlaybackImpl cassetteListener; - private AutomationRunner automationRunner; - private Thread automationThread; + private AutomationController automationController; private JFrame parentFrame; public DeviceImpl(long pluginID, ApplicationApi applicationApi, PluginSettings settings) { @@ -65,20 +64,14 @@ public void initialize() throws PluginInitializationException { public void reset() { this.controller.reset(); if (automaticEmulation && !guiSupported) { - List storedEvents = settings.getArray(SettingsDialog.SETTINGS_KEY_EVENTS); - List events = AutomationEvent.deserializeAll(storedEvents); - if (!events.isEmpty()) { - automationRunner = new AutomationRunner(controller, events); - automationThread = new Thread(automationRunner, "audiotape-automation"); - automationThread.setDaemon(true); - } + automationController = new AutomationController(controller); } } @Override public void destroy() { - if (automationRunner != null) { - automationRunner.cancel(); + if (automationController != null) { + automationController.close(); } this.controller.close(); if (guiIOset || gui != null) { @@ -112,14 +105,17 @@ public void showGUI(JFrame parent) { } this.gui.setVisible(true); - // Start automation if runner is ready - if (automationRunner != null && automationThread != null && !automationThread.isAlive()) { - gui.setAutomationRunner(automationRunner); - automationThread.start(); + // Start automation if controller is ready + if (automationController != null && !automationController.isPlaying()) { + List storedEvents = settings.getArray(SettingsDialog.SETTINGS_KEY_EVENTS); + List events = AutomationEvent.deserializeAll(storedEvents); + automationController.play(events); } - } else if (automationRunner != null && automationThread != null && !automationThread.isAlive()) { + } else if (automationController != null && !automationController.isPlaying()) { // No GUI - just start automation - automationThread.start(); + List storedEvents = settings.getArray(SettingsDialog.SETTINGS_KEY_EVENTS); + List events = AutomationEvent.deserializeAll(storedEvents); + automationController.play(events); } } diff --git a/plugins/device/audiotape-player/src/main/java/net/emustudio/plugins/device/audiotape_player/gui/TapePlayerGui.java b/plugins/device/audiotape-player/src/main/java/net/emustudio/plugins/device/audiotape_player/gui/TapePlayerGui.java index 6354c4619..11601fab0 100644 --- a/plugins/device/audiotape-player/src/main/java/net/emustudio/plugins/device/audiotape_player/gui/TapePlayerGui.java +++ b/plugins/device/audiotape-player/src/main/java/net/emustudio/plugins/device/audiotape_player/gui/TapePlayerGui.java @@ -11,7 +11,7 @@ import net.emustudio.emulib.runtime.ui.components.DialogBase; import net.emustudio.emulib.runtime.ui.components.FileExtensionsFilter; import net.emustudio.plugins.device.audiotape_player.AutomationEvent; -import net.emustudio.plugins.device.audiotape_player.AutomationRunner; +import net.emustudio.plugins.device.audiotape_player.AutomationController; import net.emustudio.plugins.device.audiotape_player.TapePlaybackController; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -32,19 +32,24 @@ import java.util.concurrent.atomic.AtomicReference; // https://stackoverflow.com/questions/25010068/miglayout-push-vs-grow -public class TapePlayerGui extends DialogBase { +public class TapePlayerGui extends DialogBase implements AutomationController.AutomationListener { private static final Logger LOGGER = LoggerFactory.getLogger(TapePlayerGui.class); private static final String PLAYBACK_PROGRESS_NOT_AVAILABLE = "N/A"; private final GUI gui; - private final static String FOLDER_OPEN_ICON = "/net/emustudio/plugins/device/audiotape_player/gui/folder-open.png"; - private final static String PLAY_ICON = "/net/emustudio/plugins/device/audiotape_player/gui/media-playback-start.png"; - private final static String STOP_ICON = "/net/emustudio/plugins/device/audiotape_player/gui/media-playback-stop.png"; - private final static String EJECT_ICON = "/net/emustudio/plugins/device/audiotape_player/gui/media-eject.png"; - private final static String REFRESH_ICON = "/net/emustudio/plugins/device/audiotape_player/gui/view-refresh.png"; - private final static String LOAD_ICON = "/net/emustudio/plugins/device/audiotape_player/gui/applications-multimedia.png"; - private final static String SAVE_ICON = "/net/emustudio/plugins/device/audiotape_player/gui/document-save.png"; - private final static String COPY_ICON = "/net/emustudio/plugins/device/audiotape_player/gui/edit-copy.png"; + private final static String BROWSE_ICON = "/net/emustudio/plugins/device/audiotape_player/gui/browse.png"; + private final static String PLAY_ICON = "/net/emustudio/plugins/device/audiotape_player/gui/play.png"; + private final static String STOP_ICON = "/net/emustudio/plugins/device/audiotape_player/gui/stop.png"; + private final static String EJECT_ICON = "/net/emustudio/plugins/device/audiotape_player/gui/tape-eject.png"; + private final static String REFRESH_ICON = "/net/emustudio/plugins/device/audiotape_player/gui/refresh.png"; + private final static String LOAD_ICON = "/net/emustudio/plugins/device/audiotape_player/gui/load.png"; + private final static String SAVE_ICON = "/net/emustudio/plugins/device/audiotape_player/gui/events-save.png"; + private final static String COPY_ICON = "/net/emustudio/plugins/device/audiotape_player/gui/events-copy.png"; + private final static String RESET_ICON = "/net/emustudio/plugins/device/audiotape_player/gui/reset.png"; + private final static String AUTO_ADD_ICON = "/net/emustudio/plugins/device/audiotape_player/gui/auto-add.png"; + private final static String AUTO_REMOVE_ICON = "/net/emustudio/plugins/device/audiotape_player/gui/auto-remove.png"; + private final static String AUTO_MOVE_UP_ICON = "/net/emustudio/plugins/device/audiotape_player/gui/auto-move-up.png"; + private final static String AUTO_MOVE_DOWN_ICON = "/net/emustudio/plugins/device/audiotape_player/gui/auto-move-down.png"; private final JPanel panelTapeInfo; private final JButton btnBrowse; @@ -61,6 +66,10 @@ public class TapePlayerGui extends DialogBase { private final JButton btnPlay = new JButton("Play", GUI.loadIcon(PLAY_ICON)); private final JButton btnStop = new JButton("Stop", GUI.loadIcon(STOP_ICON)); private final JButton btnEject = new JButton("Eject", GUI.loadIcon(EJECT_ICON)); + private final JButton btnAutoAdd = new JButton(GUI.loadIcon(AUTO_ADD_ICON)); + private final JButton btnAutoRemove = new JButton(GUI.loadIcon(AUTO_REMOVE_ICON)); + private final JButton btnAutoMoveUp = new JButton(GUI.loadIcon(AUTO_MOVE_UP_ICON)); + private final JButton btnAutoMoveDown = new JButton(GUI.loadIcon(AUTO_MOVE_DOWN_ICON)); private final JTextArea txtFileName = new JTextArea("N/A"); private final JLabel lblStatus; @@ -73,12 +82,11 @@ public class TapePlayerGui extends DialogBase { private final TapePlaybackController controller; private final PluginSettings settings; + private final AutomationController automation; private final List automationEvents = new ArrayList<>(); private final TimelinePanel timelinePanel; private final JScrollPane timelineScrollPane; private volatile int activeTimelineIndex = -1; - private volatile AutomationRunner automationRunner; - private Thread automationThread; private JButton btnAutoPlay; private JButton btnAutoStop; private JButton btnAutoReset; @@ -93,8 +101,10 @@ public TapePlayerGui(JFrame parent, Dialogs dialogs, TapePlaybackController cont this.dialogs = Objects.requireNonNull(dialogs); this.controller = Objects.requireNonNull(controller); this.settings = Objects.requireNonNull(settings); + this.automation = new AutomationController(controller); + this.automation.setListener(this); - this.timelinePanel = new TimelinePanel(automationEvents, () -> activeTimelineIndex); + this.timelinePanel = new TimelinePanel(automationEvents); this.timelineScrollPane = new JScrollPane(timelinePanel); timelineScrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER); timelineScrollPane.setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED); @@ -107,7 +117,7 @@ public TapePlayerGui(JFrame parent, Dialogs dialogs, TapePlaybackController cont cmbDirs.setSelectedIndex(0); cmbDirs.setMinimumSize(new Dimension(0, 0)); }); - btnBrowse.setIcon(GUI.loadIcon(FOLDER_OPEN_ICON)); + btnBrowse.setIcon(GUI.loadIcon(BROWSE_ICON)); btnBrowse.setText(""); btnBrowse.setToolTipText("Select directory"); btnBrowse.setFocusPainted(false); @@ -268,71 +278,31 @@ private void saveAutomationEvents() { } } - public void setAutomationRunner(AutomationRunner runner) { - this.automationRunner = runner; - runner.setEventIndexListener(this::onTimelineEventChanged); - SwingUtilities.invokeLater(() -> { - automationEvents.clear(); - automationEvents.addAll(runner.getEvents()); - refreshTimeline(); - updateAutoButtons(true); - }); - } - - private void onTimelineEventChanged(int index) { - activeTimelineIndex = index; - SwingUtilities.invokeLater(() -> { - timelinePanel.revalidate(); - timelinePanel.repaint(); - scrollToActiveTimelineEvent(); - if (index >= automationEvents.size()) { - updateAutoButtons(false); - } - }); - } - - private void scrollToActiveTimelineEvent() { - int viewHeight = timelineScrollPane.getViewport().getExtentSize().height; - int centerY = activeTimelineIndex * TimelinePanel.EVENT_ROW_HEIGHT + TimelinePanel.EVENT_ROW_HEIGHT / 2; - int scrollY = Math.max(0, centerY - viewHeight / 2); - timelineScrollPane.getViewport().setViewPosition(new Point(0, scrollY)); - } - private void refreshTimeline() { + timelinePanel.refresh(); timelinePanel.revalidate(); timelinePanel.repaint(); } private void startAutomation() { - if (automationRunner != null && !automationRunner.isCancelled()) { - return; - } - if (automationEvents.isEmpty()) { - dialogs.showInfo("No automation events to run.", "Automation"); - return; + if (!automation.isPlaying()) { + activeTimelineIndex = -1; + timelinePanel.clearSelection(); + updateAutoButtons(true); + automation.play(automationEvents); // indexes must match.. (during playing we shouldn't allow updating events) } - activeTimelineIndex = -1; - timelinePanel.selectedIndex = -1; - automationRunner = new AutomationRunner(controller, new ArrayList<>(automationEvents)); - automationRunner.setEventIndexListener(this::onTimelineEventChanged); - automationThread = new Thread(automationRunner, "audiotape-automation"); - automationThread.setDaemon(true); - automationThread.start(); - updateAutoButtons(true); } private void stopAutomation() { - if (automationRunner != null) { - automationRunner.cancel(); - automationRunner = null; - } + automation.stop(); updateAutoButtons(false); } private void resetAutomation() { stopAutomation(); activeTimelineIndex = -1; - timelinePanel.selectedIndex = -1; + timelinePanel.setActiveIndex(-1); + timelinePanel.clearSelection(); refreshTimeline(); } @@ -340,6 +310,10 @@ private void updateAutoButtons(boolean running) { btnAutoPlay.setEnabled(!running); btnAutoStop.setEnabled(running); btnAutoReset.setEnabled(!running); + btnAutoAdd.setEnabled(!running); + btnAutoRemove.setEnabled(!running); + btnAutoMoveUp.setEnabled(!running); + btnAutoMoveDown.setEnabled(!running); } private void addAutomationEvent(AutomationEvent.Type type) { @@ -367,10 +341,10 @@ private void addAutomationEvent(AutomationEvent.Type type) { } private void removeAutomationEvent() { - int index = timelinePanel.selectedIndex; + int index = timelinePanel.getSelectedRow(); if (index >= 0 && index < automationEvents.size()) { automationEvents.remove(index); - timelinePanel.selectedIndex = -1; + timelinePanel.clearSelection(); } else if (!automationEvents.isEmpty()) { automationEvents.remove(automationEvents.size() - 1); } @@ -379,20 +353,20 @@ private void removeAutomationEvent() { } private void moveAutomationEventUp() { - int index = timelinePanel.selectedIndex; + int index = timelinePanel.getSelectedRow(); if (index > 0 && index < automationEvents.size()) { Collections.swap(automationEvents, index, index - 1); - timelinePanel.selectedIndex = index - 1; + timelinePanel.setRowSelectionInterval(index - 1, index - 1); saveAutomationEvents(); refreshTimeline(); } } private void moveAutomationEventDown() { - int index = timelinePanel.selectedIndex; + int index = timelinePanel.getSelectedRow(); if (index >= 0 && index < automationEvents.size() - 1) { Collections.swap(automationEvents, index, index + 1); - timelinePanel.selectedIndex = index + 1; + timelinePanel.setRowSelectionInterval(index + 1, index + 1); saveAutomationEvents(); refreshTimeline(); } @@ -611,30 +585,26 @@ public Component getListCellRendererComponent(JList list, Object value, int i cmbEventType.setMaximumSize(new Dimension(120, 28)); toolbar.add(cmbEventType); - JButton btnAdd = new JButton("+"); - btnAdd.setToolTipText("Add event"); - btnAdd.addActionListener(e -> { + btnAutoAdd.setToolTipText("Add event"); + btnAutoAdd.addActionListener(e -> { AutomationEvent.Type type = (AutomationEvent.Type) cmbEventType.getSelectedItem(); if (type != null) { addAutomationEvent(type); } }); - toolbar.add(btnAdd); + toolbar.add(btnAutoAdd); - JButton btnRemove = new JButton("-"); - btnRemove.setToolTipText("Remove event"); - btnRemove.addActionListener(e -> removeAutomationEvent()); - toolbar.add(btnRemove); + btnAutoRemove.setToolTipText("Remove event"); + btnAutoRemove.addActionListener(e -> removeAutomationEvent()); + toolbar.add(btnAutoRemove); - JButton btnMoveUp = new JButton("↑"); - btnMoveUp.setToolTipText("Move event up"); - btnMoveUp.addActionListener(e -> moveAutomationEventUp()); - toolbar.add(btnMoveUp); + btnAutoMoveUp.setToolTipText("Move event up"); + btnAutoMoveUp.addActionListener(e -> moveAutomationEventUp()); + toolbar.add(btnAutoMoveUp); - JButton btnMoveDown = new JButton("↓"); - btnMoveDown.setToolTipText("Move event down"); - btnMoveDown.addActionListener(e -> moveAutomationEventDown()); - toolbar.add(btnMoveDown); + btnAutoMoveDown.setToolTipText("Move event down"); + btnAutoMoveDown.addActionListener(e -> moveAutomationEventDown()); + toolbar.add(btnAutoMoveDown); toolbar.addSeparator(); toolbar.add(new JPanel(null)); // spacer @@ -648,11 +618,32 @@ public Component getListCellRendererComponent(JList list, Object value, int i btnAutoStop.addActionListener(e -> stopAutomation()); toolbar.add(btnAutoStop); - btnAutoReset = new JButton("Reset"); + btnAutoReset = new JButton("Reset", GUI.loadIcon(RESET_ICON)); btnAutoReset.addActionListener(e -> resetAutomation()); toolbar.add(btnAutoReset); panel.add(toolbar, "cell 0 1, growx"); return panel; } + + @Override + public void stateChanged(AutomationController.State state) { + if (state == AutomationController.State.STOPPED || state == AutomationController.State.CLOSED) { + SwingUtilities.invokeLater(() -> updateAutoButtons(false)); + } + } + + @Override + public void currentIndexChanged(int index) { + activeTimelineIndex = index; + SwingUtilities.invokeLater(() -> { + timelinePanel.setActiveIndex(index); + if (index >= 0 && index < automationEvents.size()) { + timelinePanel.scrollRectToVisible(timelinePanel.getCellRect(index, 0, true)); + } + if (index >= automationEvents.size()) { + updateAutoButtons(false); + } + }); + } } diff --git a/plugins/device/audiotape-player/src/main/java/net/emustudio/plugins/device/audiotape_player/gui/TimelinePanel.java b/plugins/device/audiotape-player/src/main/java/net/emustudio/plugins/device/audiotape_player/gui/TimelinePanel.java index f24943d61..75b3a631b 100644 --- a/plugins/device/audiotape-player/src/main/java/net/emustudio/plugins/device/audiotape_player/gui/TimelinePanel.java +++ b/plugins/device/audiotape-player/src/main/java/net/emustudio/plugins/device/audiotape_player/gui/TimelinePanel.java @@ -5,114 +5,92 @@ import net.emustudio.plugins.device.audiotape_player.AutomationEvent; import javax.swing.*; +import javax.swing.table.AbstractTableModel; +import javax.swing.table.TableCellRenderer; import java.awt.*; -import java.awt.event.MouseAdapter; -import java.awt.event.MouseEvent; import java.util.List; /** - * Panel that draws the vertical timeline of automation events. + * JTable-based panel that shows a two-column timeline of automation events. */ -class TimelinePanel extends JPanel { - static final int EVENT_ROW_HEIGHT = 50; - private static final int TIMELINE_WIDTH = 400; - private static final Color COLOR_PAST = new Color(180, 180, 180); +class TimelinePanel extends JTable { private static final Color COLOR_ACTIVE = new Color(50, 150, 50); - private static final Color COLOR_FUTURE = new Color(220, 220, 220); - private static final Color COLOR_CONNECTOR = new Color(100, 100, 100); - private static final Color COLOR_TEXT = Color.BLACK; private static final Color COLOR_ACTIVE_TEXT = Color.WHITE; - private static final Color COLOR_SELECTED_BG = new Color(100, 150, 255, 50); - private final List events; - private final IntSupplier activeIndexSupplier; - int selectedIndex = -1; + private final EventTableModel model; + private int activeIndex = -1; - @FunctionalInterface - interface IntSupplier { - int getAsInt(); + TimelinePanel(List events) { + this.model = new EventTableModel(events); + setModel(model); + setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + setShowGrid(false); + setIntercellSpacing(new Dimension(0, 0)); + setFillsViewportHeight(true); + getTableHeader().setReorderingAllowed(false); + + getColumnModel().getColumn(0).setMaxWidth(40); + getColumnModel().getColumn(0).setMinWidth(30); + getColumnModel().getColumn(1).setCellRenderer(new WordWrapCellRenderer()); } - TimelinePanel(List events, IntSupplier activeIndexSupplier) { - this.events = events; - this.activeIndexSupplier = activeIndexSupplier; - setBackground(Color.WHITE); - addMouseListener(new MouseAdapter() { - @Override - public void mouseClicked(MouseEvent e) { - int clickedIndex = e.getY() / EVENT_ROW_HEIGHT; - if (clickedIndex >= 0 && clickedIndex < events.size()) { - selectedIndex = clickedIndex; - } else { - selectedIndex = -1; - } - repaint(); - } - }); + void refresh() { + model.fireTableDataChanged(); } - @Override - public Dimension getPreferredSize() { - int height = Math.max(events.size() * EVENT_ROW_HEIGHT, 300); - return new Dimension(TIMELINE_WIDTH, height); + void setActiveIndex(int index) { + this.activeIndex = index; + repaint(); } @Override - protected void paintComponent(Graphics g) { - super.paintComponent(g); - Graphics2D g2 = (Graphics2D) g.create(); - g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); - int circleRadius = 12; - int lineX = 40; - int activeIndex = activeIndexSupplier.getAsInt(); - for (int i = 0; i < events.size(); i++) { - int y = i * EVENT_ROW_HEIGHT + EVENT_ROW_HEIGHT / 2; - AutomationEvent event = events.get(i); - - // Selection highlight - if (i == selectedIndex) { - g2.setColor(COLOR_SELECTED_BG); - g2.fillRect(0, i * EVENT_ROW_HEIGHT, getWidth(), EVENT_ROW_HEIGHT); - } + public Component prepareRenderer(TableCellRenderer renderer, int row, int column) { + Component c = super.prepareRenderer(renderer, row, column); + if (row == activeIndex) { + c.setBackground(COLOR_ACTIVE); + c.setForeground(COLOR_ACTIVE_TEXT); + c.setFont(c.getFont().deriveFont(Font.BOLD)); + } else if (!isRowSelected(row)) { + c.setBackground(getBackground()); + c.setForeground(getForeground()); + } + return c; + } - // Connector line - if (i > 0) { - g2.setColor(COLOR_CONNECTOR); - g2.setStroke(new BasicStroke(2)); - g2.drawLine(lineX, (i - 1) * EVENT_ROW_HEIGHT + EVENT_ROW_HEIGHT / 2 + circleRadius, lineX, y - circleRadius); - } + private static class EventTableModel extends AbstractTableModel { + private static final String[] COLUMNS = {"#", "Event"}; + private final List events; + + EventTableModel(List events) { + this.events = events; + } - // Circle - Color circleColor; - Color textColor; - if (i < activeIndex) { - circleColor = COLOR_PAST; - textColor = COLOR_TEXT; - } else if (i == activeIndex) { - circleColor = COLOR_ACTIVE; - textColor = COLOR_ACTIVE_TEXT; - } else { - circleColor = COLOR_FUTURE; - textColor = COLOR_TEXT; + @Override + public int getRowCount() { + return events.size(); + } + + @Override + public int getColumnCount() { + return COLUMNS.length; + } + + @Override + public String getColumnName(int column) { + return COLUMNS[column]; + } + + @Override + public Object getValueAt(int rowIndex, int columnIndex) { + if (columnIndex == 0) { + return rowIndex + 1; } - g2.setColor(circleColor); - g2.fillOval(lineX - circleRadius, y - circleRadius, circleRadius * 2, circleRadius * 2); - g2.setColor(COLOR_CONNECTOR); - g2.drawOval(lineX - circleRadius, y - circleRadius, circleRadius * 2, circleRadius * 2); - - // Index number inside circle - g2.setColor(textColor); - g2.setFont(g2.getFont().deriveFont(Font.BOLD, 11f)); - String indexStr = String.valueOf(i + 1); - FontMetrics fm = g2.getFontMetrics(); - g2.drawString(indexStr, lineX - fm.stringWidth(indexStr) / 2, y + fm.getAscent() / 2 - 1); - - // Event description - g2.setColor(COLOR_TEXT); - g2.setFont(g2.getFont().deriveFont(i == activeIndex ? Font.BOLD : Font.PLAIN, 13f)); - g2.drawString(event.getDescription(), lineX + circleRadius + 12, y + 5); + return events.get(rowIndex).getDescription(); + } + + @Override + public boolean isCellEditable(int rowIndex, int columnIndex) { + return false; } - g2.dispose(); } } - diff --git a/plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/auto-add.png b/plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/auto-add.png new file mode 100644 index 000000000..306d3d892 Binary files /dev/null and b/plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/auto-add.png differ diff --git a/plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/auto-move-down.png b/plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/auto-move-down.png new file mode 100644 index 000000000..af2378817 Binary files /dev/null and b/plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/auto-move-down.png differ diff --git a/plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/auto-move-up.png b/plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/auto-move-up.png new file mode 100644 index 000000000..b0a0cd721 Binary files /dev/null and b/plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/auto-move-up.png differ diff --git a/plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/auto-remove.png b/plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/auto-remove.png new file mode 100644 index 000000000..45e5c2a8e Binary files /dev/null and b/plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/auto-remove.png differ diff --git a/plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/folder-open.png b/plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/browse.png similarity index 100% rename from plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/folder-open.png rename to plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/browse.png diff --git a/plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/edit-copy.png b/plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/events-copy.png similarity index 100% rename from plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/edit-copy.png rename to plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/events-copy.png diff --git a/plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/document-save.png b/plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/events-save.png similarity index 100% rename from plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/document-save.png rename to plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/events-save.png diff --git a/plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/applications-multimedia.png b/plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/load.png similarity index 100% rename from plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/applications-multimedia.png rename to plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/load.png diff --git a/plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/media-playback-start.png b/plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/play.png similarity index 100% rename from plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/media-playback-start.png rename to plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/play.png diff --git a/plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/view-refresh.png b/plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/refresh.png similarity index 100% rename from plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/view-refresh.png rename to plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/refresh.png diff --git a/plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/reset.png b/plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/reset.png new file mode 100644 index 000000000..cab4d02c7 Binary files /dev/null and b/plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/reset.png differ diff --git a/plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/media-playback-stop.png b/plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/stop.png similarity index 100% rename from plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/media-playback-stop.png rename to plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/stop.png diff --git a/plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/media-eject.png b/plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/tape-eject.png similarity index 100% rename from plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/media-eject.png rename to plugins/device/audiotape-player/src/main/resources/net/emustudio/plugins/device/audiotape_player/gui/tape-eject.png diff --git a/plugins/device/audiotape-player/src/test/java/net/emustudio/plugins/device/audiotape_player/AutomationControllerTest.java b/plugins/device/audiotape-player/src/test/java/net/emustudio/plugins/device/audiotape_player/AutomationControllerTest.java new file mode 100644 index 000000000..3bf4a4dea --- /dev/null +++ b/plugins/device/audiotape-player/src/test/java/net/emustudio/plugins/device/audiotape_player/AutomationControllerTest.java @@ -0,0 +1,517 @@ +/* SPDX-FileCopyrightText: 2006-2026 Peter Jakubčo + SPDX-License-Identifier: GPL-3.0-or-later */ +package net.emustudio.plugins.device.audiotape_player; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.easymock.EasyMock.*; +import static org.junit.Assert.*; + +public class AutomationControllerTest { + + private TapePlaybackController tapeController; + private AutomationController controller; + + @Before + public void setUp() { + tapeController = niceMock(TapePlaybackController.class); + controller = new AutomationController(tapeController); + controller.setListener(new AutomationController.AutomationListener() { + @Override + public void stateChanged(AutomationController.State state) { + } + + @Override + public void currentIndexChanged(int index) { + } + }); + } + + @After + public void tearDown() { + controller.close(); + } + + @Test(expected = NullPointerException.class) + public void testNullControllerThrows() { + new AutomationController(null); + } + + @Test + public void testInitialStateIsNotPlaying() { + assertFalse(controller.isPlaying()); + } + + @Test + public void testPlayEmptyEventsTransitionsToPlayingThenStopped() throws InterruptedException { + CountDownLatch stopped = new CountDownLatch(1); + controller.setListener(new AutomationController.AutomationListener() { + @Override + public void stateChanged(AutomationController.State state) { + if (state == AutomationController.State.STOPPED) { + stopped.countDown(); + } + } + + @Override + public void currentIndexChanged(int index) { + } + }); + + replay(tapeController); + controller.play(Collections.emptyList()); + assertTrue(stopped.await(5, TimeUnit.SECONDS)); + assertFalse(controller.isPlaying()); + } + + @Test + public void testPlayExecutesStopEvent() throws InterruptedException { + CountDownLatch stopped = new CountDownLatch(1); + controller.setListener(new AutomationController.AutomationListener() { + @Override + public void stateChanged(AutomationController.State state) { + if (state == AutomationController.State.STOPPED) { + stopped.countDown(); + } + } + + @Override + public void currentIndexChanged(int index) { + } + }); + + tapeController.stop(false); + expectLastCall().once(); + replay(tapeController); + + List events = Collections.singletonList( + new AutomationEvent(AutomationEvent.Type.STOP) + ); + controller.play(events); + assertTrue(stopped.await(5, TimeUnit.SECONDS)); + verify(tapeController); + } + + @Test + public void testPlayExecutesResetEvent() throws InterruptedException { + CountDownLatch stopped = new CountDownLatch(1); + controller.setListener(new AutomationController.AutomationListener() { + @Override + public void stateChanged(AutomationController.State state) { + if (state == AutomationController.State.STOPPED) { + stopped.countDown(); + } + } + + @Override + public void currentIndexChanged(int index) { + } + }); + + tapeController.reset(); + expectLastCall().once(); + replay(tapeController); + + List events = Collections.singletonList( + new AutomationEvent(AutomationEvent.Type.RESET) + ); + controller.play(events); + assertTrue(stopped.await(5, TimeUnit.SECONDS)); + verify(tapeController); + } + + @Test + public void testPlayExecutesUnloadEvent() throws InterruptedException { + CountDownLatch stopped = new CountDownLatch(1); + controller.setListener(new AutomationController.AutomationListener() { + @Override + public void stateChanged(AutomationController.State state) { + if (state == AutomationController.State.STOPPED) { + stopped.countDown(); + } + } + + @Override + public void currentIndexChanged(int index) { + } + }); + + tapeController.stop(true); + expectLastCall().once(); + replay(tapeController); + + List events = Collections.singletonList( + new AutomationEvent(AutomationEvent.Type.UNLOAD) + ); + controller.play(events); + assertTrue(stopped.await(5, TimeUnit.SECONDS)); + verify(tapeController); + } + + @Test + public void testPlayExecutesLoadTapeEvent() throws InterruptedException { + CountDownLatch stopped = new CountDownLatch(1); + controller.setListener(new AutomationController.AutomationListener() { + @Override + public void stateChanged(AutomationController.State state) { + if (state == AutomationController.State.STOPPED) { + stopped.countDown(); + } + } + + @Override + public void currentIndexChanged(int index) { + } + }); + + tapeController.load(Path.of("test.tap")); + expectLastCall().once(); + replay(tapeController); + + List events = Collections.singletonList( + new AutomationEvent(AutomationEvent.Type.LOAD_TAPE, "test.tap") + ); + controller.play(events); + assertTrue(stopped.await(5, TimeUnit.SECONDS)); + verify(tapeController); + } + + @Test + public void testPlaySkipsLoadTapeWithEmptyPath() throws InterruptedException { + CountDownLatch stopped = new CountDownLatch(1); + controller.setListener(new AutomationController.AutomationListener() { + @Override + public void stateChanged(AutomationController.State state) { + if (state == AutomationController.State.STOPPED) { + stopped.countDown(); + } + } + + @Override + public void currentIndexChanged(int index) { + } + }); + + // no load() call expected + replay(tapeController); + + List events = Collections.singletonList( + new AutomationEvent(AutomationEvent.Type.LOAD_TAPE, "") + ); + controller.play(events); + assertTrue(stopped.await(5, TimeUnit.SECONDS)); + verify(tapeController); + } + + @Test + public void testPlayExecutesPlayEvent() throws InterruptedException { + CountDownLatch stopped = new CountDownLatch(1); + controller.setListener(new AutomationController.AutomationListener() { + @Override + public void stateChanged(AutomationController.State state) { + if (state == AutomationController.State.STOPPED) { + stopped.countDown(); + } + } + + @Override + public void currentIndexChanged(int index) { + } + }); + + tapeController.play(); + expectLastCall().once(); + // waitForPlaybackEnd polls getState(); return STOPPED immediately + expect(tapeController.getState()).andReturn(TapePlaybackController.CassetteState.STOPPED).once(); + replay(tapeController); + + List events = Collections.singletonList( + new AutomationEvent(AutomationEvent.Type.PLAY) + ); + controller.play(events); + assertTrue(stopped.await(5, TimeUnit.SECONDS)); + verify(tapeController); + } + + @Test + public void testPlayMultipleEvents() throws InterruptedException { + CountDownLatch stopped = new CountDownLatch(1); + List indices = Collections.synchronizedList(new ArrayList<>()); + controller.setListener(new AutomationController.AutomationListener() { + @Override + public void stateChanged(AutomationController.State state) { + if (state == AutomationController.State.STOPPED) { + stopped.countDown(); + } + } + + @Override + public void currentIndexChanged(int index) { + indices.add(index); + } + }); + + tapeController.load(Path.of("file.tap")); + expectLastCall().once(); + tapeController.play(); + expectLastCall().once(); + expect(tapeController.getState()).andReturn(TapePlaybackController.CassetteState.STOPPED).once(); + tapeController.stop(false); + expectLastCall().once(); + replay(tapeController); + + List events = new ArrayList<>(); + events.add(new AutomationEvent(AutomationEvent.Type.LOAD_TAPE, "file.tap")); + events.add(new AutomationEvent(AutomationEvent.Type.PLAY)); + events.add(new AutomationEvent(AutomationEvent.Type.STOP)); + + controller.play(events); + assertTrue(stopped.await(5, TimeUnit.SECONDS)); + verify(tapeController); + assertEquals(List.of(0, 1, 2), indices); + } + + @Test + public void testStopWhilePlaying() throws InterruptedException { + CountDownLatch started = new CountDownLatch(1); + CountDownLatch stoppedLatch = new CountDownLatch(1); + controller.setListener(new AutomationController.AutomationListener() { + @Override + public void stateChanged(AutomationController.State state) { + if (state == AutomationController.State.STOPPED) { + stoppedLatch.countDown(); + } + } + + @Override + public void currentIndexChanged(int index) { + started.countDown(); + } + }); + + // DELAY event with large delay to keep it playing + replay(tapeController); + + List events = Collections.singletonList( + new AutomationEvent(AutomationEvent.Type.DELAY, "9999") + ); + controller.play(events); + assertTrue(started.await(5, TimeUnit.SECONDS)); + assertTrue(controller.isPlaying()); + + controller.stop(); + assertTrue(stoppedLatch.await(5, TimeUnit.SECONDS)); + assertFalse(controller.isPlaying()); + } + + @Test + public void testStopWhileNotPlayingDoesNothing() { + assertFalse(controller.isPlaying()); + controller.stop(); + assertFalse(controller.isPlaying()); + } + + @Test + public void testStopWithoutListenerDoesNotThrow() { + TapePlaybackController tc = niceMock(TapePlaybackController.class); + replay(tc); + AutomationController headless = new AutomationController(tc); + headless.stop(); + headless.close(); + } + + @Test + public void testCloseWithoutListenerDoesNotThrow() { + TapePlaybackController tc = niceMock(TapePlaybackController.class); + replay(tc); + AutomationController headless = new AutomationController(tc); + headless.close(); + } + + @Test + public void testPlayWithoutListenerCompletes() throws InterruptedException { + TapePlaybackController tc = niceMock(TapePlaybackController.class); + replay(tc); + try (AutomationController headless = new AutomationController(tc)) { + headless.play(Collections.emptyList()); + long deadline = System.currentTimeMillis() + 5000; + while (headless.isPlaying() && System.currentTimeMillis() < deadline) { + Thread.sleep(10); + } + assertFalse(headless.isPlaying()); + } + } + + @Test + public void testPlayWhileAlreadyPlayingIsIgnored() throws InterruptedException { + CountDownLatch started = new CountDownLatch(1); + controller.setListener(new AutomationController.AutomationListener() { + @Override + public void stateChanged(AutomationController.State state) { + } + + @Override + public void currentIndexChanged(int index) { + started.countDown(); + } + }); + + replay(tapeController); + + List longEvents = Collections.singletonList( + new AutomationEvent(AutomationEvent.Type.DELAY, "9999") + ); + controller.play(longEvents); + assertTrue(started.await(5, TimeUnit.SECONDS)); + + // second play should be ignored + List shortEvents = Collections.singletonList( + new AutomationEvent(AutomationEvent.Type.STOP) + ); + controller.play(shortEvents); + assertTrue(controller.isPlaying()); + } + + @Test + public void testResetStopsPlayback() throws InterruptedException { + CountDownLatch started = new CountDownLatch(1); + CountDownLatch stoppedLatch = new CountDownLatch(1); + controller.setListener(new AutomationController.AutomationListener() { + @Override + public void stateChanged(AutomationController.State state) { + if (state == AutomationController.State.STOPPED) { + stoppedLatch.countDown(); + } + } + + @Override + public void currentIndexChanged(int index) { + started.countDown(); + } + }); + + replay(tapeController); + + List events = Collections.singletonList( + new AutomationEvent(AutomationEvent.Type.DELAY, "9999") + ); + controller.play(events); + assertTrue(started.await(5, TimeUnit.SECONDS)); + + controller.reset(); + assertTrue(stoppedLatch.await(5, TimeUnit.SECONDS)); + assertFalse(controller.isPlaying()); + } + + @Test + public void testCloseSetsClosed() throws InterruptedException { + CountDownLatch closed = new CountDownLatch(1); + controller.setListener(new AutomationController.AutomationListener() { + @Override + public void stateChanged(AutomationController.State state) { + if (state == AutomationController.State.CLOSED) { + closed.countDown(); + } + } + + @Override + public void currentIndexChanged(int index) { + } + }); + + replay(tapeController); + controller.close(); + assertTrue(closed.await(5, TimeUnit.SECONDS)); + } + + @Test + public void testCloseWhilePlaying() throws InterruptedException { + CountDownLatch started = new CountDownLatch(1); + CountDownLatch closedLatch = new CountDownLatch(1); + controller.setListener(new AutomationController.AutomationListener() { + @Override + public void stateChanged(AutomationController.State state) { + if (state == AutomationController.State.CLOSED) { + closedLatch.countDown(); + } + } + + @Override + public void currentIndexChanged(int index) { + started.countDown(); + } + }); + + replay(tapeController); + + List events = Collections.singletonList( + new AutomationEvent(AutomationEvent.Type.DELAY, "9999") + ); + controller.play(events); + assertTrue(started.await(5, TimeUnit.SECONDS)); + + controller.close(); + assertTrue(closedLatch.await(5, TimeUnit.SECONDS)); + } + + @Test + public void testDelayWithZeroCompletesImmediately() throws InterruptedException { + CountDownLatch stopped = new CountDownLatch(1); + controller.setListener(new AutomationController.AutomationListener() { + @Override + public void stateChanged(AutomationController.State state) { + if (state == AutomationController.State.STOPPED) { + stopped.countDown(); + } + } + + @Override + public void currentIndexChanged(int index) { + } + }); + + replay(tapeController); + + List events = Collections.singletonList( + new AutomationEvent(AutomationEvent.Type.DELAY, "0") + ); + controller.play(events); + assertTrue(stopped.await(5, TimeUnit.SECONDS)); + } + + @Test + public void testStateListenerReceivesPlayingNotification() throws InterruptedException { + // When play() is called, the listener should be notified with state changes. + // The listener is notified with STOPPED when finished. + List states = Collections.synchronizedList(new ArrayList<>()); + CountDownLatch done = new CountDownLatch(1); + controller.setListener(new AutomationController.AutomationListener() { + @Override + public void stateChanged(AutomationController.State state) { + states.add(state); + if (state == AutomationController.State.STOPPED) { + done.countDown(); + } + } + + @Override + public void currentIndexChanged(int index) { + } + }); + + replay(tapeController); + controller.play(Collections.emptyList()); + assertTrue(done.await(5, TimeUnit.SECONDS)); + assertTrue(states.contains(AutomationController.State.STOPPED)); + } +} + diff --git a/plugins/device/audiotape-player/src/test/java/net/emustudio/plugins/device/audiotape_player/AutomationRunnerTest.java b/plugins/device/audiotape-player/src/test/java/net/emustudio/plugins/device/audiotape_player/AutomationRunnerTest.java deleted file mode 100644 index 31948d922..000000000 --- a/plugins/device/audiotape-player/src/test/java/net/emustudio/plugins/device/audiotape_player/AutomationRunnerTest.java +++ /dev/null @@ -1,287 +0,0 @@ -/* SPDX-FileCopyrightText: 2006-2026 Peter Jakubčo - SPDX-License-Identifier: GPL-3.0-or-later */ -package net.emustudio.plugins.device.audiotape_player; - -import net.emustudio.plugins.device.audiotape_player.loaders.Loader; -import org.junit.After; -import org.junit.Before; -import org.junit.Test; - -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; - -import static org.easymock.EasyMock.*; -import static org.junit.Assert.*; - -public class AutomationRunnerTest { - - private TapePlaybackController controller; - - @Before - public void setUp() { - Loader.TapePlayback listener = niceMock(Loader.TapePlayback.class); - replay(listener); - controller = new TapePlaybackController(listener); - } - - @After - public void tearDown() { - controller.close(); - } - - @Test(expected = NullPointerException.class) - public void testNullControllerThrows() { - new AutomationRunner(null, Collections.emptyList()); - } - - @Test - public void testEmptyEventsCompletes() throws InterruptedException { - AutomationRunner runner = new AutomationRunner(controller, Collections.emptyList()); - Thread thread = new Thread(runner); - thread.start(); - thread.join(2000); - assertFalse(thread.isAlive()); - assertFalse(runner.isCancelled()); - assertEquals(0, runner.getCurrentEventIndex()); - } - - @Test - public void testGetEventsReturnsUnmodifiable() { - List events = Arrays.asList( - new AutomationEvent(AutomationEvent.Type.PLAY), - new AutomationEvent(AutomationEvent.Type.STOP) - ); - AutomationRunner runner = new AutomationRunner(controller, events); - assertEquals(2, runner.getEvents().size()); - try { - runner.getEvents().add(new AutomationEvent(AutomationEvent.Type.PLAY)); - fail("Should be unmodifiable"); - } catch (UnsupportedOperationException expected) { - } - } - - @Test - public void testInitialIndexIsMinusOne() { - AutomationRunner runner = new AutomationRunner(controller, Collections.emptyList()); - assertEquals(-1, runner.getCurrentEventIndex()); - } - - @Test - public void testCancelSetsFlag() { - AutomationRunner runner = new AutomationRunner(controller, Collections.emptyList()); - assertFalse(runner.isCancelled()); - runner.cancel(); - assertTrue(runner.isCancelled()); - } - - @Test - public void testStopEventCallsControllerStop() throws InterruptedException { - List events = Collections.singletonList( - new AutomationEvent(AutomationEvent.Type.STOP) - ); - AutomationRunner runner = new AutomationRunner(controller, events); - Thread thread = new Thread(runner); - thread.start(); - thread.join(2000); - assertFalse(thread.isAlive()); - // Controller should still be in UNLOADED (stop on unloaded = no-op) - assertEquals(TapePlaybackController.CassetteState.UNLOADED, controller.getState()); - } - - @Test - public void testResetEventCallsControllerReset() throws InterruptedException { - controller.load(Path.of("test.tap")); - assertEquals(TapePlaybackController.CassetteState.STOPPED, controller.getState()); - - List events = Collections.singletonList( - new AutomationEvent(AutomationEvent.Type.RESET) - ); - AutomationRunner runner = new AutomationRunner(controller, events); - Thread thread = new Thread(runner); - thread.start(); - thread.join(2000); - assertFalse(thread.isAlive()); - assertEquals(TapePlaybackController.CassetteState.UNLOADED, controller.getState()); - } - - @Test - public void testUnloadEventCallsControllerStopWithUnload() throws InterruptedException { - controller.load(Path.of("test.tap")); - assertEquals(TapePlaybackController.CassetteState.STOPPED, controller.getState()); - - List events = Collections.singletonList( - new AutomationEvent(AutomationEvent.Type.UNLOAD) - ); - AutomationRunner runner = new AutomationRunner(controller, events); - Thread thread = new Thread(runner); - thread.start(); - thread.join(2000); - assertFalse(thread.isAlive()); - assertEquals(TapePlaybackController.CassetteState.UNLOADED, controller.getState()); - } - - @Test - public void testLoadTapeEventCallsControllerLoad() throws InterruptedException { - List events = Collections.singletonList( - new AutomationEvent(AutomationEvent.Type.LOAD_TAPE, "test.tap") - ); - AutomationRunner runner = new AutomationRunner(controller, events); - Thread thread = new Thread(runner); - thread.start(); - thread.join(2000); - assertFalse(thread.isAlive()); - assertEquals(TapePlaybackController.CassetteState.STOPPED, controller.getState()); - } - - @Test - public void testLoadTapeEmptyPathSkipped() throws InterruptedException { - List events = Collections.singletonList( - new AutomationEvent(AutomationEvent.Type.LOAD_TAPE, "") - ); - AutomationRunner runner = new AutomationRunner(controller, events); - Thread thread = new Thread(runner); - thread.start(); - thread.join(2000); - assertFalse(thread.isAlive()); - assertEquals(TapePlaybackController.CassetteState.UNLOADED, controller.getState()); - } - - @Test - public void testCancelStopsExecution() throws InterruptedException { - // Use a delay event so we have time to cancel - List events = Arrays.asList( - new AutomationEvent(AutomationEvent.Type.DELAY, "10"), - new AutomationEvent(AutomationEvent.Type.STOP) - ); - AutomationRunner runner = new AutomationRunner(controller, events); - Thread thread = new Thread(runner); - thread.start(); - - // Wait a bit then cancel + interrupt (sleep won't check the flag) - Thread.sleep(200); - assertTrue(thread.isAlive()); - runner.cancel(); - thread.interrupt(); - thread.join(2000); - assertFalse(thread.isAlive()); - assertTrue(runner.isCancelled()); - // Should have been interrupted during DELAY, so STOP event should not have run - assertEquals(0, runner.getCurrentEventIndex()); - } - - @Test - public void testEventIndexListenerNotified() throws InterruptedException { - List events = Arrays.asList( - new AutomationEvent(AutomationEvent.Type.STOP), - new AutomationEvent(AutomationEvent.Type.STOP), - new AutomationEvent(AutomationEvent.Type.STOP) - ); - AutomationRunner runner = new AutomationRunner(controller, events); - - List notifiedIndices = Collections.synchronizedList(new ArrayList<>()); - runner.setEventIndexListener(notifiedIndices::add); - - Thread thread = new Thread(runner); - thread.start(); - thread.join(2000); - assertFalse(thread.isAlive()); - - // Should have been notified for indices 0, 1, 2, and final (3 = events.size()) - assertEquals(4, notifiedIndices.size()); - assertEquals(Integer.valueOf(0), notifiedIndices.get(0)); - assertEquals(Integer.valueOf(1), notifiedIndices.get(1)); - assertEquals(Integer.valueOf(2), notifiedIndices.get(2)); - assertEquals(Integer.valueOf(3), notifiedIndices.get(3)); - } - - @Test - public void testNoListenerDoesNotThrow() throws InterruptedException { - List events = Collections.singletonList( - new AutomationEvent(AutomationEvent.Type.STOP) - ); - AutomationRunner runner = new AutomationRunner(controller, events); - // no listener set - Thread thread = new Thread(runner); - thread.start(); - thread.join(2000); - assertFalse(thread.isAlive()); - } - - @Test - public void testMultipleEventsExecuteInOrder() throws InterruptedException { - List events = Arrays.asList( - new AutomationEvent(AutomationEvent.Type.LOAD_TAPE, "test.tap"), - new AutomationEvent(AutomationEvent.Type.UNLOAD) - ); - AutomationRunner runner = new AutomationRunner(controller, events); - - List notifiedIndices = Collections.synchronizedList(new ArrayList<>()); - runner.setEventIndexListener(notifiedIndices::add); - - Thread thread = new Thread(runner); - thread.start(); - thread.join(2000); - assertFalse(thread.isAlive()); - - // After LOAD then UNLOAD, state should be UNLOADED - assertEquals(TapePlaybackController.CassetteState.UNLOADED, controller.getState()); - assertEquals(3, notifiedIndices.size()); // 0, 1, 2 (completed) - } - - @Test - public void testDelayWithInvalidValueSkipped() throws InterruptedException { - List events = Collections.singletonList( - new AutomationEvent(AutomationEvent.Type.DELAY, "abc") - ); - AutomationRunner runner = new AutomationRunner(controller, events); - Thread thread = new Thread(runner); - thread.start(); - thread.join(2000); - assertFalse(thread.isAlive()); - // Should complete quickly without error - assertEquals(1, runner.getCurrentEventIndex()); - } - - @Test - public void testDelayWithZeroCompletesImmediately() throws InterruptedException { - List events = Collections.singletonList( - new AutomationEvent(AutomationEvent.Type.DELAY, "0") - ); - AutomationRunner runner = new AutomationRunner(controller, events); - Thread thread = new Thread(runner); - thread.start(); - thread.join(2000); - assertFalse(thread.isAlive()); - } - - @Test - public void testPlayOnUnloadedCompletesImmediately() throws InterruptedException { - // Play without loading - controller stays UNLOADED, play() is no-op, - // waitForPlaybackEnd sees not PLAYING → exits immediately - List events = Collections.singletonList( - new AutomationEvent(AutomationEvent.Type.PLAY) - ); - AutomationRunner runner = new AutomationRunner(controller, events); - Thread thread = new Thread(runner); - thread.start(); - thread.join(2000); - assertFalse(thread.isAlive()); - } - - @Test - public void testFinalIndexIsEventsSize() throws InterruptedException { - List events = Arrays.asList( - new AutomationEvent(AutomationEvent.Type.STOP), - new AutomationEvent(AutomationEvent.Type.STOP) - ); - AutomationRunner runner = new AutomationRunner(controller, events); - Thread thread = new Thread(runner); - thread.start(); - thread.join(2000); - assertEquals(events.size(), runner.getCurrentEventIndex()); - } -} - diff --git a/plugins/device/zxspectrum-ula/src/main/java/net/emustudio/plugins/device/zxspectrum/ula/audio/Beeper.java b/plugins/device/zxspectrum-ula/src/main/java/net/emustudio/plugins/device/zxspectrum/ula/audio/Beeper.java index 9434f8374..7e3128473 100644 --- a/plugins/device/zxspectrum-ula/src/main/java/net/emustudio/plugins/device/zxspectrum/ula/audio/Beeper.java +++ b/plugins/device/zxspectrum-ula/src/main/java/net/emustudio/plugins/device/zxspectrum/ula/audio/Beeper.java @@ -148,7 +148,7 @@ public void setLevel(boolean earOn, boolean micOn, boolean tapeIn) { // Tape input does not drive the speaker on real hardware, but mixing it in at // reduced amplitude reproduces the familiar loading sounds heard through the TV speaker. if (tapeIn) { - pcm = (short) Math.min(Short.MAX_VALUE, pcm + (MAX_SAMPLE_AMPLITUDE / 10)); + pcm = (short) Math.min(Short.MAX_VALUE, pcm + (MAX_SAMPLE_AMPLITUDE / 25)); } short finalPcm = pcm;