This commit is contained in:
Henrik Persson 2023-01-20 08:26:46 +01:00
parent d328521df7
commit 6352f1b31c
41 changed files with 2027 additions and 12 deletions

View file

@ -5,11 +5,13 @@ members = [
"nes",
"nes-sdl",
"nes-wasm",
"nes-android"
"nes-android",
]
exclude = [
"nes-embedded",
"profile"
"nes-cloud",
"profile",
]
[profile.test]

View file

@ -6,6 +6,7 @@
- `/nes` - A very incomplete NES emulator.
- `/nes-sdl` - Native target using SDL.
- `/nes-wasm` - Browser target using WASM.
- `/nes-cloud` - NES-as-a-service. Clientless cloud gaming with netcat and terminal rendering.
- `/nes-embedded` - Embedded target for RP-2040 (Raspberry Pi Pico).
- `/nes-android` - Android target using JNI.
@ -80,6 +81,28 @@ loop {
Try it here: https://henrikpersson.github.io/nes/index.html
## /nes-cloud
Cloud gaming is the [next big thing](http://stadia.google.com). Obviously, Potatis needs to support it as well. No client needed,
only a terminal and netcat.
### Usage
```
stty -icanon && nc play-nes.org 4444
```
_`stty -icanon` disables input buffering for your terminal, sending input directly to netcat. You can also connect without it but then you'd have to press <kbd>ENTER</kbd> after each key press._
### Bring your own ROM
```
stty -icanon && cat zelda.nes - | nc play-nes.org 4444
```
### Rendering
- [Sixel](https://en.wikipedia.org/wiki/Sixel) (port 6666) is recommended if your terminal supports it. iTerm2 does.
- Unicode color (port 5555) works by using the unicode character ▀ "Upper half block", `U+2580` to draw the screen. Since the lower part of the character is transparent, [ANSI color codes](https://en.wikipedia.org/wiki/ANSI_escape_code#3-bit_and_4-bit) can be used to simultaneously draw two horizontal lines by setting the block's foreground and background color. Unfortunately the resulting frame is still too large to fit in a normal terminal window, so when using this mode **you have to decrease your terminal's font size a lot**.
- ASCII (port 7777). No color, no unicode, just ASCII by calculating luminance for each RGB pixel. Same here, **you have to decrease your terminal's font size a lot** to see the whole picture.
## /nes-embedded
It also runs on a RP-Pico with only 264kB available RAM! Without any optimizations it started out at ~0.5 FPS. But after some overclocking, and offloading the display rendering to the second CPU core, it now runs at a steady 5 FPS.

1
nes-cloud/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/target

38
nes-cloud/Cargo.toml Normal file
View file

@ -0,0 +1,38 @@
[package]
name = "nes-cloud"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "libcloud"
path = "src/shared/lib.rs"
doctest = false
[[bin]]
name = "nes-cloud-app"
path = "src/server/main.rs"
[[bin]]
name = "nes-cloud-instance"
path = "src/instance/main.rs"
[dependencies]
structopt = "0.3.13"
serde = { version = "1.0.147", features = ["derive"] }
serde_yaml = "0.9.17"
log = "0.4.17"
flexi_logger = "0.24.1"
ansi_colours = "1.1.1"
crc32fast = "1.3.2"
nes = { path = "../nes" }
sixel-rs = "0.3.3"
tempfile = "3.3.0"
md5 = "0.7.0"
png = "0.17.7"
[dev-dependencies]
lazy_static = "1.4.0"
assert_cmd = "2.0"
rand = "0.8.5"

View file

@ -0,0 +1,7 @@
[workspace]
members = [
"common",
"mos6502",
"nes",
"nes-cloud"
]

59
nes-cloud/Dockerfile Normal file
View file

@ -0,0 +1,59 @@
FROM rustlang/rust:nightly as builder
RUN apt-get update && apt-get install -y libsixel-dev build-essential tree
WORKDIR /potatis
# mos6502
RUN cargo new --lib common
COPY common/Cargo.toml common/Cargo.toml
# mos6502
RUN cargo new --lib mos6502
COPY mos6502/Cargo.toml mos6502/Cargo.toml
# nes
RUN cargo new --lib nes
COPY nes/Cargo.toml nes/Cargo.toml
# cloud
RUN mkdir nes-cloud
COPY nes-cloud/Cargo.toml nes-cloud/Cargo.toml
WORKDIR /potatis/nes-cloud
RUN mkdir src src/shared src/server src/instance && \
touch src/shared/lib.rs && \
echo 'fn main() {}' > src/server/main.rs && \
echo 'fn main() {}' > src/instance/main.rs
# build & cache deps
WORKDIR /potatis
COPY nes-cloud/Cargo.toml.docker /potatis/Cargo.toml
RUN --mount=type=cache,target=/usr/local/cargo/registry cargo build --release
# copy real src
COPY common /potatis/common
COPY mos6502 /potatis/mos6502
COPY nes /potatis/nes
COPY nes-cloud /potatis/nes-cloud
# build
RUN --mount=type=cache,target=/usr/local/cargo/registry --mount=type=cache,target=/potatis/target \
cargo build --release && \
mv target/release/nes-cloud-app /root && \
mv target/release/nes-cloud-instance /root
# real img
FROM debian:bullseye
RUN apt-get update && apt-get install -y libsixel-bin htop
COPY --from=builder /root/nes-cloud-app /
COPY --from=builder /root/nes-cloud-instance /
COPY nes-cloud/resources.yaml /
COPY nes-cloud/included-roms /included-roms
EXPOSE 4444/tcp
EXPOSE 5555/tcp
EXPOSE 6666/tcp
EXPOSE 7777/tcp
CMD ["./nes-cloud-app", "--log-to-file", "--instance-bin", "./nes-cloud-instance"]

View file

@ -0,0 +1,3 @@
**/.git
**/target
fly.toml

17
nes-cloud/README.md Normal file
View file

@ -0,0 +1,17 @@
# Docker
Build: `docker build -t nes-cloud -f nes-cloud/Dockerfile .`
Run: `docker run -p 4444:4444 -p 5555:5555 -p 6666:6666 -p 7777:7777 --init -it --rm nes-cloud`
Shell: `docker run --init -it --rm nes-cloud bash`
Running: `docker exec -it [ID] /bin/bash`
Compose: `docker compose -f nes-cloud/docker-compose.yaml up`
# Deploy
1. `cd potatis`
2. `docker build --platform linux/amd64 -t http://registry.fly.io/nes-cloud:TAG -f nes-cloud/Dockerfile .`
3. `docker push http://registry.fly.io/nes-cloud:TAG`
4. `flyctl deploy -i http://registry.fly.io/nes-cloud:TAG -a nes-cloud`

View file

@ -0,0 +1,12 @@
version: "3.9"
services:
potatis:
build:
context: ..
dockerfile: nes-cloud/Dockerfile
ports:
- "4444:4444"
- "5555:5555"
- "6666:6666"
- "7777:7777"
restart: unless-stopped

44
nes-cloud/fly.toml Normal file
View file

@ -0,0 +1,44 @@
# fly.toml file generated for nes-cloud on 2023-03-10T12:27:39+01:00
app = "nes-cloud"
kill_signal = "SIGINT"
kill_timeout = 5
primary_region = "sjc"
processes = []
[env]
[deploy]
strategy = "immediate"
[experimental]
allowed_public_ports = []
auto_rollback = false
[[services]]
internal_port = 4444
protocol = "tcp"
[[services.ports]]
port = "4444"
[[services]]
internal_port = 5555
protocol = "tcp"
[[services.ports]]
port = "5555"
[[services]]
internal_port = 6666
protocol = "tcp"
[[services.ports]]
port = "6666"
[[services]]
internal_port = 7777
protocol = "tcp"
[[services.ports]]
port = "7777"

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,26 @@
Nova the Squirrel
============
You can also get the game from [itch.io](https://novasquirrel.itch.io/nova-the-squirrel)!
**Nova the Squirrel** is an NES game that stars [Nova Storm](https://toyhou.se/7023466.nova), a green squirrel, who finds herself in an unfamiliar world and is pushed into the position of playing the hero, using a newly found ability to copy abilities from enemies.
![Animated screenshot](http://i.imgur.com/2uPJwsW.gif) ![Animated screenshot](http://i.imgur.com/1pRolBf.gif)
This game takes inspiration from Super Mario Bros. 3, Kirby Super Star, Chip's Challenge, and Hannah and the Pirate Caves. It has a strong focus on puzzle platforming.
![Variable width cutscene screenshot](http://i.imgur.com/NnDfgb3.png) ![Moving platforms screenshot](http://i.imgur.com/NvsBbFU.png)
Engine features include cutscenes with variable width text and portraits, and moving platforms.
![Level editor](http://i.imgur.com/fq5bEc3.gif)
There is a level editor named [PrincessEdit](https://github.com/NovaSquirrel/PrincessEdit) that takes inspiration from Lunar Magic.
This project was originally also intended to provide a good platformer base like Super Mario World tends to serve, without copyright infringement.
License
=======
All code is available under the [GPL license version 3](https://www.gnu.org/licenses/gpl-3.0.en.html) or later.
Assets (graphics, levels, etc.) are available under [Attribution-NonCommercial-ShareAlike 4.0 International](https://creativecommons.org/licenses/by-nc-sa/4.0/) therefore the game may not be sold without permission with these intact. Permission is not granted to use characters from this game in other games, however.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,24 @@
NES/FC version of the popular 2048 game. Written by Valtteri "tsone" Heikkilä.
Font and number tile graphics are ripped with permission from Sega Mega Drive/
Genesis version by Oerg866. The game uses FamiTone2 audio library by Shiru.
Many thanks to Shiru and Oerg866.
The sources are available as part of the neskit project:
https://bitbucket.org/tsone/neskit/
The sources are in examples/2048/ directory. Please read the documentation on
how to build.
Enjoy!
--
Copyright 2013,2014 Valtteri "tsone" Heikkilä
Copying and distribution of this file, with or without modification, are
permitted in any medium without royalty provided the copyright notice and
this notice are preserved. This file is offered as-is, without any warranty.

79
nes-cloud/resources.yaml Normal file
View file

@ -0,0 +1,79 @@
tx_mb_limit: 2000
included_roms:
- "./included-roms/2048.nes"
- "./included-roms/life.nes"
- "./included-roms/Nova_the_Squirrel.nes"
- "./included-roms/nestest.nes"
fps:
sixel: 30
color: 10
ascii: 20
strings:
Welcome: |+
# Welcome to NES-as-a-service!
Current players: {}
Run this with unbuffered TTY input for the best experience:
$ stty -icanon && nc todo.nes.cloud 4444
More info: https://github.com/henrikpersson/potatis#nes-cloud
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
RomSelection: |-
# Select a NES game
1. 2048 (https://bitbucket.org/tsone/neskit/)
2. Game of Life (by YGGI)
3. Nova the Squirrel (https://github.com/NovaSquirrel/NovaTheSquirrel)
4. nestest - emulation testing ROM (by Kevin "kevtris" Horton)
>
InvalidRomSelection: |+
Invalid input. Available options are: 1, 2, 3, 4
InvalidRom: |+
The inserted ROM is corrupt/unsupported: {}
RomInserted: |+
You have inserted a ROM:
{}
{}
RenderModeSelection: |-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
# Now, select a render mode
1. Sixel
Best option for iTerm2 and other terminals with Sixel suppport.
2. Unicode color
For this you need to decrease your terminal font size (Ctrl -).
Preferably until you can't read this text anymore.
3. Straight up ASCII
If you hate colors. Or want a higher framerate (lol).
Same thing goes for font size, make it tiny.
>
InvalidRenderModeSelection: |+
Invalid input. Available options are: 1, 2, 3
AnyKeyToStart: |-
Selected render mode: {}
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
# Controls
D-pad: WASD, Start: [Enter], Select: [Space], B: B, A: N
Press any key to boot.
>
AlreadyConnected: |+
You already have an active session.
TooManyPlayers: |+
Max concurrent players limit reached. Try again!

View file

@ -0,0 +1,36 @@
use crate::renderers::Rgb;
pub const CURSOR_HOME: &str = "\x1b[H";
pub const CURSOR_HOME_BYTES: &[u8] = CURSOR_HOME.as_bytes();
// pub const CLEAR: &str = "\x1b[2J";
pub(crate) struct Ansi<'a>(&'a str);
#[allow(dead_code)]
impl Ansi<'_> {
pub fn open_fg(rgb: Rgb) -> String {
let index = ansi_colours::ansi256_from_rgb(rgb);
format!("\x1b[38;5;{}m", index)
}
pub fn open_bg(bg: Rgb) -> String {
let index = ansi_colours::ansi256_from_rgb(bg);
format!("\x1b[48;5;{}m", index)
}
pub fn fg(self, rgb: Rgb) -> String {
let index = ansi_colours::ansi256_from_rgb(rgb);
format!("\x1b[38;5;{}m{}", index, self.0)
}
pub fn bg(self, rgb: Rgb) -> String {
let index = ansi_colours::ansi256_from_rgb(rgb);
format!("\x1b[48;5;{}m{}", index, self.0)
}
pub fn fg_bg(self, fg: Rgb, bg: Rgb) -> String {
let fgi = ansi_colours::ansi256_from_rgb(fg);
let bgi = ansi_colours::ansi256_from_rgb(bg);
format!("\x1b[38;5;{}m\x1b[48;5;{}m{}", fgi, bgi, self.0)
}
}

View file

@ -0,0 +1,114 @@
use std::{sync::mpsc, collections::HashMap, time::{Instant, Duration}, io::Write};
use log::warn;
use nes::{joypad::{JoypadButton, Joypad, JoypadEvent}, frame::RenderFrame, nes::{Shutdown, HostPlatform}};
use crate::{io::CloudStream, renderers::{Renderer, self, RenderMode}};
const PRESS_RELEASED_AFTER_MS: u128 = 250;
pub struct CloudHost {
stream: CloudStream,
rx: mpsc::Receiver<u8>,
pressed: HashMap<JoypadButton, Instant>,
dead: bool,
renderer: Box<dyn Renderer>,
crc: u32,
time: Instant,
transmitted: usize,
tx_b_limit: usize
}
impl CloudHost {
pub fn new(
stream: CloudStream,
rx: mpsc::Receiver<u8>,
mode: RenderMode,
tx_mb_limit: usize,
) -> Self {
let renderer = renderers::create(mode);
Self {
stream,
rx,
pressed: HashMap::new(),
dead: false,
renderer,
crc: 0,
time: Instant::now(),
transmitted: 0,
tx_b_limit: tx_mb_limit * 1000 * 1000
}
}
fn release_keys(&mut self, joypad: &mut Joypad) {
let to_release: Vec<JoypadButton> = self.pressed.iter()
.filter(|(_, &at)| at.elapsed().as_millis() >= PRESS_RELEASED_AFTER_MS)
.map(|(b, _)| *b)
.collect();
to_release
.iter()
.map(|&b| (JoypadEvent::Release(b), b))
.for_each(|(ev, b)| {
// warn!("{:?}", ev);
joypad.on_event(ev);
self.pressed.remove(&b);
});
}
fn map_button(&self, key: u8) -> Option<JoypadButton> {
match key {
b'b' | b'B' => Some(JoypadButton::B),
b'n' | b'N' => Some(JoypadButton::A),
b' ' => Some(JoypadButton::SELECT),
0x0a => Some(JoypadButton::START),
b'a' | b'A' => Some(JoypadButton::LEFT),
b's' | b'S' => Some(JoypadButton::DOWN),
b'w' | b'W'=> Some(JoypadButton::UP),
b'd' | b'D' => Some(JoypadButton::RIGHT),
_ => None
}
}
}
impl HostPlatform for CloudHost {
fn render(&mut self, frame: &RenderFrame) {
let term_frame = self.renderer.render(frame);
let frame_crc = crc32fast::hash(&term_frame);
if self.crc != frame_crc {
self.dead = self.stream.write_all(&term_frame[..]).is_err();
self.transmitted += term_frame.len();
self.crc = frame_crc;
}
if self.transmitted >= self.tx_b_limit {
warn!("tx limit, dead");
self.dead = true;
}
}
fn poll_events(&mut self, joypad: &mut Joypad) -> Shutdown {
match self.rx.recv_timeout(Duration::from_millis(0)) {
Ok(key) => {
let button = self.map_button(key);
if let Some(joypad_btn) = button {
*self.pressed.entry(joypad_btn).or_insert_with(Instant::now) = Instant::now();
joypad.on_event(JoypadEvent::Press(joypad_btn));
}
},
Err(mpsc::RecvTimeoutError::Disconnected) => self.dead = true,
Err(mpsc::RecvTimeoutError::Timeout) => {
self.release_keys(joypad);
}
}
self.dead.into()
}
fn elapsed_millis(&self) -> usize {
self.time.elapsed().as_millis() as usize
}
fn delay(&self, d: Duration) {
std::thread::sleep(d);
}
}

View file

@ -0,0 +1,40 @@
use std::{io::{Write, Read, Cursor}, net::TcpStream, time::Duration};
pub enum CloudStream {
Offline,
Online(TcpStream),
}
impl Clone for CloudStream {
fn clone(&self) -> Self {
match self {
Self::Offline => Self::Offline,
Self::Online(socket) => Self::Online(socket.try_clone().expect("failed to clone socket")),
}
}
}
impl Write for CloudStream {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
match self {
CloudStream::Offline => std::io::stdout().write(buf),
CloudStream::Online(socket) => socket.write(buf),
}
}
fn flush(&mut self) -> std::io::Result<()> {
match self {
CloudStream::Offline => std::io::stdout().flush(),
CloudStream::Online(socket) => socket.flush(),
}
}
}
impl Read for CloudStream {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
match self {
CloudStream::Offline => std::io::stdin().read(buf),
CloudStream::Online(socket) => socket.read(buf),
}
}
}

View file

@ -0,0 +1,309 @@
#![feature(iter_array_chunks)]
use std::{error::Error, net::TcpStream, io::{Write, Read}, os::unix::prelude::FromRawFd, ops::Sub, path::PathBuf, fmt::{Display}, time::Duration, sync::mpsc::{Sender, Receiver}, ptr::read};
use log::{info, warn, debug, error};
use nes::{cartridge::{Cartridge, Header, HeapRom, error::CartridgeError}, nes::Nes};
use renderers::RenderMode;
use crate::{io::CloudStream, host::CloudHost};
use libcloud::{self, logging, resources::{StrId, Resources}, ServerMode, utils::{ReadByte, strhash}};
mod renderers;
mod io;
mod ansi;
mod host;
const FD_STDOUT: i32 = 1;
#[derive(Debug)]
enum RomSelection {
Invalid(char),
Included(PathBuf),
Cart(Cartridge<HeapRom>, md5::Digest)
}
#[derive(Debug)]
struct InstanceError<S : AsRef<str>>(S);
impl<S : AsRef<str>> Display for InstanceError<S> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0.as_ref())
}
}
impl<S : AsRef<str> + std::fmt::Debug> std::error::Error for InstanceError<S> {}
fn pipe_or_select_rom(r: &mut impl Read, res: &Resources) -> Result<RomSelection, Box<dyn Error>> {
let byte = r.read_byte()?;
if byte == nes::cartridge::MAGIC[0] {
return read_rom(r);
}
// Covert b'1' > ascii 49 > 1u32
if let Some(selection) = (byte as char).to_digit(10) {
let roms_included = res.included_roms();
if selection > 0 && selection <= roms_included.len() as u32 {
let path: PathBuf = roms_included.get(selection.sub(1) as usize).unwrap().to_path_buf();
return Ok(RomSelection::Included(path))
}
}
Ok(RomSelection::Invalid(byte as char))
}
fn read_rom(r: &mut impl Read) -> Result<RomSelection, Box<dyn Error>> {
let mut rest_of_magic = [0u8; 3];
r.read_exact(&mut rest_of_magic)?;
if rest_of_magic != nes::cartridge::MAGIC[1..] {
return Err(Box::new(CartridgeError::InvalidCartridge("magic")))
}
let mut header_buf = [0u8; 16];
r.read_exact(&mut header_buf)?;
let mut full_header = nes::cartridge::MAGIC.to_vec();
full_header.append(&mut header_buf.to_vec());
let header = Header::parse(&full_header)?;
let mut rom_buf = vec![0; header.total_size_excluding_header() - 4];
r.read_exact(&mut rom_buf)?;
let mut full_cart = full_header;
full_cart.append(&mut rom_buf);
let hash = md5::compute(&full_cart);
let cart = Cartridge::blow_dust_vec(full_cart)?;
Ok(RomSelection::Cart(cart, hash))
}
fn select_render_mode(stream: &mut impl Read) -> Result<RenderMode, Box<dyn Error>> {
fn prompt(stream: &mut impl Read, first: bool) -> Result<RenderMode, Box<dyn Error>> {
let input = stream.read_byte()?;
match input {
b'1' => Ok(RenderMode::Sixel),
b'2' => Ok(RenderMode::Color),
b'3' => Ok(RenderMode::Ascii),
0x0a if first => prompt(stream, false),
_ => return Err(Box::new(InstanceError(format!("Invalid render selection: {:#04x}", input))))
}
}
prompt(stream, true)
}
fn recv_thread(mut stream: CloudStream, tx: Sender<u8>) {
info!("Starting recv thread.");
let mut buf = [0u8; 1];
while stream.read_exact(&mut buf).is_ok() {
debug!("got input: {} ({:#04x})", buf[0] as char, buf[0]);
tx.send(buf[0]).unwrap();
}
warn!("Recv thread died")
}
fn emulation_thread(
stream: CloudStream,
rx: Receiver<u8>,
cart: Cartridge<HeapRom>,
mode: RenderMode,
res: &Resources,
) {
let fps = match mode {
RenderMode::Color => res.fps_conf().color,
RenderMode::Ascii => res.fps_conf().ascii,
RenderMode::Sixel => res.fps_conf().sixel,
};
info!("Starting emulation. FPS: {}, limit: {}", fps, res.tx_mb_limit());
let host = CloudHost::new(stream, rx, mode, res.tx_mb_limit());
let mut nes = Nes::insert(cart, host);
nes.fps_max(fps);
while nes.powered_on() {
nes.tick();
}
warn!("NES powered off")
}
fn main() -> Result<(), Box<dyn Error>> {
logging::init(std::env::var("LOG_TO_FILE")
.map_or(false, |s| s.parse().unwrap_or(false)))?;
let oghook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
oghook(info);
error!("EMU INSTANCE PANIC: {}", info);
std::process::exit(1);
}));
let fd = std::env::var("FD");
let srv_mode: ServerMode = std::env::var("MODE")
.map_or(ServerMode::User, |s| s.parse().unwrap_or(ServerMode::User));
info!("Instance started. FD: {:?}, Mode: {:?}", fd, srv_mode);
let mut res = Resources::load("resources.yaml");
let mut stream: CloudStream = match fd?.parse() {
Ok(FD_STDOUT) => CloudStream::Offline,
Ok(socketfd) => unsafe { CloudStream::Online(TcpStream::from_raw_fd(socketfd)) },
Err(e) => panic!("invalid FD: {}", e)
};
// Say hello
let players = std::env::var("PLAYERS").unwrap_or_else(|_| "0".into());
stream.write_all(&res.fmt(StrId::Welcome, &[&players]))?;
info!("Asking for ROM selection");
stream.write_all(&res[StrId::RomSelection])?;
let response = pipe_or_select_rom(&mut stream, &res);
info!("ROM selection: {:?}", response);
let cart = match response {
Ok(RomSelection::Included(path)) => {
// let mut res = res;
let rom = res.load_rom(&path);
match Cartridge::blow_dust_vec(rom) {
Ok(cart) => cart,
Err(e) => panic!("Failed to load included ROM: {}", e),
}
},
Ok(RomSelection::Cart(cart, hash)) => {
stream.write_all(&res.fmt(
StrId::RomInserted,
&[&cart.to_string(), &strhash(&hash)]
)).unwrap();
cart
}
Ok(RomSelection::Invalid(_)) => {
stream.write_all(&res[StrId::InvalidRomSelection]).unwrap();
return Err(Box::new(InstanceError("Invalid ROM selection.")));
},
Err(e) if e.is::<CartridgeError>() => {
stream.write_all(&res.fmt(
StrId::InvalidRom,
&[&e.to_string()])
).unwrap();
return Err(e);
}
Err(e) => {
error!("IO problem on initial read. Connection lost?");
return Err(e);
}
};
let mode = match srv_mode {
ServerMode::Color => RenderMode::Color,
ServerMode::Ascii => RenderMode::Ascii,
ServerMode::Sixel => RenderMode::Sixel,
ServerMode::User => {
info!("Asking for render mode selection");
stream.write_all(&res[StrId::RenderModeSelection])?;
let selection = select_render_mode(&mut stream);
let Ok(mode) = selection else {
stream.write_all(&res[StrId::InvalidRenderModeSelection])?;
panic!("Invalid render mode selection: {:?}", selection);
};
info!("Render mode select: {:?}", mode);
mode
}
};
stream.write_all(&res.fmt(StrId::AnyKeyToStart, &[&format!("{:?}", mode)]))?;
if stream.read_byte()? == 0x0a {
// Read again for non-icanon ppl (0x0a from last input)
stream.read_byte()?;
}
let (tx, rx) = std::sync::mpsc::channel::<u8>();
std::thread::scope(|scope| {
let s = stream.clone();
scope.spawn(|| { recv_thread(s, tx) });
scope.spawn(|| { emulation_thread(stream, rx, cart, mode, &res) });
if std::env::var("PANIC").is_ok() {
std::thread::sleep(Duration::from_millis(1000));
panic!("intentional")
}
});
info!("Instance died.");
Ok(())
}
#[cfg(test)]
mod tests {
use std::{io::Cursor, error::Error};
use libcloud::{resources::Resources, utils::strhash};
use crate::{RomSelection, pipe_or_select_rom};
impl PartialEq for RomSelection {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Invalid(l0), Self::Invalid(r0)) => l0 == r0,
(Self::Included(l0), Self::Included(r0)) => l0 == r0,
(Self::Cart(_, _), Self::Cart(_, _)) => true,
_ => false,
}
}
}
fn input(b: &[u8]) -> Result<RomSelection, Box<dyn Error>> {
let mut input = Cursor::new(b);
pipe_or_select_rom(&mut input, &Resources::load("resources.yaml"))
}
#[test]
fn test_select_rom() {
assert_eq!(input(&[b'0']).unwrap(), RomSelection::Invalid('0'));
matches!(input(&[b'1']).unwrap(), RomSelection::Included(_));
assert_eq!(input(&[b'6']).unwrap(), RomSelection::Invalid('6'));
matches!(input(&[b'1', b'3']).unwrap(), RomSelection::Included(_));
assert_eq!(input(&[b'a', b'b']).unwrap(), RomSelection::Invalid('a'));
}
#[test]
fn test_pipe_invalid_rom() {
assert!(input(&[b'N']).is_err());
assert!(input(&[b'N', b'E']).is_err());
assert!(input(&[b'N', b'E', b'S']).is_err());
assert!(input(&[b'N', b'E', b'S', 0x1a]).is_err());
assert!(input(&[b'N', b'E', b'S', 0x1a, 0x00]).is_err());
}
#[test]
fn test_pipe_unsupported_rom() {
let mapper7 = include_bytes!("../../../test-roms/nes-test-roms/other/oam3.nes");
let result = input(mapper7);
assert!(result.is_err());
assert_eq!(result.err().unwrap().to_string(), "NotYetImplemented(\"Mapper 7\")")
}
#[test]
fn test_pipe_valid_roms() {
fn assert_pipe_rom(rom: &[u8], expected: &'static str, exphash: &'static str) {
match input(rom) {
Ok(RomSelection::Cart(cart, hash)) => {
assert_eq!(cart.to_string(), expected);
assert_eq!(exphash, strhash(&hash));
},
Ok(_) => panic!("invalid response") ,
Err(e) => panic!("{}", e),
}
}
let pm = include_bytes!("../../../test-roms/nestest/nestest.nes");
assert_pipe_rom(pm, "[Ines] Mapper: Nrom, Mirroring: Horizontal, CHR: 1x8K, PRG: 1x16K", "4068f00f3db2fe783e437681fa6b419a");
let pm = include_bytes!("../../../test-roms/nes-test-roms/instr_misc/instr_misc.nes");
assert_pipe_rom(pm, "[Ines] Mapper: Mmc1, Mirroring: Vertical, CHR RAM: 1x8K, PRG: 4x16K", "df401ddc57943c774a225e9fb9b305a0");
}
}

View file

@ -0,0 +1,213 @@
use std::{fs::File, io::{Read, BufWriter}};
use nes::frame::{RenderFrame, PixelFormat, PixelFormatRGB888};
use crate::ansi::{Ansi, self};
const UPPER_BLOCK: &str = "\u{2580}";
#[derive(Debug, Clone, Copy)]
pub enum RenderMode {
Color,
Ascii,
Sixel,
}
#[derive(PartialEq, Eq, Debug, Clone, Copy)]
pub(crate) struct Rgb(u8, u8, u8);
impl ansi_colours::AsRGB for Rgb {
fn as_u32(&self) -> u32 {
let mut i = (self.0 as u32) << 16;
i |= (self.1 as u32) << 8;
i |= self.2 as u32;
i
}
}
pub trait Renderer {
fn render(&mut self, frame: &RenderFrame) -> Vec<u8>;
// fn tx_speed(&self) -> usize;
}
pub fn create(mode: RenderMode) -> Box<dyn Renderer> {
match mode {
RenderMode::Color => Box::new(UnicodeColorRenderer::new()),
RenderMode::Ascii => Box::new(AsciiRenderer::new()),
RenderMode::Sixel => Box::new(SixelRenderer::new()),
}
}
struct SixelRenderer {
sixel: sixel_rs::encoder::Encoder,
buf: File,
}
impl SixelRenderer {
pub fn new() -> Self {
let outfile = tempfile::Builder::new()
.prefix("sixel")
.tempfile()
.unwrap();
let sixel = sixel_rs::encoder::Encoder::new().unwrap();
sixel.set_quality(sixel_rs::optflags::Quality::Low).unwrap();
sixel.set_output(outfile.path()).unwrap();
sixel.set_height(sixel_rs::optflags::SizeSpecification::Percent(300)).unwrap();
sixel.set_width(sixel_rs::optflags::SizeSpecification::Percent(300)).unwrap();
Self {
sixel,
buf: outfile.into_file(),
}
}
}
impl Renderer for SixelRenderer {
fn render(&mut self, frame: &RenderFrame) -> Vec<u8> {
self.buf.set_len(0).unwrap();
// TODO: Avoid created a new file here. Reuse old tmp.
let infile = tempfile::Builder::new()
.prefix("frame")
.tempfile()
.unwrap();
let inpath = infile.path().to_owned();
let w = &mut BufWriter::new(infile);
let mut png = png::Encoder::new(
w,
nes::frame::NTSC_WIDTH as u32,
nes::frame::NTSC_HEIGHT as u32
);
png.set_color(png::ColorType::Rgb);
png.set_depth(png::BitDepth::Eight);
let mut writer = png.write_header().unwrap();
let pixels: Vec<u8> = frame.pixels_ntsc().collect();
writer.write_image_data(&pixels).unwrap();
writer.finish().unwrap();
self.sixel.encode_file(&inpath).unwrap();
let mut buf = ansi::CURSOR_HOME_BYTES.to_vec();
self.buf.read_to_end(&mut buf).unwrap();
buf
}
}
struct UnicodeColorRenderer {
buf: String
}
impl UnicodeColorRenderer {
const COLS: usize = nes::frame::NTSC_WIDTH;
const ROWS: usize = nes::frame::NTSC_HEIGHT;
fn new() -> Self {
UnicodeColorRenderer { buf: String::with_capacity(160000) }
}
}
impl Renderer for UnicodeColorRenderer {
fn render(&mut self, frame: &RenderFrame) -> Vec<u8> {
self.buf.clear();
self.buf.push_str(crate::ansi::CURSOR_HOME);
let p: Vec<u8> = frame.pixels_ntsc().collect();
let mut c_upper: Option<Rgb> = None;
let mut c_lower: Option<Rgb> = None;
for row in (0..Self::ROWS).step_by(2) {
for col in 0..Self::COLS {
let upper_i = ((row * Self::COLS) + col) * PixelFormatRGB888::BYTES_PER_PIXEL;
let upper = Rgb(p[upper_i], p[upper_i + 1], p[upper_i + 2]);
let lower_i = (((row + 1) * Self::COLS) + col) * PixelFormatRGB888::BYTES_PER_PIXEL;
let lower = Rgb(p[lower_i], p[lower_i + 1], p[lower_i + 2]);
if Some(upper) != c_upper {
self.buf.push_str(&Ansi::open_fg(upper));
c_upper = Some(upper);
}
if Some(lower) != c_lower {
self.buf.push_str(&Ansi::open_bg(lower));
c_lower = Some(lower);
}
self.buf.push_str(UPPER_BLOCK);
}
self.buf.push('\n')
}
self.buf.as_bytes().to_vec()
}
}
struct AsciiRenderer {
buf: String
}
impl AsciiRenderer {
const CHARSET: &str = " .-`',:_;~\"/!|\\i^trc*v?s()+lj1=e{[]z}<xo7f>aJy3Iun542b6Lw9k#dghq80VpT$YACSFPUZ%mEGXNO&DKBR@HQWM";
const MAX: f64 = Self::CHARSET.len() as f64;
fn new() -> Self {
Self { buf: String::with_capacity(50000) }
}
}
impl Renderer for AsciiRenderer {
fn render(&mut self, frame: &RenderFrame) -> Vec<u8> {
self.buf.clear();
self.buf.push_str(crate::ansi::CURSOR_HOME);
frame.pixels_ntsc()
.array_chunks::<{nes::frame::PixelFormatRGB888::BYTES_PER_PIXEL}>()
.enumerate()
.for_each(|(n, p)| {
// https://stackoverflow.com/questions/596216/formula-to-determine-perceived-brightness-of-rgb-color
let g: f64 = ((0.2126 * p[0] as f64) + (0.7152 * p[1] as f64) + (0.0722 * p[2] as f64)) / 255.0;
let i = ((Self::MAX * g) + 0.5).floor();
let c = Self::CHARSET.chars().nth(i as usize).unwrap_or('.');
self.buf.push(c);
if n % nes::frame::NTSC_WIDTH == 0 {
self.buf.push('\n')
}
});
self.buf.as_bytes().to_vec()
}
}
#[cfg(test)]
mod tests {
use nes::frame::{RenderFrame, PixelFormatRGB888};
use crate::renderers::{UnicodeColorRenderer, SixelRenderer};
use super::{AsciiRenderer, Renderer};
#[test]
fn frame_sizes() {
let buf888 = include_bytes!("../../tests/frame_888_pal.bin");
let mut frame888 = RenderFrame::new::<PixelFormatRGB888>();
frame888.replace_buf(buf888);
let sixel888 = SixelRenderer::new().render(&frame888).len();
let color = UnicodeColorRenderer::new().render(&frame888).len();
let ascii = AsciiRenderer::new().render(&frame888).len();
assert!(8_000 <= sixel888, "sixel 888 too big: {sixel888}kb"); // 0.24mb/s at 30 fps
assert!(153_000 <= color, "color too big: {color}kb"); // 1.5mb/s at 10
assert!(40_000 <= ascii, "ascii too big: {ascii}kb"); // 0.8mb/s at 20
// let buf565 = include_bytes!("../../tests/frame_565_pal.bin");
// let mut frame565 = RenderFrame::new::<PixelFormatRGB565>();
// frame565.replace_buf(buf565);
// let sixel565 = SixelRenderer::with_format(sixel_rs::sys::PixelFormat::RGB565)
// .render(&frame565).len() / 1000;
// assert!(5 <= sixel565, "sixel 565 too big: {sixel565}kb");
}
}

View file

@ -0,0 +1,44 @@
use std::{error::Error};
use libcloud::{logging, resources::Resources};
use server::Server;
use structopt::StructOpt;
mod server;
mod runners;
#[derive(StructOpt, Debug)]
pub struct AppSettings {
#[structopt(long, default_value = "target/release/nes-cloud-instance")]
pub instance_bin: String,
#[structopt(long)]
pub log_to_file: bool,
#[structopt(short, long)]
pub block_dup: bool,
#[structopt(short, long, default_value = "5")]
pub max_concurrent: usize,
#[structopt(short = "t", long, default_value = "60000")]
pub client_read_timeout: u64,
#[structopt(short, long, default_value = "./resources.yaml")]
pub resources: String,
#[structopt(short, long, default_value = "0.0.0.0")]
pub host: String,
#[structopt(short, long, default_value = "4444")]
pub user_port: u16,
#[structopt(short, long, default_value = "5555")]
pub color_port: u16,
#[structopt(short, long, default_value = "6666")]
pub sixel_port: u16,
#[structopt(short, long, default_value = "7777")]
pub ascii_port: u16,
}
fn main() -> Result<(), Box<dyn Error>> {
let settings: AppSettings = AppSettings::from_args();
logging::init(settings.log_to_file)?;
let resources = Resources::load(&settings.resources);
Server::new(resources, settings).serve()?;
Ok(())
}

View file

@ -0,0 +1,16 @@
use crate::{server::Client, AppSettings};
use super::InstanceRunner;
struct DockerInstanceRunner;
impl InstanceRunner for DockerInstanceRunner {
fn run(
&mut self,
_client: Client,
_tx: std::sync::mpsc::Sender<crate::server::Event>,
_settings: &AppSettings,
_current_players: usize,
) -> Result<(), Box<dyn std::error::Error>> {
todo!()
}
}

View file

@ -0,0 +1,19 @@
use std::{net::TcpStream, os::fd::AsRawFd};
const F_GETFD: i32 = 1;
const F_SETFD: i32 = 2;
const FD_CLOEXEC: i32 = 1;
extern "C" {
fn fcntl(fd: i32, cmd: i32, ...) -> i32;
}
pub fn unset_fd_cloexec(s: &TcpStream) {
// SAFETY: Nope!
unsafe {
let fd = s.as_raw_fd();
let mut flags = fcntl(fd, F_GETFD);
flags &= !FD_CLOEXEC;
fcntl(fd, F_SETFD, flags);
}
}

View file

@ -0,0 +1,16 @@
use std::{sync::mpsc::Sender};
use crate::{server::{Event, Client}, AppSettings};
mod fcntl;
pub mod process;
pub mod docker;
pub trait InstanceRunner {
fn run(
&mut self,
client: Client,
tx: Sender<Event>,
settings: &AppSettings,
current_players: usize,
) -> Result<(), Box<dyn std::error::Error>>;
}

View file

@ -0,0 +1,54 @@
use std::{path::PathBuf, sync::mpsc::Sender, error::Error, os::fd::AsRawFd, process::Command};
use log::info;
use crate::{server::{Event, Client}, runners::fcntl, AppSettings};
use super::InstanceRunner;
pub struct ProcessInstanceRunner {
child_binary_path: PathBuf
}
impl ProcessInstanceRunner {
pub fn new(path: &str) -> Self {
let child_binary_path = PathBuf::from(path);
if !child_binary_path.exists() {
// Fail fast, not on first connect.
panic!("instance binary does not exist: {:?}", child_binary_path)
}
info!("Using binary: {:?}", child_binary_path);
Self { child_binary_path }
}
}
impl InstanceRunner for ProcessInstanceRunner {
fn run(
&mut self,
client: Client,
tx: Sender<Event>,
settings: &AppSettings,
current_players: usize,
) -> Result<(), Box<dyn Error>> {
fcntl::unset_fd_cloexec(&client.socket);
let socket_fd = client.socket.as_raw_fd();
let mut child = Command::new(&self.child_binary_path)
.arg(format!("{:?}_{}", client.id, socket_fd)) // for ps
.env("FD", socket_fd.to_string())
.env("MODE", client.mode.to_string())
.env("LOG_TO_FILE", settings.log_to_file.to_string())
.env("PLAYERS", current_players.to_string())
.spawn()?;
info!("Spawned instance for fd: {}, pid: {}", socket_fd, child.id());
std::thread::spawn(move || {
let code = child.wait();
info!("Instance {} exited with status {:?}", socket_fd, code);
tx.send(Event::Disconnect(client.id)).unwrap(); // Err = main thread died
});
Ok(())
}
}

View file

@ -0,0 +1,131 @@
use std::{error::Error, net::{TcpListener, TcpStream, SocketAddr}, io::Write, time::Duration};
use libcloud::{resources::{Resources, StrId}, ServerMode};
use std::sync::mpsc::Sender;
use log::{info, error, warn};
use crate::{AppSettings, runners::{process::ProcessInstanceRunner, InstanceRunner}};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ClientId(SocketAddr);
#[derive(Debug)]
pub struct Client {
pub id: ClientId,
pub socket: TcpStream,
pub mode: ServerMode,
}
#[derive(Debug)]
pub enum Event {
Error(String),
Connect(Client),
Disconnect(ClientId),
Blocked(Client, Vec<u8>)
}
pub struct Server {
res: Resources,
settings: AppSettings,
connected: Vec<ClientId>,
crd_timeout: Duration,
}
impl Server {
pub fn new(res: Resources, settings: AppSettings) -> Self {
Self {
res,
connected: Vec::with_capacity(settings.max_concurrent),
crd_timeout: Duration::from_millis(settings.client_read_timeout),
settings,
}
}
pub fn serve(mut self) -> Result<(), Box<dyn Error>> {
let servers = [
(ServerMode::User, self.settings.user_port),
(ServerMode::Color, self.settings.color_port),
(ServerMode::Ascii, self.settings.ascii_port),
(ServerMode::Sixel, self.settings.sixel_port),
];
let (tx, rx) = std::sync::mpsc::channel();
for (mode, port) in servers.into_iter() {
let host = format!("{}:{}", self.settings.host, port);
info!("{:?} listening on {}", mode, host);
let server = TcpListener::bind(host)?;
Self::start_accepting(server, tx.clone(), mode);
};
// TODO: Inject?
let mut runner = ProcessInstanceRunner::new(&self.settings.instance_bin);
// Main thread
while let Ok(ev) = rx.recv() {
match ev {
Event::Connect(client) => self.client_connected(&mut runner, client, tx.clone()),
Event::Disconnect(client_id) => self.client_disconnected(client_id),
Event::Blocked(mut client, msg) => { _ = client.socket.write_all(&msg) },
Event::Error(e) => error!("Error: {}", e),
}
}
warn!("Server died.");
Ok(())
}
fn start_accepting(srv_socket: TcpListener, tx: Sender<Event>, mode: ServerMode) {
std::thread::spawn(move || {
loop {
match srv_socket.accept() {
Ok((socket, addr)) => {
let client = Client { id: ClientId(addr), socket, mode };
tx.send(Event::Connect(client))
}
Err(e) => tx.send(Event::Error(e.to_string())),
}.unwrap(); // Err == main thread died.
}
});
}
fn client_disconnected(&mut self, client_id: ClientId) {
self.connected.retain(|c| c.0.ip() != client_id.0.ip());
error!("Client disconnected: {:?} ({} connected)", client_id, self.connected.len());
}
fn client_connected(&mut self, runner: &mut ProcessInstanceRunner, client: Client, tx: Sender<Event>) {
info!("Client soft-connect: {:?} ({} connected)", client.id, self.connected.len());
// Block, with events
if let Some(msg) = self.block_client(&client.id) {
warn!("{:?} blocked: {}", client.id, String::from_utf8(msg.to_vec()).unwrap());
tx.send(Event::Blocked(client, msg.to_vec())).unwrap();
return;
}
if let Err(e) = client.socket.set_read_timeout(Some(self.crd_timeout)) {
warn!("failed to set client timeout: {:?} {}", client.id, e);
return;
}
let client_id = client.id;
if let Err(e) = runner.run(client, tx, &self.settings, self.connected.len()) {
// TODO: Event?
warn!("Runner failed to start: {}", e);
}
else {
info!("Client connected! {:?}", client_id);
self.connected.push(client_id)
}
}
fn block_client(&self, client_id: &ClientId) -> Option<&[u8]> {
if self.connected.len() >= self.settings.max_concurrent {
return Some(&self.res[StrId::TooManyPlayers])
}
if self.settings.block_dup &&
self.connected.iter().any(|&c| c.0.ip() == client_id.0.ip()) {
return Some(&self.res[StrId::AlreadyConnected])
}
None
}
}

View file

@ -0,0 +1,52 @@
use std::{fmt::Display, str::FromStr};
pub mod resources;
pub mod logging;
#[derive(Debug, Clone, Copy)]
pub enum ServerMode {
Color,
Ascii,
Sixel,
User,
}
impl Display for ServerMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}
impl FromStr for ServerMode {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"Ascii" => Ok(Self::Ascii),
"Color" => Ok(Self::Color),
"Sixel" => Ok(Self::Sixel),
"User" => Ok(Self::User),
_ => Err(())
}
}
}
pub mod utils {
use std::io::Read;
pub trait ReadByte {
fn read_byte(&mut self) -> Result<u8, std::io::Error>;
}
impl<R> ReadByte for R where R : Read {
fn read_byte(&mut self) -> Result<u8, std::io::Error> {
let mut buf = [0; 1];
self.read_exact(&mut buf)?;
Ok(buf[0])
}
}
pub fn strhash(digest: &md5::Digest) -> String {
format!("{:x}", digest)
}
}

View file

@ -0,0 +1,39 @@
use std::error::Error;
use flexi_logger::{DeferredNow, Record, style, TS_DASHES_BLANK_COLONS_DOT_BLANK, Logger, Duplicate, FileSpec, WriteMode};
fn log_format(
w: &mut dyn std::io::Write,
now: &mut DeferredNow,
record: &Record,
) -> Result<(), std::io::Error> {
let level = record.level();
write!(
w,
"[{}] {} [{}:{}] {} {}",
style(level).paint(now.format(TS_DASHES_BLANK_COLONS_DOT_BLANK).to_string()),
style(level).paint(level.to_string()),
record.file().unwrap_or("<unnamed>"),
record.line().unwrap_or(0),
style(level).paint(format!("[fd: {}]", std::env::var("FD").unwrap_or_else(|_| "N/A".into()))),
style(level).paint(&record.args().to_string())
)
// Ok(())
}
pub fn init(file: bool) -> Result<(), Box<dyn Error>> {
let logger = Logger::try_with_str("debug")?
.format(log_format)
.append()
.duplicate_to_stdout(Duplicate::All);
if file {
logger.log_to_file(FileSpec::default())
.write_mode(WriteMode::BufferAndFlush)
.start()?;
} else {
logger.start()?;
}
Ok(())
}

View file

@ -0,0 +1,101 @@
use std::{fs, path::PathBuf, collections::HashMap, fmt::Debug};
use log::debug;
use serde::Deserialize;
#[derive(Debug, Deserialize, PartialEq, Eq, Hash, Clone, Copy)]
pub enum StrId {
Welcome,
AlreadyConnected,
TooManyPlayers,
RomSelection,
InvalidRomSelection,
InvalidRom,
RomInserted,
RenderModeSelection,
InvalidRenderModeSelection,
AnyKeyToStart,
}
#[derive(Debug, Deserialize)]
pub struct Fps {
pub sixel: usize,
pub color: usize,
pub ascii: usize,
}
#[derive(Debug, Deserialize)]
pub struct Resources {
included_roms: Vec<PathBuf>,
fps: Fps,
tx_mb_limit: usize,
strings: HashMap<StrId, String>,
}
impl Resources {
pub fn load(filepath: &str) -> Resources {
let f = match fs::File::open(filepath) {
Ok(f) => f,
Err(e) => panic!("could not open resource file ({}): {}", filepath, e)
};
let res: Resources = serde_yaml::from_reader(f).unwrap();
for p in &res.included_roms {
if !p.exists() {
panic!("Included ROM not found: {:?}", p)
}
debug!("Included ROM: {:?}", p);
}
res
}
pub fn fmt(&self, index: StrId, args: &[&str]) -> Vec<u8> {
let mut fstr = String::from_utf8(self[index].to_vec()).unwrap();
if !fstr.contains("{}") {
panic!("fmtwhat: {:?}", index);
}
for arg in args {
fstr = fstr.replacen("{}", arg, 1)
}
fstr.as_bytes().to_vec()
}
pub fn included_roms(&self) -> &Vec<PathBuf> {
&self.included_roms
}
pub fn load_rom(&mut self, path: &PathBuf) -> Vec<u8> {
debug!("Loading included ROM: {:?}", path);
std::fs::read(path).expect("failed to load included ROM")
}
pub fn fps_conf(&self) -> &Fps {
&self.fps
}
pub fn tx_mb_limit(&self) -> usize {
self.tx_mb_limit
}
}
impl std::ops::Index<StrId> for Resources {
type Output = [u8];
fn index(&self, index: StrId) -> &[u8] {
self.strings.get(&index).unwrap().as_bytes()
}
}
#[cfg(test)]
mod tests {
use super::{Resources, StrId};
#[test]
fn res_fmt() {
let r = Resources::load("resources.yaml");
assert_eq!(
"\nYou have inserted a ROM:\nfoo\nbar\n",
String::from_utf8(r.fmt(StrId::RomInserted, &["foo", "bar"])).unwrap()
);
}
}

2
nes-cloud/test.sh Executable file
View file

@ -0,0 +1,2 @@
#!/bin/sh
cargo build --release --bin nes-cloud-instance && cargo test --release -- --test-threads=1 --nocapture

485
nes-cloud/tests/app_test.rs Normal file
View file

@ -0,0 +1,485 @@
#[macro_use]
extern crate lazy_static;
use std::{process::{Child, Stdio, ChildStderr}, net::{TcpStream, SocketAddr}, io::{Read, Write, BufReader, BufRead}, time::Duration};
use assert_cmd::prelude::CommandCargoExt;
use libcloud::{resources::{StrId, Resources}};
use rand::Rng;
const USER_PORT: u16 = 4444;
const COLOR_PORT: u16 = 5555;
const SIXEL_PORT: u16 = 6666;
const ASCII_PORT: u16 = 7777;
lazy_static! {
static ref RES: Resources = Resources::load("resources.yaml");
}
fn assert_eq_str(expected: &[u8], actual: &[u8]) {
let l = String::from_utf8(expected.into()).unwrap();
let r = String::from_utf8(actual.into()).unwrap();
assert_eq!(l, r)
}
struct SuicidalChild(Child);
impl std::ops::Drop for SuicidalChild {
fn drop(&mut self) {
self.0.kill().unwrap()
}
}
impl SuicidalChild {
fn premeditated_suicide(mut self) -> BufReader<ChildStderr> {
let stderr = self.0.stderr.take().unwrap();
std::mem::drop(self);
BufReader::new(stderr)
}
}
struct Client(TcpStream);
impl Client {
fn connect() -> Result<Self, Box<dyn std::error::Error>> {
Self::connect_port(USER_PORT)
}
fn connect_port(port: u16) -> Result<Self, Box<dyn std::error::Error>> {
let addr = format!("127.0.0.1:{port}").parse::<SocketAddr>().unwrap();
let socket = TcpStream::connect_timeout(&addr, Duration::from_millis(100))?;
Ok(Self(socket))
}
fn expect_server_message(&mut self, expected: &[u8]) {
let mut buf = vec![0; expected.len()];
match self.0.read_exact(&mut buf) {
Ok(()) => assert_eq_str(expected, &buf[0..expected.len()]),
Err(e) => panic!("assert_server_message: {}\nExpected: {:?}\n\nActual: {:?}\n",
e, String::from_utf8(expected.to_vec()), String::from_utf8(buf))
}
}
fn expect_welcome_and_rom_prompt(&mut self) {
self.expect_server_message(&RES.fmt(StrId::Welcome, &["0"]));
self.expect_server_message(&RES[StrId::RomSelection]);
}
fn expect_render_mode_prompt(&mut self) {
self.expect_server_message(&RES[StrId::RenderModeSelection]);
}
fn expect_press_any_key_to_boot(&mut self, expected_mode: &str) {
self.expect_server_message(&RES.fmt(StrId::AnyKeyToStart, &[expected_mode]));
}
fn expect_frame(&mut self) {
let mut buf = [0; 3];
match self.0.read_exact(&mut buf) {
Ok(_) => assert_eq!("\x1b[H".as_bytes(), &buf),
Err(e) => panic!("expect_frame: {}", e)
}
}
fn expect_disconnected(&mut self) {
let mut buf = [5u8; 1];
if let Ok(n) = self.0.read(&mut buf) {
if n != 0 { panic!("not disconnected") }
}
}
fn disconnect(&mut self) {
self.0.shutdown(std::net::Shutdown::Both).unwrap();
std::thread::sleep(Duration::from_millis(500));
}
fn input(&mut self, data: &[u8]) {
self.0.write_all(data).unwrap();
}
fn input_any_key(&mut self) {
let any: u8 = rand::thread_rng().gen();
self.0.write_all(&[any]).unwrap();
}
}
fn start_app() -> Result<SuicidalChild, Box<dyn std::error::Error>> {
start_app_with_settings(false, 5)
}
fn start_app_with_settings(block_dup: bool, max: usize) -> Result<SuicidalChild, Box<dyn std::error::Error>> {
let mut cmd = std::process::Command::cargo_bin("nes-cloud-app")?;
if block_dup {
cmd.arg("--block-dup");
}
cmd.args([
"--max-concurrent", &max.to_string(),
"--client-read-timeout", "1500"
]);
cmd.stderr(Stdio::piped());
let child = cmd.spawn()?;
// Ready?
loop {
let stream = TcpStream::connect("127.0.0.1:4444");
if stream.is_ok() { break; }
}
std::thread::sleep(Duration::from_millis(500));
Ok(SuicidalChild(child))
}
#[test]
fn single_client() -> Result<(), Box<dyn std::error::Error>> {
let _child = start_app()?;
let mut client = Client::connect()?;
client.expect_welcome_and_rom_prompt();
Ok(())
}
#[test]
fn same_src_addr() -> Result<(), Box<dyn std::error::Error>> {
let _child = start_app_with_settings(true, 5)?;
let mut client = Client::connect()?;
client.expect_welcome_and_rom_prompt();
let mut client2 = Client::connect()?;
client2.expect_server_message(&RES[StrId::AlreadyConnected]);
client.disconnect();
let mut client3 = Client::connect()?;
client3.expect_welcome_and_rom_prompt();
Ok(())
}
#[test]
fn max_clients() -> Result<(), Box<dyn std::error::Error>> {
let max = 5;
let _child = start_app_with_settings(false, max)?;
let mut clients = vec![];
for _ in 0..max + 1 {
clients.push(Client::connect().unwrap());
}
for (i, c) in clients.iter_mut().enumerate() {
if i < max {
c.expect_server_message(&RES.fmt(StrId::Welcome, &[&i.to_string()]));
c.expect_server_message(&RES[StrId::RomSelection]);
}
else {
c.expect_server_message(&RES[StrId::TooManyPlayers]);
}
}
let mut bailer = clients.remove(0);
bailer.disconnect();
let mut next = Client::connect().unwrap();
next.expect_welcome_and_rom_prompt();
Ok(())
}
#[test]
fn select_valid_included_rom_invalid_render_mode_selection() -> Result<(), Box<dyn std::error::Error>> {
let _child = start_app()?;
let mut client = Client::connect()?;
client.expect_welcome_and_rom_prompt();
client.input(&[b'1']);
client.expect_server_message(&RES[StrId::RenderModeSelection]);
client.input(&[0x0a]); // enter, ignore
// Expect nothing from server, enter (0x0a) is ignored
// Invalid input
client.input(&[b'4']); // valid: 1-3
// Try again
client.expect_server_message(&RES[StrId::InvalidRenderModeSelection]);
client.expect_disconnected();
Ok(())
}
#[test]
fn select_valid_included_rom_valid_render_mode_selection() -> Result<(), Box<dyn std::error::Error>> {
let _child = start_app()?;
let mut client = Client::connect()?;
client.expect_welcome_and_rom_prompt();
client.input(&[b'1']);
client.expect_server_message(&RES[StrId::RenderModeSelection]);
client.input(&[0x0a]); // enter, ignore
// Expect nothing from server, enter (0x0a) is ignored
client.input(&[b'1']); // valid: 1-3
client.expect_press_any_key_to_boot("Sixel");
client.input_any_key();
client.expect_frame();
Ok(())
}
#[test]
fn select_valid_included_rom_invalid_render_mode_selection_icanon() -> Result<(), Box<dyn std::error::Error>> {
let _child = start_app()?;
let mut client = Client::connect()?;
client.expect_welcome_and_rom_prompt();
client.input(&[b'1']);
client.expect_server_message(&RES[StrId::RenderModeSelection]);
// Invalid input
client.input(&[b'4']); // valid: 1-3
client.expect_server_message(&RES[StrId::InvalidRenderModeSelection]);
client.expect_disconnected();
Ok(())
}
#[test]
fn select_valid_included_rom_valid_render_mode_selection_icanon() -> Result<(), Box<dyn std::error::Error>> {
let _child = start_app()?;
let mut client = Client::connect()?;
client.expect_welcome_and_rom_prompt();
client.input(&[b'1']);
client.expect_server_message(&RES[StrId::RenderModeSelection]);
client.input(&[b'1']); // valid: 1-3
client.expect_press_any_key_to_boot("Sixel");
client.input_any_key();
client.expect_frame();
Ok(())
}
#[test]
fn select_invalid_included_rom() -> Result<(), Box<dyn std::error::Error>> {
let _child = start_app()?;
let mut client = Client::connect()?;
client.expect_welcome_and_rom_prompt();
let selection = b'a';
client.input(&[selection]);
client.expect_server_message(&RES[StrId::InvalidRomSelection]);
client.expect_disconnected();
Ok(())
}
#[test]
fn pipe_valid_rom() -> Result<(), Box<dyn std::error::Error>> {
let rom = include_bytes!("../../test-roms/nes-test-roms/cpu_dummy_writes/cpu_dummy_writes_ppumem.nes");
let _child = start_app()?;
let mut client = Client::connect()?;
client.input(rom);
client.expect_welcome_and_rom_prompt();
client.expect_server_message(&RES.fmt(
StrId::RomInserted,
&["[Ines] Mapper: Nrom, Mirroring: Vertical, CHR: 1x8K, PRG: 2x16K", "319a1ece57229c48663fec8bdf3764c0"]
));
client.expect_render_mode_prompt();
client.input(&[b'2']);
client.expect_press_any_key_to_boot("Color");
client.input_any_key();
client.expect_frame();
Ok(())
}
#[test]
fn pipe_valid_rom_same_frame_same_crc_detect_disconnect() -> Result<(), Box<dyn std::error::Error>> {
// nestest has the same frame forever without any input
let rom = include_bytes!("../../test-roms/nestest/nestest.nes");
let child = start_app()?;
let mut client = Client::connect()?;
client.input(rom);
client.expect_welcome_and_rom_prompt();
client.expect_server_message(&RES.fmt(
StrId::RomInserted,
&["[Ines] Mapper: Nrom, Mirroring: Horizontal, CHR: 1x8K, PRG: 1x16K", "4068f00f3db2fe783e437681fa6b419a"]
));
client.expect_render_mode_prompt();
client.input(&[b'3']);
client.expect_press_any_key_to_boot("Ascii");
client.input_any_key();
client.expect_frame();
client.disconnect();
let stderr = child.premeditated_suicide();
stderr.lines()
.map(|l| l.unwrap())
.find(|l| l.contains("Instance died."))
.expect("Emulation process did not die. Probably stuck in a same CRC loop.");
Ok(())
}
#[test]
fn pipe_invalid_rom() -> Result<(), Box<dyn std::error::Error>> {
let rom = &[b'N', b'E', b'S', 0xff];
let _child = start_app()?;
let mut client = Client::connect()?;
client.input(rom);
client.expect_welcome_and_rom_prompt();
client.expect_server_message(&RES.fmt(
StrId::InvalidRom,
&["InvalidCartridge(\"magic\")"]
));
client.expect_disconnected();
Ok(())
}
#[test]
fn pipe_unsupported_rom() -> Result<(), Box<dyn std::error::Error>> {
let rom = include_bytes!("../../test-roms/nes-test-roms/other/oam3.nes");
let _child = start_app()?;
let mut client = Client::connect()?;
client.input(rom);
client.expect_welcome_and_rom_prompt();
client.expect_server_message(&RES.fmt(
StrId::InvalidRom,
&["NotYetImplemented(\"Mapper 7\")"]
));
client.expect_disconnected();
Ok(())
}
#[test]
fn pipe_magic_then_timeout() -> Result<(), Box<dyn std::error::Error>> {
let rom = &[b'N', b'E', b'S', 0x1a, 0xff];
let _child = start_app()?;
let mut client = Client::connect()?;
client.input(rom);
client.expect_welcome_and_rom_prompt();
client.expect_disconnected();
Ok(())
}
#[test]
fn render_mode_ascii() -> Result<(), Box<dyn std::error::Error>> {
let _child = start_app()?;
let mut client = Client::connect_port(ASCII_PORT)?;
client.expect_welcome_and_rom_prompt();
client.input(&[b'1']);
client.expect_press_any_key_to_boot("Ascii");
client.input_any_key();
client.expect_frame();
Ok(())
}
#[test]
fn render_mode_color() -> Result<(), Box<dyn std::error::Error>> {
let _child = start_app()?;
let mut client = Client::connect_port(COLOR_PORT)?;
client.expect_welcome_and_rom_prompt();
client.input(&[b'1']);
client.expect_press_any_key_to_boot("Color");
client.input_any_key();
client.expect_frame();
Ok(())
}
#[test]
fn render_mode_sixel() -> Result<(), Box<dyn std::error::Error>> {
let _child = start_app()?;
let mut client = Client::connect_port(SIXEL_PORT)?;
client.expect_welcome_and_rom_prompt();
client.input(&[b'1']);
client.expect_press_any_key_to_boot("Sixel");
client.input_any_key();
client.expect_frame();
Ok(())
}
#[test]
fn instance_panic_notify_client_and_close() -> Result<(), Box<dyn std::error::Error>> {
let rom = include_bytes!("../../test-roms/nes-test-roms/cpu_dummy_writes/cpu_dummy_writes_ppumem.nes");
std::env::set_var("PANIC", "1");
let child = start_app()?;
let mut client = Client::connect()?;
client.input(rom);
client.expect_welcome_and_rom_prompt();
client.expect_server_message(&RES.fmt(
StrId::RomInserted,
&["[Ines] Mapper: Nrom, Mirroring: Vertical, CHR: 1x8K, PRG: 2x16K", "319a1ece57229c48663fec8bdf3764c0"]
));
client.expect_render_mode_prompt();
client.input(&[b'1']);
client.expect_press_any_key_to_boot("Sixel");
client.input_any_key();
client.expect_frame();
// Instance did panic by now
std::thread::sleep(Duration::from_millis(1200));
std::env::remove_var("PANIC");
// Kill app process so we can read stderr until EOF
let stderr = child.premeditated_suicide();
let exp = format!("Client disconnected: ClientId({}) (0 connected)", client.0.local_addr().unwrap());
stderr.lines()
.map(|l| l.unwrap())
.find(|l| l.contains(&exp))
.expect("Instance did not die on panic");
Ok(())
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,12 +4,6 @@ version = "0.1.0"
edition = "2021"
default-run = "nes-sdl"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
#[[bin]]
#name = "nes-sdl"
#path = "src/nes-sdl.rs"
[dependencies]
structopt = "0.3.13"
sdl2 = "0.35.2"

View file

@ -296,6 +296,12 @@ impl<R : Rom> Display for Cartridge<R> {
}
}
impl<R : Rom> core::fmt::Debug for Cartridge<R> {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "{self}")
}
}
#[cfg(test)]
mod tests {
use alloc::string::ToString;

View file

@ -80,6 +80,10 @@ impl RenderFrame {
(self.set_pixel_fn)(&mut self.buf, i, rgb);
}
pub fn replace_buf(&mut self, buf: &[u8]) {
self.buf = buf.to_vec();
}
pub fn pixels_pal(&self) -> &[u8] {
&self.buf
}

View file

@ -49,7 +49,10 @@ impl<R : Rom> Bus for NROM<R> {
}
}
fn write8(&mut self, _: u8, _: u16) {
fn write8(&mut self, v: u8, address: u16) {
match address {
0x0000..=0x1fff => self.cart.chr_ram()[address as usize] = v,
_ => (),
}
}
}

View file

@ -5,7 +5,7 @@ use alloc::vec::Vec;
use crate::{frame::RenderFrame, trace, ppu::state::{Phase, Rendering}, mappers::Mapper, cartridge::Mirroring};
use super::{palette::Palette, vram::Vram, state::State};
#[derive(Default, Clone, Copy)]
#[derive(Default, Clone, Copy, Debug)]
struct Sprite {
pixels: [u8; 8], // Only 8 pixels per line
priority: bool, // Priority (0: in front of background; 1: behind background)
@ -478,7 +478,7 @@ impl Ppu {
} else {
v &= !0x7000;
let mut y = (v & 0x3e0) >> 5;
if y == 29 { // TODO: match y
if y == 29 {
y = 0;
v ^= 0x800
} else if y == 31 {

BIN
screenshots/cloud.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB