Compare commits

..
15 Commits
12 changed files with 709 additions and 292 deletions
Generated
+214 -216
View File
File diff suppressed because it is too large Load Diff
+9 -7
View File
@@ -4,7 +4,7 @@ description = "An emulator for the Virtual Boy."
repository = "https://git.virtual-boy.com/PVB/lemur"
publish = false
license = "MIT"
version = "0.2.1"
version = "0.2.5"
edition = "2021"
[dependencies]
@@ -14,12 +14,13 @@ bytemuck = { version = "1", features = ["derive"] }
clap = { version = "4", features = ["derive"] }
cpal = { git = "https://github.com/sidit77/cpal.git", rev = "66ed6be" }
directories = "5"
egui = { version = "0.29", features = ["serde"] }
egui_extras = { version = "0.29", features = ["image"] }
egui-toast = "0.15"
egui-winit = "0.29"
egui-wgpu = { version = "0.29", features = ["winit"] }
egui = { version = "0.30", features = ["serde"] }
egui_extras = { version = "0.30", features = ["image"] }
egui-toast = { git = "https://github.com/urholaukkarinen/egui-toast.git", rev = "d0bcf97" }
egui-winit = "0.30"
egui-wgpu = { version = "0.30", features = ["winit"] }
gilrs = { version = "0.11", features = ["serde-serialize"] }
hex = "0.4"
image = { version = "0.25", default-features = false, features = ["png"] }
itertools = "0.13"
num-derive = "0.4"
@@ -32,7 +33,8 @@ rubato = "0.16"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thread-priority = "1"
wgpu = "22.1"
tokio = { version = "1", features = ["io-util", "macros", "net", "rt", "sync"] }
wgpu = "23"
winit = { version = "0.30", features = ["serde"] }
[target.'cfg(windows)'.dependencies]
+9 -2
View File
@@ -1,3 +1,4 @@
set -e
version=$(cat Cargo.toml | sed -n -e '/version/ {s/.* = *//p;q}' | tr -d '"')
read -p "You wanted to release $version, right? [Y/n] " -n 1 -r
@@ -18,11 +19,15 @@ if ! command -v jq 2>&1 >/dev/null; then
echo "Please install jq."
exit 1
fi
if ! command -v docker 2>&1 >/dev/null; then
echo "Please install docker."
exit 1
fi
docker build -f build.Dockerfile -t lemur-build .
MSYS_NO_PATHCONV=1 docker run -it --rm -v .:/app -w /app --entrypoint bash lemur-build /app/scripts/do-bundle.sh
read -r -d EOF 'body' <<EOF
body=$(cat <<EOF
## How to install
The emulator can be found in the "Downloads" section of this release.
@@ -43,8 +48,9 @@ If you're not sure which to choose, use [this guide](https://support.apple.com/e
You can either download and run \`lemur-linux\`, or download and install the attached .deb file.
EOF
)
read -r -d EOF 'payload' <<EOF
payload=$(cat <<EOF
{
"body": $(echo "$body" | jq -Rsa .),
"draft": false,
@@ -53,6 +59,7 @@ read -r -d EOF 'payload' <<EOF
"tag_name": "v${version}"
}
EOF
)
echo "Creating release..."
response=$(curl -s --json "$payload" "https://git.virtual-boy.com/api/v1/repos/PVB/lemur/releases?token=$RELEASE_TOKEN")
+22 -14
View File
@@ -18,7 +18,7 @@ use crate::{
emulator::{EmulatorClient, EmulatorCommand, SimId},
input::MappingProvider,
persistence::Persistence,
window::{AboutWindow, AppWindow, GameWindow, InputWindow},
window::{AboutWindow, AppWindow, GameWindow, GdbServerWindow, InputWindow},
};
fn load_icon() -> anyhow::Result<IconData> {
@@ -183,6 +183,10 @@ impl ApplicationHandler<UserEvent> for Application {
let about = AboutWindow;
self.open(event_loop, Box::new(about));
}
UserEvent::OpenDebugger(sim_id) => {
let debugger = GdbServerWindow::new(sim_id, self.client.clone());
self.open(event_loop, Box::new(debugger));
}
UserEvent::OpenInput => {
let input = InputWindow::new(self.mappings.clone());
self.open(event_loop, Box::new(input));
@@ -231,22 +235,13 @@ impl Viewport {
icon: Option<Arc<IconData>>,
mut app: Box<dyn AppWindow>,
) -> Self {
let mut painter = egui_wgpu::winit::Painter::new(
egui_wgpu::WgpuConfiguration {
present_mode: wgpu::PresentMode::AutoNoVsync,
..egui_wgpu::WgpuConfiguration::default()
},
1,
None,
false,
true,
);
let ctx = Context::default();
let mut fonts = FontDefinitions::default();
fonts.font_data.insert(
"Selawik".into(),
FontData::from_static(include_bytes!("../assets/selawik.ttf")),
Arc::new(FontData::from_static(include_bytes!(
"../assets/selawik.ttf"
))),
);
fonts
.families
@@ -260,6 +255,18 @@ impl Viewport {
});
egui_extras::install_image_loaders(&ctx);
let mut painter = egui_wgpu::winit::Painter::new(
ctx.clone(),
egui_wgpu::WgpuConfiguration {
present_mode: wgpu::PresentMode::AutoNoVsync,
..egui_wgpu::WgpuConfiguration::default()
},
1,
None,
false,
true,
);
let mut info = ViewportInfo::default();
let mut builder = app.initial_viewport();
if let Some(icon) = icon {
@@ -325,7 +332,7 @@ impl Viewport {
[0.0, 0.0, 0.0, 0.0],
&clipped_primitives,
&output.textures_delta,
false,
vec![],
);
self.state
@@ -371,6 +378,7 @@ impl Drop for Viewport {
pub enum UserEvent {
GamepadEvent(gilrs::Event),
OpenAbout,
OpenDebugger(SimId),
OpenInput,
OpenPlayer2,
}
+66 -49
View File
@@ -1,5 +1,6 @@
use std::{
collections::HashMap,
fmt::Display,
fs::{self, File},
io::{Read, Seek, SeekFrom, Write},
path::{Path, PathBuf},
@@ -45,6 +46,14 @@ impl SimId {
}
}
}
impl Display for SimId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Player1 => "Player 1",
Self::Player2 => "Player 2",
})
}
}
struct Cart {
rom_path: PathBuf,
@@ -141,6 +150,8 @@ pub struct Emulator {
linked: Arc<AtomicBool>,
renderers: HashMap<SimId, TextureSink>,
messages: HashMap<SimId, mpsc::Sender<Toast>>,
eye_contents: Vec<u8>,
audio_samples: Vec<f32>,
}
impl Emulator {
@@ -164,6 +175,8 @@ impl Emulator {
linked,
renderers: HashMap::new(),
messages: HashMap::new(),
eye_contents: vec![0u8; 384 * 224 * 2],
audio_samples: vec![0.0; EXPECTED_FRAME_SIZE],
})
}
@@ -257,56 +270,8 @@ impl Emulator {
}
pub fn run(&mut self) {
let mut eye_contents = vec![0u8; 384 * 224 * 2];
let mut audio_samples = vec![];
loop {
let p1_running = self.running[SimId::Player1.to_index()].load(Ordering::Acquire);
let p2_running = self.running[SimId::Player2.to_index()].load(Ordering::Acquire);
let mut idle = p1_running || p2_running;
if p1_running && p2_running {
Sim::emulate_many(&mut self.sims);
} else if p1_running {
self.sims[SimId::Player1.to_index()].emulate();
} else if p2_running {
self.sims[SimId::Player2.to_index()].emulate();
}
for sim_id in SimId::values() {
let Some(renderer) = self.renderers.get_mut(&sim_id) else {
continue;
};
let Some(sim) = self.sims.get_mut(sim_id.to_index()) else {
continue;
};
if sim.read_pixels(&mut eye_contents) {
idle = false;
if renderer.queue_render(&eye_contents).is_err() {
self.renderers.remove(&sim_id);
}
}
}
let p1_audio =
p1_running && self.audio_on[SimId::Player1.to_index()].load(Ordering::Acquire);
let p2_audio =
p2_running && self.audio_on[SimId::Player2.to_index()].load(Ordering::Acquire);
let weight = if p1_audio && p2_audio { 0.5 } else { 1.0 };
if p1_audio {
if let Some(sim) = self.sims.get_mut(SimId::Player1.to_index()) {
sim.read_samples(&mut audio_samples, weight);
}
}
if p2_audio {
if let Some(sim) = self.sims.get_mut(SimId::Player2.to_index()) {
sim.read_samples(&mut audio_samples, weight);
}
}
if audio_samples.is_empty() {
audio_samples.resize(EXPECTED_FRAME_SIZE, 0.0);
} else {
idle = false;
}
self.audio.update(&audio_samples);
audio_samples.clear();
let idle = self.tick();
if idle {
// The game is paused, and we have output all the video/audio we have.
// Block the thread until a new command comes in.
@@ -331,6 +296,58 @@ impl Emulator {
}
}
// returns true if the emulator is "idle" (i.e. this didn't output anything)
pub fn tick(&mut self) -> bool {
let p1_running = self.running[SimId::Player1.to_index()].load(Ordering::Acquire);
let p2_running = self.running[SimId::Player2.to_index()].load(Ordering::Acquire);
let mut idle = p1_running || p2_running;
if p1_running && p2_running {
Sim::emulate_many(&mut self.sims);
} else if p1_running {
self.sims[SimId::Player1.to_index()].emulate();
} else if p2_running {
self.sims[SimId::Player2.to_index()].emulate();
}
for sim_id in SimId::values() {
let Some(renderer) = self.renderers.get_mut(&sim_id) else {
continue;
};
let Some(sim) = self.sims.get_mut(sim_id.to_index()) else {
continue;
};
if sim.read_pixels(&mut self.eye_contents) {
idle = false;
if renderer.queue_render(&self.eye_contents).is_err() {
self.renderers.remove(&sim_id);
}
}
}
let p1_audio =
p1_running && self.audio_on[SimId::Player1.to_index()].load(Ordering::Acquire);
let p2_audio =
p2_running && self.audio_on[SimId::Player2.to_index()].load(Ordering::Acquire);
let weight = if p1_audio && p2_audio { 0.5 } else { 1.0 };
if p1_audio {
if let Some(sim) = self.sims.get_mut(SimId::Player1.to_index()) {
sim.read_samples(&mut self.audio_samples, weight);
}
}
if p2_audio {
if let Some(sim) = self.sims.get_mut(SimId::Player2.to_index()) {
sim.read_samples(&mut self.audio_samples, weight);
}
}
if self.audio_samples.is_empty() {
self.audio_samples.resize(EXPECTED_FRAME_SIZE, 0.0);
} else {
idle = false;
}
self.audio.update(&self.audio_samples);
self.audio_samples.clear();
idle
}
fn handle_command(&mut self, command: EmulatorCommand) {
match command {
EmulatorCommand::ConnectToSim(sim_id, renderer, messages) => {
+294
View File
@@ -0,0 +1,294 @@
use anyhow::{bail, Result};
use std::{
sync::{Arc, Mutex},
thread,
};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt as _, BufReader, BufWriter},
net::{
tcp::{OwnedReadHalf, OwnedWriteHalf},
TcpListener, TcpStream,
},
select,
sync::oneshot,
};
use crate::emulator::{EmulatorClient, EmulatorCommand, SimId};
pub struct GdbServer {
sim_id: SimId,
client: EmulatorClient,
status: Arc<Mutex<GdbServerStatus>>,
killer: Option<oneshot::Sender<()>>,
}
impl GdbServer {
pub fn new(sim_id: SimId, client: EmulatorClient) -> Self {
Self {
sim_id,
client,
status: Arc::new(Mutex::new(GdbServerStatus::Stopped)),
killer: None,
}
}
pub fn status(&self) -> GdbServerStatus {
self.status.lock().unwrap().clone()
}
pub fn start(&mut self, port: u16) {
let sim_id = self.sim_id;
let client = self.client.clone();
let status = self.status.clone();
let (tx, rx) = oneshot::channel();
self.killer = Some(tx);
thread::spawn(move || {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(async move {
select! {
_ = run_server(sim_id, client, port, &status) => {}
_ = rx => {
*status.lock().unwrap() = GdbServerStatus::Stopped;
}
}
})
});
}
pub fn stop(&mut self) {
if let Some(killer) = self.killer.take() {
let _ = killer.send(());
}
}
}
async fn run_server(
sim_id: SimId,
client: EmulatorClient,
port: u16,
status: &Mutex<GdbServerStatus>,
) {
client.send_command(EmulatorCommand::Pause);
let Some(stream) = try_connect(port, status).await else {
return;
};
let connection = GdbConnection::new(sim_id, client, stream);
match connection.run().await {
Ok(()) => {
*status.lock().unwrap() = GdbServerStatus::Stopped;
}
Err(error) => {
*status.lock().unwrap() = GdbServerStatus::Error(error.to_string());
}
}
}
async fn try_connect(port: u16, status: &Mutex<GdbServerStatus>) -> Option<TcpStream> {
*status.lock().unwrap() = GdbServerStatus::Connecting;
let listener = match TcpListener::bind(("127.0.0.1", port)).await {
Ok(l) => l,
Err(err) => {
*status.lock().unwrap() = GdbServerStatus::Error(err.to_string());
return None;
}
};
match listener.accept().await {
Ok((stream, _)) => {
*status.lock().unwrap() = GdbServerStatus::Running;
Some(stream)
}
Err(err) => {
*status.lock().unwrap() = GdbServerStatus::Error(err.to_string());
None
}
}
}
#[derive(Clone)]
pub enum GdbServerStatus {
Stopped,
Connecting,
Running,
Error(String),
}
impl GdbServerStatus {
pub fn running(&self) -> bool {
matches!(self, Self::Connecting | Self::Running)
}
}
struct GdbConnection {
sim_id: SimId,
client: EmulatorClient,
stream_in: BufReader<OwnedReadHalf>,
stream_out: BufWriter<OwnedWriteHalf>,
ack_messages: bool,
}
impl GdbConnection {
fn new(sim_id: SimId, client: EmulatorClient, stream: TcpStream) -> Self {
let (rx, tx) = stream.into_split();
Self {
sim_id,
client,
stream_in: BufReader::new(rx),
stream_out: BufWriter::new(tx),
ack_messages: true,
}
}
async fn run(mut self) -> Result<()> {
println!("Connected for {}", self.sim_id);
self.client.send_command(EmulatorCommand::Resume);
loop {
let message = self.read_message().await?;
println!("received {:?}", message);
let mut res = ResponseWriter::new(&mut self.stream_out);
res.init(self.ack_messages).await?;
let body = match &message {
Message::String(str) => str.as_str(),
Message::Signal => {
// TODO: handle this
res.send().await?;
continue;
}
};
if body == "QStartNoAckMode" {
self.ack_messages = false;
res.send_ok().await?;
} else if body.starts_with("qSupported:") {
res.write_str("multiprocess+;swbreak+;vContSupported+")
.await?;
res.send().await?;
} else if body == "QThreadSuffixSupported" || body == "QListThreadsInStopReply" {
res.send_ok().await?;
} else if body == "qHostInfo" || body == "qProcessInfo" {
res.write_str(&format!(
"triple:{};endian:little;ptrsize:4;",
hex::encode("v810-unknown-vb")
))
.await?;
res.send().await?;
} else if body == "k" {
return Ok(());
} else {
// unrecognized command
res.send().await?;
}
}
}
async fn read_message(&mut self) -> Result<Message> {
let mut char = self.read_byte().await?;
while char == b'+' {
// just ignore positive acks
char = self.read_byte().await?;
}
if char == b'-' {
bail!("no support for negative acks");
}
if char == 0x03 {
// This is how the client "cancels an in-flight request"
return Ok(Message::Signal);
}
if char != b'$' {
// Messages are supposed to start with a dollar sign
bail!("malformed message");
}
// now read the body
let mut checksum = 0u8;
let mut body = vec![];
char = self.read_byte().await?;
while char != b'#' {
if char == b'}' {
// escape character
checksum = checksum.wrapping_add(char);
char = self.read_byte().await?;
checksum = checksum.wrapping_add(char);
body.push(char ^ 0x20);
} else {
checksum = checksum.wrapping_add(char);
body.push(char);
}
char = self.read_byte().await?;
}
let mut checksum_bytes = [b'0'; 2];
self.stream_in.read_exact(&mut checksum_bytes).await?;
let checksum_str = std::str::from_utf8(&checksum_bytes)?;
let real_checksum = u8::from_str_radix(checksum_str, 16)?;
if checksum != real_checksum {
bail!("invalid checksum");
}
let string = String::from_utf8(body)?;
Ok(Message::String(string))
}
async fn read_byte(&mut self) -> std::io::Result<u8> {
self.stream_in.read_u8().await
}
}
struct ResponseWriter<'a> {
inner: &'a mut BufWriter<OwnedWriteHalf>,
checksum: u8,
}
impl<'a> ResponseWriter<'a> {
fn new(inner: &'a mut BufWriter<OwnedWriteHalf>) -> Self {
Self { inner, checksum: 0 }
}
async fn init(&mut self, ack: bool) -> std::io::Result<()> {
if ack {
self.inner.write_u8(b'+').await?;
}
self.inner.write_u8(b'$').await
}
async fn write_str(&mut self, str: &str) -> std::io::Result<()> {
for byte in str.bytes() {
self.checksum = self.checksum.wrapping_add(byte);
}
self.inner.write_all(str.as_bytes()).await
}
async fn write_hex_u8(&mut self, value: u8) -> std::io::Result<()> {
for digit in [(value >> 4), (value & 0xf)] {
let char = if digit > 9 {
b'a' + digit - 10
} else {
b'0' + digit
};
self.checksum = self.checksum.wrapping_add(char);
self.inner.write_u8(char).await?;
}
Ok(())
}
async fn send_ok(mut self) -> std::io::Result<()> {
self.write_str("OK").await?;
self.send().await
}
async fn send(mut self) -> std::io::Result<()> {
let final_checksum = self.checksum;
self.inner.write_u8(b'#').await?;
self.write_hex_u8(final_checksum).await?;
println!("{:?}", std::str::from_utf8(self.inner.buffer()));
self.inner.flush().await
}
}
#[derive(Debug)]
enum Message {
String(String),
Signal,
}
+1
View File
@@ -14,6 +14,7 @@ mod app;
mod audio;
mod controller;
mod emulator;
mod gdbserver;
mod graphics;
mod input;
mod persistence;
+2
View File
@@ -1,12 +1,14 @@
pub use about::AboutWindow;
use egui::{Context, ViewportBuilder, ViewportId};
pub use game::GameWindow;
pub use gdb::GdbServerWindow;
pub use input::InputWindow;
use winit::event::KeyEvent;
mod about;
mod game;
mod game_screen;
mod gdb;
mod input;
pub trait AppWindow {
+8
View File
@@ -121,6 +121,14 @@ impl GameWindow {
}
}
});
ui.menu_button("Tools", |ui| {
if ui.button("GDB Server").clicked() {
self.proxy
.send_event(UserEvent::OpenDebugger(self.sim_id))
.unwrap();
ui.close_menu();
}
});
ui.menu_button("About", |ui| {
self.proxy.send_event(UserEvent::OpenAbout).unwrap();
ui.close_menu();
+3 -3
View File
@@ -63,13 +63,13 @@ impl GameScreen {
layout: Some(&render_pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: "vs_main",
entry_point: Some("vs_main"),
buffers: &[],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point,
entry_point: Some(entry_point),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Bgra8Unorm,
blend: Some(wgpu::BlendState::REPLACE),
@@ -235,7 +235,7 @@ impl egui_wgpu::CallbackTrait for GameScreenCallback {
.get(&self.display_mode)
.unwrap_or_else(|| panic!("Unrecognized display mode {:?}", self.display_mode));
render_pass.set_pipeline(pipeline);
render_pass.set_bind_group(0, &self.bind_group, &[]);
render_pass.set_bind_group(0, Some(self.bind_group.as_ref()), &[]);
render_pass.set_viewport(x, y, w, h, 0.0, 1.0);
render_pass.draw(0..6, 0..1);
}
+80
View File
@@ -0,0 +1,80 @@
use egui::{Button, CentralPanel, TextEdit, ViewportBuilder, ViewportId};
use crate::{
emulator::{EmulatorClient, SimId},
gdbserver::{GdbServer, GdbServerStatus},
};
use super::AppWindow;
pub struct GdbServerWindow {
sim_id: SimId,
port_str: String,
server: GdbServer,
}
impl GdbServerWindow {
pub fn new(sim_id: SimId, client: EmulatorClient) -> Self {
Self {
sim_id,
port_str: (8080 + sim_id.to_index()).to_string(),
server: GdbServer::new(sim_id, client),
}
}
}
impl AppWindow for GdbServerWindow {
fn viewport_id(&self) -> ViewportId {
ViewportId::from_hash_of(format!("Debugger-{}", self.sim_id))
}
fn initial_viewport(&self) -> ViewportBuilder {
ViewportBuilder::default()
.with_title(format!("GDB Server ({})", self.sim_id))
.with_inner_size((300.0, 200.0))
}
fn show(&mut self, ctx: &egui::Context) {
let port_num: Option<u16> = self.port_str.parse().ok();
let status = self.server.status();
CentralPanel::default().show(ctx, |ui| {
ui.horizontal(|ui| {
if port_num.is_none() {
let style = ui.style_mut();
let error = style.visuals.error_fg_color;
style.visuals.widgets.active.bg_stroke.color = error;
style.visuals.widgets.hovered.bg_stroke.color = error;
}
ui.label("Port");
let port_editor = TextEdit::singleline(&mut self.port_str).desired_width(100.0);
ui.add_enabled(!status.running(), port_editor);
});
if !status.running() {
let start_button = Button::new("Start");
if ui.add_enabled(port_num.is_some(), start_button).clicked() {
let port = port_num.unwrap();
self.server.start(port);
}
} else {
let stop_button = Button::new("Stop");
if ui.add(stop_button).clicked() {
self.server.stop();
}
}
match &status {
GdbServerStatus::Stopped => {}
GdbServerStatus::Connecting => {
ui.label("Connecting...");
}
GdbServerStatus::Running => {
ui.label("Running");
}
GdbServerStatus::Error(message) => {
ui.label(message);
}
}
});
}
}