mirror of
https://github.com/henrikpersson/potatis.git
synced 2024-06-02 11:37:29 -04:00
Cloud
This commit is contained in:
parent
d328521df7
commit
6352f1b31c
|
@ -5,11 +5,13 @@ members = [
|
|||
"nes",
|
||||
"nes-sdl",
|
||||
"nes-wasm",
|
||||
"nes-android"
|
||||
"nes-android",
|
||||
]
|
||||
|
||||
exclude = [
|
||||
"nes-embedded",
|
||||
"profile"
|
||||
"nes-cloud",
|
||||
"profile",
|
||||
]
|
||||
|
||||
[profile.test]
|
||||
|
|
23
README.md
23
README.md
|
@ -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
1
nes-cloud/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
/target
|
38
nes-cloud/Cargo.toml
Normal file
38
nes-cloud/Cargo.toml
Normal 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"
|
7
nes-cloud/Cargo.toml.docker
Normal file
7
nes-cloud/Cargo.toml.docker
Normal file
|
@ -0,0 +1,7 @@
|
|||
[workspace]
|
||||
members = [
|
||||
"common",
|
||||
"mos6502",
|
||||
"nes",
|
||||
"nes-cloud"
|
||||
]
|
59
nes-cloud/Dockerfile
Normal file
59
nes-cloud/Dockerfile
Normal 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"]
|
3
nes-cloud/Dockerfile.dockerignore
Normal file
3
nes-cloud/Dockerfile.dockerignore
Normal file
|
@ -0,0 +1,3 @@
|
|||
**/.git
|
||||
**/target
|
||||
fly.toml
|
17
nes-cloud/README.md
Normal file
17
nes-cloud/README.md
Normal 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`
|
12
nes-cloud/docker-compose.yaml
Normal file
12
nes-cloud/docker-compose.yaml
Normal 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
44
nes-cloud/fly.toml
Normal 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"
|
BIN
nes-cloud/included-roms/2048.nes
Normal file
BIN
nes-cloud/included-roms/2048.nes
Normal file
Binary file not shown.
BIN
nes-cloud/included-roms/Nova_the_Squirrel.nes
Normal file
BIN
nes-cloud/included-roms/Nova_the_Squirrel.nes
Normal file
Binary file not shown.
26
nes-cloud/included-roms/README.nova.md
Normal file
26
nes-cloud/included-roms/README.nova.md
Normal 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.
|
BIN
nes-cloud/included-roms/life.nes
Normal file
BIN
nes-cloud/included-roms/life.nes
Normal file
Binary file not shown.
BIN
nes-cloud/included-roms/nestest.nes
Normal file
BIN
nes-cloud/included-roms/nestest.nes
Normal file
Binary file not shown.
24
nes-cloud/included-roms/readme.2048.txt
Normal file
24
nes-cloud/included-roms/readme.2048.txt
Normal 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
79
nes-cloud/resources.yaml
Normal 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!
|
36
nes-cloud/src/instance/ansi.rs
Normal file
36
nes-cloud/src/instance/ansi.rs
Normal 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)
|
||||
}
|
||||
}
|
114
nes-cloud/src/instance/host.rs
Normal file
114
nes-cloud/src/instance/host.rs
Normal 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);
|
||||
}
|
||||
}
|
40
nes-cloud/src/instance/io.rs
Normal file
40
nes-cloud/src/instance/io.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
}
|
309
nes-cloud/src/instance/main.rs
Normal file
309
nes-cloud/src/instance/main.rs
Normal 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");
|
||||
}
|
||||
}
|
213
nes-cloud/src/instance/renderers.rs
Normal file
213
nes-cloud/src/instance/renderers.rs
Normal 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");
|
||||
}
|
||||
}
|
44
nes-cloud/src/server/main.rs
Normal file
44
nes-cloud/src/server/main.rs
Normal 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(())
|
||||
}
|
16
nes-cloud/src/server/runners/docker.rs
Normal file
16
nes-cloud/src/server/runners/docker.rs
Normal 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!()
|
||||
}
|
||||
}
|
19
nes-cloud/src/server/runners/fcntl.rs
Normal file
19
nes-cloud/src/server/runners/fcntl.rs
Normal 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);
|
||||
}
|
||||
}
|
16
nes-cloud/src/server/runners/mod.rs
Normal file
16
nes-cloud/src/server/runners/mod.rs
Normal 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>>;
|
||||
}
|
54
nes-cloud/src/server/runners/process.rs
Normal file
54
nes-cloud/src/server/runners/process.rs
Normal 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(())
|
||||
}
|
||||
}
|
131
nes-cloud/src/server/server.rs
Normal file
131
nes-cloud/src/server/server.rs
Normal 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
|
||||
}
|
||||
}
|
52
nes-cloud/src/shared/lib.rs
Normal file
52
nes-cloud/src/shared/lib.rs
Normal 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)
|
||||
}
|
||||
}
|
39
nes-cloud/src/shared/logging.rs
Normal file
39
nes-cloud/src/shared/logging.rs
Normal 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(())
|
||||
}
|
101
nes-cloud/src/shared/resources.rs
Normal file
101
nes-cloud/src/shared/resources.rs
Normal 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
2
nes-cloud/test.sh
Executable 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
485
nes-cloud/tests/app_test.rs
Normal 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(())
|
||||
}
|
1
nes-cloud/tests/frame_565_pal.bin
Normal file
1
nes-cloud/tests/frame_565_pal.bin
Normal file
File diff suppressed because one or more lines are too long
1
nes-cloud/tests/frame_888_pal.bin
Normal file
1
nes-cloud/tests/frame_888_pal.bin
Normal file
File diff suppressed because one or more lines are too long
|
@ -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"
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
BIN
screenshots/cloud.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 260 KiB |
Loading…
Reference in a new issue