[v0.1.0-beta.1] Implemented ObjectViewer

- load/reload `ObjectParameters/*.json`
- get managers, managees
- read bytes, struct, string, class name
- write bytes
This commit is contained in:
sup39 2023-07-23 05:27:51 +09:00
commit 89e808d3fe
No known key found for this signature in database
GPG key ID: 14D2E0D21140D260
48 changed files with 15582 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
target/
out/

7
CHANGELOG.md Normal file
View file

@ -0,0 +1,7 @@
# Changelog
## v0.1.0-beta.1 (2023/07/23)
- Implemented ObjectViewer
- load/reload `ObjectParameters/*.json`
- get managers, managees
- read bytes, struct, string, class name
- write bytes

1230
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

26
Cargo.toml Normal file
View file

@ -0,0 +1,26 @@
[package]
name = "sup-smsac"
version = "0.1.0-beta.1"
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://github.com/sup39/sup-smsac"
[dependencies]
sup-smsac-derive = { path = "./sup-smsac-derive" }
encoding_rs = "0.8.32"
windows = {version = "0.48.0", features = [
"Win32_Foundation",
"Win32_System_Diagnostics_ToolHelp",
"Win32_System_Memory",
]}
futures-util = "0.3.28"
hyper = { version = "0.14.27", features = ["full"] }
hyper-tungstenite = "0.10.0"
serde_json = "1.0.103"
tokio = { version = "1.29.1", features = ["full"] }
tokio-util = "0.7.8"
open = "5.0.0"
clap = { version = "4.3.16", features = ["derive"] }
mime_guess = "2.0.4"
urlencoding = "2.1.2"
serde = { version = "1.0.174", features = ["rc", "derive"] }

4
LICENSE.txt Normal file
View file

@ -0,0 +1,4 @@
# SPDX-FileCopyrightText: 2023 sup39 <sms@sup39.dev>
# SPDX-License-Identifier: MIT OR Apache-2.0
See "www/LICENSE.html" for the full license text.

36
README.md Normal file
View file

@ -0,0 +1,36 @@
# sup-smsac
A tool to support Super Mario Sunshine academic research and TAS.
It is written in Rust (backend) and JavaScript (frontend), and uses HTTP + WebSocket to communicate between backend and frontend.
It only contains a simple Object Viewer at the moment.
## Usage
Download the binary from the [releases page](https://github.com/sup39/sup-smsac/releases). Unzip and double click `sup-smsac.exe`. It should open browser automatically for you. If it doesn't, open browser and navigate to the url shown in the terminal manually.
## Building from Source (Windows only)
Requirements:
- [cargo](https://www.rust-lang.org/tools/install)
- [Git Bash](https://git-scm.com/download/win)
```sh
# Clone the repository
git clone https://github.com/sup39/sup-smsac
# cd to the directory of the repository
cd sup-smsac
# run the build script
sh build.sh
# the out files will be in "out/sup-smsac-$version"
```
Note that if you are using `cargo run`, you have to pass `-d path/to/repository/directory` as argument to specify the path to the directory of the repository:
```
# assuming you are in the directory of the repository
cargo run -- -d .
```
## TODO
- [ ] documentation of the WebSocket API
- [ ] add more ObjectParameters files
- [ ] UI improvement

70
about.hbs Normal file
View file

@ -0,0 +1,70 @@
<html>
<head>
<style>
@media (prefers-color-scheme: dark) {
body {
background: #333;
color: white;
}
a {
color: skyblue;
}
}
.container {
font-family: sans-serif;
max-width: 800px;
margin: 0 auto;
}
.intro {
text-align: center;
}
.licenses-list {
list-style-type: none;
margin: 0;
padding: 0;
}
.license-used-by {
margin-top: -10px;
}
.license-text {
max-height: 200px;
overflow-y: scroll;
white-space: pre-wrap;
}
</style>
</head>
<body>
<main class="container">
<div class="intro">
<h1>Third Party Licenses</h1>
<p>This page lists the licenses of the projects used in cargo-about.</p>
</div>
<h2>Overview of licenses:</h2>
<ul class="licenses-overview">
{{#each overview}}
<li><a href="#{{id}}">{{name}}</a> ({{count}})</li>
{{/each}}
</ul>
<h2>All license text:</h2>
<ul class="licenses-list">
{{#each licenses}}
<li class="license">
<h3 id="{{id}}">{{name}}</h3>
<h4>Used by:</h4>
<ul class="license-used-by">
{{#each used_by}}
<li><a href="{{#if crate.repository}} {{crate.repository}} {{else}} https://crates.io/crates/{{crate.name}} {{/if}}">{{crate.name}} {{crate.version}}</a></li>
{{/each}}
</ul>
<pre class="license-text">{{text}}</pre>
</li>
{{/each}}
</ul>
</main>
</body>
</html>

10
about.toml Normal file
View file

@ -0,0 +1,10 @@
accepted = [
"Apache-2.0",
"MIT",
"BSD-2-Clause",
"BSD-3-Clause",
"Unicode-DFS-2016",
]
ignore-build-dependencies = true
ignore-dev-dependencies = true

10
build.sh Normal file
View file

@ -0,0 +1,10 @@
set -e
version=$(grep -Po '(?<=version = ").*(?=")' Cargo.toml | head -n1)
outDir=out/sup-smsac-$version
#rm -rf "$outDir"
mkdir -p "$outDir"
cargo build --release
cp ./target/release/sup-smsac.exe "$outDir/"
cp -r www res README.md LICENSE.txt CHANGELOG.md "$outDir/"

View file

@ -0,0 +1,38 @@
{
"JDrama::TActor": {
"size": 68,
"offsets": [
{
"offset": "0",
"type": "JDrama::TPlacement",
"name": "Inherited fields",
"notes": ""
},
{
"offset": "20",
"type": "JStage::TActor",
"name": "Inherited fields",
"notes": ""
},
{
"offset": "24",
"type": "JGeometry::TVec3<float>",
"name": "* Scale",
"notes": ""
},
{
"offset": "30",
"type": "JGeometry::TVec3<float>",
"name": "* Rotation",
"notes": ""
},
{
"offset": "40",
"type": "JDrama::TLightMap*",
"name": "",
"notes": "",
"hidden": true
}
]
}
}

View file

@ -0,0 +1,26 @@
{
"JDrama::TNameRef": {
"size": 10,
"offsets": [
{
"offset": "0",
"type": "void*",
"name": "vtable",
"notes": ""
},
{
"offset": "4",
"type": "string",
"name": "Name",
"notes": ""
},
{
"offset": "8",
"type": "s16",
"name": "Key code",
"notes": "",
"hidden": true
}
]
}
}

View file

@ -0,0 +1,26 @@
{
"JDrama::TPlacement": {
"size": 32,
"offsets": [
{
"offset": "0",
"type": "JDrama::TViewObj",
"name": "Inherited fields",
"notes": ""
},
{
"offset": "10",
"type": "JGeometry::TVec3<float>",
"name": "* Position",
"notes": ""
},
{
"offset": "1c",
"type": "s16",
"name": "",
"notes": "",
"hidden": true
}
]
}
}

View file

@ -0,0 +1,20 @@
{
"JDrama::TViewObj": {
"size": 16,
"offsets": [
{
"offset": "0",
"type": "JDrama::TNameRef",
"name": "Inherited fields",
"notes": ""
},
{
"offset": "c",
"type": "u16",
"name": "Flags",
"notes": "",
"hidden": true
}
]
}
}

View file

@ -0,0 +1,25 @@
{
"JGeometry::TVec3<float>": {
"size": 12,
"offsets": [
{
"offset": "0",
"type": "float",
"name": "X",
"notes": ""
},
{
"offset": "4",
"type": "float",
"name": "Y",
"notes": ""
},
{
"offset": "8",
"type": "float",
"name": "Z",
"notes": ""
}
]
}
}

View file

@ -0,0 +1,73 @@
{
"TBossEel": {
"size": 544,
"offsets": [
{
"offset": "0",
"type": "TSpineEnemy",
"name": "Inherited fields",
"notes": ""
},
{
"offset": ["16c", "70"],
"type": "s32",
"name": "tooth[0].hp",
"notes": ""
},
{
"offset": ["170", "70"],
"type": "s32",
"name": "tooth[1].hp",
"notes": ""
},
{
"offset": ["174", "70"],
"type": "s32",
"name": "tooth[2].hp",
"notes": ""
},
{
"offset": ["178", "70"],
"type": "s32",
"name": "tooth[3].hp",
"notes": ""
},
{
"offset": ["17c", "70"],
"type": "s32",
"name": "tooth[4].hp",
"notes": ""
},
{
"offset": ["180", "70"],
"type": "s32",
"name": "tooth[5].hp",
"notes": ""
},
{
"offset": ["184", "70"],
"type": "s32",
"name": "tooth[6].hp",
"notes": ""
},
{
"offset": ["188", "70"],
"type": "s32",
"name": "tooth[7].hp",
"notes": ""
},
{
"offset": "1fc",
"type": "u8",
"name": "Water hit tooth flag",
"notes": ""
},
{
"offset": "1fd",
"type": "u8",
"name": "Molar washed flag",
"notes": ""
}
]
}
}

View file

@ -0,0 +1,75 @@
{
"THitActor": {
"size": 104,
"offsets": [
{
"offset": "0",
"type": "JDrama::TActor",
"name": "Inherited fields",
"notes": ""
},
{
"offset": "44",
"type": "THitActor**",
"name": "Collided object array",
"notes": ""
},
{
"offset": "48",
"type": "s16",
"name": "Collided object count",
"notes": ""
},
{
"offset": "4a",
"type": "s16",
"name": "Collided object capacity",
"notes": ""
},
{
"offset": "4c",
"type": "u32",
"format": "hex",
"name": "Actor type",
"notes": "& 0x8000_0000: player\n& 0x1000_0000: enemy\n& 0x0800_0000: boss"
},
{
"offset": "50",
"type": "float",
"name": "Attacking hitbox radius",
"notes": ""
},
{
"offset": "54",
"type": "float",
"name": "Attacking hitbox height",
"notes": ""
},
{
"offset": "58",
"type": "float",
"name": "Receiving hitbox radius",
"notes": ""
},
{
"offset": "5c",
"type": "float",
"name": "Receiving hitbox height",
"notes": ""
},
{
"offset": "60",
"type": "float",
"name": "Entry radius",
"notes": ""
},
{
"offset": "64",
"type": "u32",
"format": "hex",
"name": "Collision flag\n (0) active / (1) no collision",
"notes": ""
}
]
}
}

View file

@ -0,0 +1,138 @@
{
"TLiveActor": {
"size": 244,
"offsets": [
{
"offset": "0",
"type": "TTakeActor",
"name": "Inherited fields",
"notes": ""
},
{
"offset": "70",
"type": "TLiveManager*",
"name": "Manager",
"notes": "",
"hidden": true
},
{
"offset": "74",
"type": "MActor*",
"name": "",
"notes": ""
},
{
"offset": ["74", "c", "0"],
"type": "s32",
"name": "Animation id",
"notes": ""
},
{
"offset": ["74", "28", "0", "14"],
"type": "float",
"name": "Animation frame counter",
"notes": ""
},
{
"offset": ["74", "28", "0", "c"],
"type": "s16",
"name": "Animation frame length",
"notes": ""
},
{
"offset": ["74", "28", "0", "10"],
"type": "float",
"name": "Animation frame rate",
"notes": ""
},
{
"offset": "7c",
"type": "s16",
"name": "Index in manager?",
"notes": "",
"hidden": true
},
{
"offset": "80",
"type": "MAnimSound*",
"name": "",
"notes": "",
"hidden": true
},
{
"offset": "84",
"type": "JKRFileLoader*",
"name": "",
"notes": "",
"hidden": true
},
{
"offset": "8c",
"type": "TSpineBase<TLiveActor>*",
"name": "AI",
"notes": ""
},
{
"offset": ["8c", "1c", "0"],
"type": "void*",
"name": "Previous nerve",
"notes": ""
},
{
"offset": ["8c", "14", "0"],
"type": "void*",
"name": "Current nerve",
"notes": ""
},
{
"offset": ["8c", "20"],
"type": "s32",
"name": "Nerve timer",
"notes": ""
},
{
"offset": "94",
"type": "JGeometry::TVec3<float>",
"name": "* Movement (unit/step)",
"notes": ""
},
{
"offset": "ac",
"type": "JGeometry::TVec3<float>",
"name": "* Speed (unit/step)",
"notes": ""
},
{
"offset": "bc",
"type": "float",
"name": "Wall hitbox width",
"notes": ""
},
{
"offset": "c4",
"type": "TBGCheckData*",
"name": "",
"notes": ""
},
{
"offset": "c8",
"type": "float",
"name": "Ground Height",
"notes": ""
},
{
"offset": "cc",
"type": "float",
"name": "Gravity (unit/step²)",
"notes": ""
},
{
"offset": "f0",
"type": "u32",
"format": "hex",
"name": "Flag",
"notes": "& 0x0001: active(0) / inactive(1)\n& 0x0010: this.bind() enabled(0) / disabled(1)\n& 0x0080: is airborne(1): next position > height of ground below + 0.05\n& 0x1000: checkGround(0) / checkGroundIgnoreWaterSurface(1)"
}
]
}
}

View file

@ -0,0 +1,20 @@
{
"TSpineEnemy": {
"size": 336,
"offsets": [
{
"offset": "0",
"type": "TLiveActor",
"name": "Inherited fields",
"notes": ""
},
{
"offset": "124",
"type": "TGraphTracer*",
"name": "Movement graph",
"notes": "",
"hidden": true
}
]
}
}

View file

@ -0,0 +1,25 @@
{
"TTakeActor": {
"size": 112,
"offsets": [
{
"offset": "0",
"type": "THitActor",
"name": "Inherited fields",
"notes": ""
},
{
"offset": "68",
"type": "TTakeActor*",
"name": "Holder",
"notes": ""
},
{
"offset": "6c",
"type": "TTakeActor*",
"name": "Held object",
"notes": ""
}
]
}
}

View file

@ -0,0 +1,13 @@
{
"_default": {
"size": -1,
"offsets": [
{
"offset": "0",
"type": "TSpineEnemy",
"name": "",
"notes": ""
}
]
}
}

81
src/addr.rs Normal file
View file

@ -0,0 +1,81 @@
/// SPDX-FileCopyrightText: 2023 sup39 <sms@sup39.dev>
/// SPDX-License-Identifier: MIT OR Apache-2.0
use crate::big_endian::DecodeBE;
use sup_smsac_derive::DecodeBE;
#[derive(DecodeBE, Clone, Copy, PartialEq, PartialOrd)]
pub struct Addr(pub u32);
impl From<Addr> for u32 {
fn from(x: Addr) -> u32 {
x.0
}
}
impl From<u32> for Addr {
fn from(x: u32) -> Self {
Self(x)
}
}
impl Addr {
pub fn add(&self, rhs: u32) -> Addr {
Addr(self.0 + rhs)
}
pub fn offset(&self, by: i32) -> Addr {
Addr(self.0 + by as u32)
}
}
impl std::ops::Add<u32> for Addr {
type Output = Addr;
fn add(self, other: u32) -> Addr {
Addr(self.0+other)
}
}
impl std::ops::Sub<u32> for Addr {
type Output = Addr;
fn sub(self, other: u32) -> Addr {
Addr(self.0-other)
}
}
impl std::ops::Sub<Addr> for Addr {
type Output = isize;
fn sub(self, other: Addr) -> isize {
(self.0 - other.0) as i32 as isize
}
}
impl std::fmt::Display for Addr {
fn fmt(&self, f:&mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:08X}", self.0)
}
}
impl std::fmt::Debug for Addr {
fn fmt(&self, f:&mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:08X}", self.0)
}
}
#[derive(Debug, Clone)]
pub struct AddrOffsets<T=u32>(pub T, pub Box<[T]>);
impl std::ops::Add<&AddrOffsets> for &AddrOffsets {
type Output = AddrOffsets;
fn add(self, other: &AddrOffsets) -> AddrOffsets {
match self.1.split_last() {
Some((last, init)) => AddrOffsets(
self.0,
[init, &[last+other.0], &other.1].concat().into(),
),
None => AddrOffsets(self.0+other.0, other.1.clone()),
}
}
}
impl std::fmt::Display for AddrOffsets {
fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
write!(fmt, "{:X}", self.0)?;
for off in self.1.iter() {
write!(fmt, ",{:X}", off)?;
}
Ok(())
}
}

53
src/big_endian.rs Normal file
View file

@ -0,0 +1,53 @@
/// SPDX-FileCopyrightText: 2023 sup39 <sms@sup39.dev>
/// SPDX-License-Identifier: MIT OR Apache-2.0
pub trait DecodeBE {
const PACKED_SIZE: usize;
/// # Safety
///
/// [`ptr`, `ptr+size_of(Self)`) must be valid
unsafe fn decode_be(ptr: *const u8) -> Self;
}
macro_rules! impl_decode_be_for_int {
($type:ident, $size:literal) => {
impl DecodeBE for $type {
const PACKED_SIZE: usize = $size;
#[inline]
unsafe fn decode_be(ptr: *const u8) -> Self {
$type::to_be(*(ptr as *const $type))
}
}
};
}
macro_rules! impl_decode_be_for_float {
($ftype:ident, $itype:ident, $size:literal) => {
impl DecodeBE for $ftype {
const PACKED_SIZE: usize = $size;
#[inline]
unsafe fn decode_be(ptr: *const u8) -> Self {
$ftype::from_bits($itype::to_be(*(ptr as *const $itype)))
}
}
};
}
impl_decode_be_for_int!(u8, 1);
impl_decode_be_for_int!(i8, 1);
impl_decode_be_for_int!(u16, 2);
impl_decode_be_for_int!(i16, 2);
impl_decode_be_for_int!(u32, 4);
impl_decode_be_for_int!(i32, 4);
impl_decode_be_for_int!(u64, 8);
impl_decode_be_for_int!(i64, 8);
impl_decode_be_for_int!(u128, 16);
impl_decode_be_for_int!(i128, 16);
impl_decode_be_for_float!(f32, u32, 4);
impl_decode_be_for_float!(f64, u64, 8);
impl<const N: usize> DecodeBE for &[u8; N] {
const PACKED_SIZE: usize = N;
unsafe fn decode_be(ptr: *const u8) -> Self {
&*(ptr as *const [u8; N])
}
}

94
src/dolphin.rs Normal file
View file

@ -0,0 +1,94 @@
/// SPDX-FileCopyrightText: 2023 sup39 <sms@sup39.dev>
/// SPDX-License-Identifier: MIT OR Apache-2.0
use crate::sys::process::PidType;
use crate::sys::shared_memory::{SharedMemory, SharedMemoryOpenError};
use crate::addr::Addr;
use crate::big_endian::DecodeBE;
use crate::sys::process::{Process32Iterator, ProcessInfo};
use encoding_rs::SHIFT_JIS;
pub const MEM1_START_ADDR: Addr = Addr(0x8000_0000);
pub const MEM1_END_ADDR: Addr = Addr(0x8180_0000);
pub trait Dolphin {
/// # Safety
///
/// The offset must be smaller than the size of the memory region
unsafe fn mem<T: Into<isize>>(&self, offset: T) -> *mut u8;
fn get_ptr_mut(&self, addr: Addr, size: usize) -> Option<*mut u8> {
if MEM1_START_ADDR <= addr && addr < MEM1_END_ADDR - size as u32 {
Some(unsafe {self.mem(addr - MEM1_START_ADDR)})
} else {
None
}
}
#[inline]
fn read_bytes(&self, addr: Addr, size: usize) -> Option<&[u8]> {
self.get_ptr_mut(addr, size)
.map(|ptr| unsafe {std::slice::from_raw_parts(ptr, size)})
}
#[inline]
fn read<T: DecodeBE>(&self, addr: Addr) -> Option<T> {
let size = std::mem::size_of::<T>();
self.get_ptr_mut(addr, size)
.map(|ptr| unsafe {T::decode_be(ptr)})
}
fn read_str(&self, addr: Addr) -> Option<String> {
if MEM1_START_ADDR <= addr && addr < MEM1_END_ADDR {
let ptr = unsafe {self.mem(addr - MEM1_START_ADDR)};
const MAX_LENGTH: u32 = 256; // TODO
let max_length = MAX_LENGTH; // TODO
let maxlen = std::cmp::min(max_length as usize, (MEM1_END_ADDR - addr) as usize);
// let maxlen = (MEM1_END_ADDR - addr) as usize;
let mut len = 0usize;
while len < maxlen {
if unsafe{*ptr.add(len) == 0} {break;}
len += 1;
}
SHIFT_JIS.decode_without_bom_handling_and_without_replacement(unsafe{std::slice::from_raw_parts(ptr, len)}).map(|x| x.into_owned())
} else {
None
}
}
#[inline]
fn write_bytes(&self, addr: Addr, payload: &[u8]) -> Option<()> {
let size = payload.len();
self.get_ptr_mut(addr, size)
.map(|ptr| unsafe {std::ptr::copy(payload.as_ptr(), ptr, size)})
}
}
pub struct DolphinMemory {
shared_memory: SharedMemory,
pid: PidType,
}
impl Dolphin for DolphinMemory {
#[inline]
unsafe fn mem<T: Into<isize>>(&self, offset: T) -> *mut u8 {
self.shared_memory.get_ptr().offset(offset.into())
}
}
impl DolphinMemory {
#[inline]
pub fn pid(&self) -> PidType {
self.pid
}
pub fn open_pid<T: Into<PidType>>(pid: T) -> Result<DolphinMemory, SharedMemoryOpenError> {
let pid: usize = pid.into();
let shared_memory = SharedMemory::open(&format!("dolphin-emu.{}", pid))?;
Ok(DolphinMemory {shared_memory, pid})
}
pub fn list_dolphin() -> impl Iterator<Item = (usize, Result<DolphinMemory, SharedMemoryOpenError>)> {
Process32Iterator::new().filter_map(|p| p.get_name().to_str().and_then(|name|
match name {
"Dolphin.exe" => Some((p.pid(), DolphinMemory::open_pid(p.pid()))),
_ => None,
}
))
}
}

63
src/main.rs Normal file
View file

@ -0,0 +1,63 @@
/// SPDX-FileCopyrightText: 2023 sup39 <sms@sup39.dev>
/// SPDX-License-Identifier: MIT OR Apache-2.0
pub mod dolphin;
pub mod addr;
pub mod big_endian;
pub mod sys;
pub mod sms;
pub mod server;
pub mod obj_params;
use std::net::{IpAddr, SocketAddr};
use std::path::PathBuf;
use clap::Parser;
#[derive(Parser)]
struct Args {
#[arg(long, default_value_t = IpAddr::V4(std::net::Ipv4Addr::LOCALHOST))]
host: IpAddr,
#[arg(short='p', long, default_value_t = 35353)]
port: u16,
#[arg(long)]
no_browser: bool,
#[arg(short='d', long)]
root_dir: Option<PathBuf>,
}
#[tokio::main]
async fn main() {
let args = Args::parse();
let listener = {
let mut sock_addr = SocketAddr::new(args.host, args.port);
match tokio::net::TcpListener::bind(&sock_addr).await {
Ok(listener) => {
listener
},
Err(err) => {
println!("Failed to listen on {sock_addr}: {err}\nTrying other port...\n");
sock_addr.set_port(0);
tokio::net::TcpListener::bind(&sock_addr).await.unwrap()
},
}
};
let url = format!("http://{}", listener.local_addr().unwrap());
println!("Listening on {url}");
if !args.no_browser {
let _ = open::that(url);
}
let root_dir = args.root_dir
.unwrap_or_else(|| {
let mut path = std::env::current_exe().unwrap();
path.pop();
path
}).canonicalize().unwrap().into_boxed_path();
server::http::serve(listener, root_dir).await.unwrap();
}

View file

@ -0,0 +1,76 @@
use crate::{
addr::Addr,
big_endian::DecodeBE,
dolphin::Dolphin,
sms::SMSDolphin,
};
use std::marker::PhantomData;
pub trait FieldReader<D: Dolphin, T> {
fn read(&self, d: &D, addr: Addr) -> Option<T>;
}
pub struct PrimitiveFieldReader<T> {
phantom: PhantomData<T>,
}
impl<T> PrimitiveFieldReader<T> {
pub fn new() -> Self {
Self {
phantom: PhantomData,
}
}
}
impl<D: Dolphin, T: DecodeBE> FieldReader<D, T> for PrimitiveFieldReader<T> {
fn read(&self, d: &D, addr: Addr) -> Option<T> {
d.read::<T>(addr)
}
}
impl<D: Dolphin, T: DecodeBE + ToString> FieldReader<D, String> for PrimitiveFieldReader<T> {
fn read(&self, d: &D, addr: Addr) -> Option<String> {
d.read::<T>(addr).map(|x| x.to_string())
}
}
pub struct F32FieldReader;
impl<D: Dolphin> FieldReader<D, f32> for F32FieldReader {
fn read(&self, d: &D, addr: Addr) -> Option<f32> {
d.read::<f32>(addr)
}
}
impl<D: Dolphin> FieldReader<D, String> for F32FieldReader {
fn read(&self, d: &D, addr: Addr) -> Option<String> {
d.read::<f32>(addr).map(|x| match x.abs() {
m if (1e-4..1e8).contains(&m) || m == 0f32 => {
let s = format!("{x}");
match m < 8388608f32 && s.contains('.') {
true => s,
false => s+".0",
}
},
_ => format!("{x:e}"),
})
}
}
pub struct StringFieldReader;
impl<D: Dolphin> FieldReader<D, String> for StringFieldReader {
fn read(&self, d: &D, addr: Addr) -> Option<String> {
d.read::<Addr>(addr).and_then(|a| d.read_str(a))
}
}
pub struct ClassNameReader;
impl FieldReader<SMSDolphin, String> for ClassNameReader {
fn read(&self, d: &SMSDolphin, addr: Addr) -> Option<String> {
d.read::<Addr>(addr)
.map(|addr| d.get_class_string(addr))
}
}
pub struct HexFieldReader(pub usize);
impl<D: Dolphin> FieldReader<D, String> for HexFieldReader {
fn read(&self, d: &D, addr: Addr) -> Option<String> {
d.read_bytes(addr, self.0)
.map(|bytes| bytes.iter().map(|x| format!("{x:02X}")).collect())
}
}

286
src/obj_params/mod.rs Normal file
View file

@ -0,0 +1,286 @@
/// SPDX-FileCopyrightText: 2023 sup39 <sms@sup39.dev>
/// SPDX-License-Identifier: MIT OR Apache-2.0
use std::fs::{read_dir, File};
use std::io::BufReader;
use std::sync::Arc;
use std::borrow::Cow;
use std::collections::HashMap;
use std::path::Path;
use serde_json;
use serde::{Deserialize, Deserializer, de::{self, Visitor}};
use crate::{
addr::{Addr, AddrOffsets},
big_endian::DecodeBE,
dolphin::Dolphin,
sms::SMSDolphin,
};
mod field_reader;
use field_reader::*;
/**** original json ****/
#[derive(Debug, Deserialize)]
struct ObjParamsJson {
offsets: Box<[ObjParamsOffsetEntry]>,
}
#[derive(Debug, Deserialize)]
struct ObjParamsOffsetEntry {
#[serde(deserialize_with = "deserialize_obj_params_offset_entry")]
offset: AddrOffsets,
#[serde(rename = "type")]
type_: Arc<str>,
name: Arc<str>,
notes: Arc<str>,
#[serde(
default = "ObjParamsOffsetEntryFormat::none",
deserialize_with = "deserialize_obj_params_offset_entry_format",
)]
format: Option<ObjParamsOffsetEntryFormat>,
hidden: Option<bool>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum ObjParamsOffsetEntryFormat {
Hex,
}
impl ObjParamsOffsetEntryFormat {
#[inline]
fn none() -> Option<ObjParamsOffsetEntryFormat> {
None
}
}
impl std::fmt::Display for ObjParamsOffsetEntryFormat {
fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
match self {
Self::Hex => write!(fmt, "hex"),
}
}
}
fn deserialize_obj_params_offset_entry<'de, D>(deserializer: D) -> Result<AddrOffsets, D::Error>
where
D: Deserializer<'de>,
{
struct ValueVisitor;
impl<'de> Visitor<'de> for ValueVisitor {
type Value = AddrOffsets;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a hex string or a non-empty array of hex string")
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
u32::from_str_radix(value, 16)
.map_err(|e| E::custom(e))
.map(|x| AddrOffsets(x, Box::from([])))
}
fn visit_seq<S>(self, mut seq: S) -> Result<Self::Value, S::Error>
where
S: de::SeqAccess<'de>,
{
let mut arr = match seq.size_hint() {
Some(size) => Vec::<u32>::with_capacity(size),
None => Vec::<u32>::new(),
};
while let Some(value) = seq.next_element::<Cow<'de, str>>()? {
arr.push(u32::from_str_radix(&value, 16).map_err(de::Error::custom)?);
}
arr.split_first()
.map(|p| AddrOffsets(*p.0, p.1.into()))
.ok_or_else(|| de::Error::custom("Offset array must not be empty"))
}
}
deserializer.deserialize_any(ValueVisitor)
}
fn deserialize_obj_params_offset_entry_format<'de, D>(deserializer: D)
-> Result<Option<ObjParamsOffsetEntryFormat>, D::Error>
where
D: Deserializer<'de>,
{
struct ValueVisitor;
impl<'de> Visitor<'de> for ValueVisitor {
type Value = Option<ObjParamsOffsetEntryFormat>;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("\"hex\" or omitted")
}
fn visit_none<E>(self) -> Result<Self::Value, E> {
Ok(None)
}
fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
match value {
"hex" => Ok(Some(ObjParamsOffsetEntryFormat::Hex)),
_ => Err(E::unknown_variant(value, &["hex"])),
}
}
}
deserializer.deserialize_any(ValueVisitor)
}
/**** parsed ****/
pub enum ObjectType<D: Dolphin> {
Primitive(ClassFieldReader<D>),
Class(Box<[ClassField<D>]>),
}
impl<D: Dolphin> ObjectType<D> {
fn new_primitive<T: DecodeBE + ToString + Send + Sync + 'static>() -> Self {
ObjectType::<D>::Primitive(Arc::new(PrimitiveFieldReader::<T>::new()))
}
}
type ClassFieldReader<D> = Arc<dyn FieldReader<D, String> + Send + Sync>;
pub struct ClassField<D: Dolphin> {
pub offset: AddrOffsets,
pub type_: Arc<str>,
pub name: Arc<str>,
pub notes: Arc<str>,
pub class: Arc<str>,
pub reader: ClassFieldReader<D>,
}
impl<D: Dolphin> FieldReader<D, String> for ClassField<D> {
#[inline]
fn read(&self, d: &D, addr: Addr) -> Option<String> {
self.reader.read(d, addr)
}
}
pub type ObjParams<D> = HashMap<Arc<str>, ObjectType<D>>;
pub type ObjParamsLoadResult<D> = Result<ObjParams<D>, std::io::Error>;
pub fn load_obj_params(dir: &Path) -> ObjParamsLoadResult<SMSDolphin> {
type D = SMSDolphin; // TODO
let entry_reader = read_dir(dir)?;
let mut db_raw = HashMap::<Arc<str>, Arc<ObjParamsJson>>::new();
entry_reader.for_each(|entry| {
let Ok(entry) = entry.map_err(|e| eprintln!("Fail to get entry: {e}")) else {return};
let path = entry.path();
if Some(true) != path.extension().map(|e| e == "json") {return}
let Ok(file) = File::open(&path)
.map_err(|e| eprintln!("Fail to open file \"{}\": {e}", path.to_string_lossy())) else {return};
let reader = BufReader::new(file);
let Ok(o) = serde_json::from_reader::<_, HashMap<Arc<str>, Arc<ObjParamsJson>>>(reader)
.map_err(|e| eprintln!("Fail to parse {}: {e}", path.to_string_lossy())) else {return};
for e in o {
db_raw.insert(e.0.clone(), e.1.clone());
}
});
struct Env<'a, D: Dolphin> {
db_raw: &'a HashMap::<Arc<str>, Arc<ObjParamsJson>>,
db_types: HashMap::<Arc<str>, ObjectType<D>>,
db_formatted: HashMap::<(&'a str, ObjParamsOffsetEntryFormat), ClassFieldReader<D>>,
reader_unk: ClassFieldReader<D>,
type_addr: ObjectType<D>,
}
fn resolve_type<'a, D: Dolphin>(env: &'a mut Env<D>, type_: Arc<str>) -> &'a ObjectType<D> {
if !env.db_types.contains_key(&type_) {
if type_.ends_with('*') {
return &env.type_addr;
}
let new_type = match env.db_raw.get(&type_) {
Some(o) => {
let mut class_fields = Vec::<ClassField<D>>::new();
for field in o.offsets.iter() {
// skip hidden fields
if let Some(true) = field.hidden {continue}
// format
if let Some(format) = field.format {
if let Some(reader) = env.db_formatted.get(&(&field.type_, format)) {
class_fields.push(
ClassField {
reader: reader.clone(),
offset: field.offset.clone(),
name: field.name.clone(),
notes: field.notes.clone(),
type_: field.type_.clone(),
class: type_.clone(),
},
);
continue;
} else {
eprintln!("format \"{format}\" cannot be used for type \"{}\" (in class \"{type_}\")", field.type_);
}
}
// resolve
match resolve_type(env, field.type_.clone()) {
ObjectType::<D>::Primitive(reader) => class_fields.push(
ClassField {
reader: reader.clone(),
offset: field.offset.clone(),
name: field.name.clone(),
notes: field.notes.clone(),
type_: field.type_.clone(),
class: type_.clone(),
},
),
ObjectType::<D>::Class(subfields) => {
let is_name_template = field.name.contains('*');
for subfield in subfields.iter() {
class_fields.push(ClassField {
reader: subfield.reader.clone(),
offset: &field.offset + &subfield.offset,
name: match is_name_template {
true => Arc::from(field.name.replace('*', &subfield.name)),
false => subfield.name.clone(),
},
notes: subfield.notes.clone(),
type_: subfield.type_.clone(),
class: subfield.class.clone(),
})
}
},
};
}
ObjectType::<D>::Class(class_fields.into())
},
None => {
ObjectType::<D>::Primitive(env.reader_unk.clone())
},
};
env.db_types.insert(type_.clone(), new_type);
}
env.db_types.get(&type_).unwrap()
}
let mut env = Env {
db_raw: &db_raw,
db_types: HashMap::<Arc<str>, ObjectType<D>>::from([
(Arc::from("u8"), ObjectType::<D>::new_primitive::<u8>()),
(Arc::from("u16"), ObjectType::<D>::new_primitive::<u16>()),
(Arc::from("u32"), ObjectType::<D>::new_primitive::<u32>()),
(Arc::from("s8"), ObjectType::<D>::new_primitive::<i8>()),
(Arc::from("s16"), ObjectType::<D>::new_primitive::<i16>()),
(Arc::from("s32"), ObjectType::<D>::new_primitive::<i32>()),
(Arc::from("float"), ObjectType::<D>::Primitive(Arc::new(F32FieldReader))),
(Arc::from("string"), ObjectType::<D>::Primitive(Arc::new(StringFieldReader))),
(Arc::from("void*"), ObjectType::<D>::Primitive(Arc::new(ClassNameReader))),
]),
db_formatted: HashMap::from([
(
("u8", ObjParamsOffsetEntryFormat::Hex),
Arc::new(HexFieldReader(1)) as ClassFieldReader<D>,
),
(
("u16", ObjParamsOffsetEntryFormat::Hex),
Arc::new(HexFieldReader(2)) as ClassFieldReader<D>,
),
(
("u32", ObjParamsOffsetEntryFormat::Hex),
Arc::new(HexFieldReader(4)) as ClassFieldReader<D>,
),
]),
reader_unk: Arc::new(PrimitiveFieldReader::<Addr>::new()), // TODO
type_addr: ObjectType::<D>::new_primitive::<Addr>(),
};
for type_ in db_raw.keys() {
resolve_type(&mut env, type_.clone());
}
Ok(env.db_types)
}

280
src/server/api.rs Normal file
View file

@ -0,0 +1,280 @@
/// SPDX-FileCopyrightText: 2023 sup39 <sms@sup39.dev>
/// SPDX-License-Identifier: MIT OR Apache-2.0
use crate::{
addr::{Addr, AddrOffsets},
dolphin::Dolphin,
sms::{SMSDolphin, SMSVersion},
big_endian::DecodeBE,
server::http::HttpEnv,
obj_params::{load_obj_params, ObjectType},
};
use sup_smsac_derive::DecodeBE;
use serde_json::{self, json, Value as JsonValue};
#[derive(Debug, DecodeBE)]
struct ConductorNode {
next: Addr,
_prev: Addr,
obj: Addr,
}
#[derive(Debug, DecodeBE)]
struct ChildInfo {
count: u32,
addr: Addr,
}
trait DolphinMemoryJsExt {
fn resolve_addr(&self, addr: &JsonValue) -> Result<Option<Addr>, ()>;
fn resolve_addr_offsets(&self, base: Addr, offsets: &AddrOffsets) -> Option<Addr>;
}
impl<T: Dolphin> DolphinMemoryJsExt for T {
fn resolve_addr(&self, addr: &JsonValue) -> Result<Option<Addr>, ()> {
// single addr
if let Some(addr) = addr.as_u64() {
return Ok(Some(Addr(addr as u32)));
}
// addr + offsets
let Some((Some(mut addr), offs)) = addr.as_array()
.and_then(|x| x.split_first())
.map(|e| (e.0.as_u64().map(|x| Addr(x as u32)), e.1))
else {return Err(())};
// resolve
for off in offs {
let Some(off) = off.as_i64().map(|x| x as u32) else {
return Err(());
};
match self.read::<Addr>(addr) {
None => return Ok(None),
Some(_addr) => addr = _addr+off,
}
}
Ok(Some(addr))
}
fn resolve_addr_offsets(&self, base: Addr, offsets: &AddrOffsets) -> Option<Addr> {
let mut addr = base + offsets.0;
for off in offsets.1.iter() {
match self.read::<Addr>(addr) {
None => return None,
Some(_addr) => addr = _addr + *off,
}
}
Some(addr)
}
}
pub async fn handle_command(
env: &HttpEnv,
dolphin: &mut Option<SMSDolphin>,
command: &str,
body: &JsonValue,
) -> Result<JsonValue, JsonValue> {
macro_rules! return_err {
($($msg:expr),+) => {
return Err(json!(format!($($msg),+)))
}
}
macro_rules! let_dolphin {
($d: ident) => {
let $d = match &dolphin {
Some(d) => d,
None => match SMSDolphin::find_one() {
Ok(d) => {
*dolphin = Some(d);
match &dolphin {
Some(d) => d,
None => unreachable!(),
}
},
Err(e) => return_err!("{}", e),
},
};
};
}
macro_rules! let_obj_params_fields {
($fields:ident, $type: ident) => {
let lock_obj_params = env.obj_params_result.lock().await;
let obj_params = match &*lock_obj_params {
Ok(v) => v,
Err(e) => return_err!("Fail to get ObjectParameters: {e}"),
};
let Some($fields) = obj_params.get($type).or_else(|| obj_params.get("_default")) else {
return_err!("unknown type: \"{}\". Please defined \"_default\" type in ObjectParameters/*.json", $type);
};
};
}
match command {
"init" => {
let_dolphin!(d);
Ok(json!(d.pid()))
},
"getManagers" => {
let_dolphin!(d);
Ok(d.read::<Addr>(Addr(match d.ver() {
// TODO put addr in external file
SMSVersion::GMSJ01 => 0x8040A6E8,
SMSVersion::GMSE01 => 0x8040D110,
SMSVersion::GMSP01 => 0x80404870,
SMSVersion::GMSJ0A => 0x803FE048,
}))
.and_then(|a| d.read::<ChildInfo>(a+0x14))
.and_then(|o| {
let mut next = o.addr;
let mut arr: Vec<JsonValue> = Vec::with_capacity(o.count as usize);
for _i in 0..o.count {
let Some(node) = d.read::<ConductorNode>(next) else {return None};
arr.push(json!([
node.obj.0,
d.read::<Addr>(node.obj)
.map(|a| d.get_class_string(a))
.unwrap_or_else(|| "({a})".to_string()),
d.read::<Addr>(node.obj+4).and_then(|a| d.read_str(a)).unwrap_or_else(|| "<EFBFBD>".to_string()),
d.read::<i32>(node.obj+0x14).unwrap_or(-1),
]));
next = node.next;
}
Some(JsonValue::Array(arr))
}).unwrap_or_else(|| json!(null))
)
},
"getManagees" => {
let_dolphin!(d);
let Some(addr) = body.as_u64().map(|x| Addr(x as u32)) else {
return_err!("\"body\" must be a string");
};
Ok(d.read::<ChildInfo>(addr+0x14).and_then(|o| {
let mut arr: Vec<JsonValue> = Vec::with_capacity(o.count as usize);
for a in (0..o.count).map(|i| d.read::<Addr>(o.addr+4*i)) {
let Some(a) = a else {return None};
arr.push(json!([
a.0,
d.read::<Addr>(a).map(|a| d.get_class_string(a)),
d.read::<Addr>(a+4).and_then(|a| d.read_str(a)).unwrap_or_else(|| "<EFBFBD>".to_string()),
]));
}
Some(JsonValue::Array(arr))
}).unwrap_or_else(|| json!(null)))
},
"read" => {
let_dolphin!(d);
let Some(addr) = body.get("addr") else {
return_err!("addr must be specified");
};
let Ok(addr) = d.resolve_addr(addr) else {
return_err!("invalid addr: {addr:?}");
};
let Some(addr) = addr else {
return Ok(json!(null));
};
Ok(match body.get("size") {
Some(size) => {
if body.get("type").is_some() {
return_err!("\"size\" and \"type\" cannot be specified at the same time");
}
let Some(size) = size.as_u64().map(|x| x as usize) else {
return_err!("\"size\" must be a positive integer");
};
d.read_bytes(addr, size)
.map(|bytes| json!(bytes.iter().map(|x| format!("{:02X}", x)).collect::<String>()))
.unwrap_or_else(|| json!(null))
},
None => {
let Some(type_) = body.get("type") else {
return_err!("either \"size\" and \"type\" must be specified");
};
let Some(type_) = type_.as_str() else {
return_err!("\"type\" must be a string");
};
let_obj_params_fields!(fields, type_);
match fields {
ObjectType::Primitive(p) => p.read(d, addr)
.map(|x| json!(x))
.unwrap_or_else(|| json!(null)),
ObjectType::Class(fields) => JsonValue::Array(fields.iter().map(|field| {
d.resolve_addr_offsets(addr, &field.offset)
.map(|addr| field.reader.read(d, addr))
.map(|x| json!(x))
.unwrap_or_else(|| json!(null))
}).collect()),
}
},
})
},
"readString" => {
let_dolphin!(d);
let Ok(addr) = body.get("addr").ok_or(()).and_then(|o| d.resolve_addr(o)) else {
return_err!("Invalid body: {body:?}");
};
Ok(addr
.and_then(|addr| d.read_str(addr))
.map(|addr| json!(addr))
.unwrap_or_else(|| json!(null)))
},
"write" => {
let_dolphin!(d);
let (Ok(addr), Ok(payload)) = (
body.get("addr").ok_or(()).and_then(|o| d.resolve_addr(o)),
body.get("payload")
.and_then(|x| x.as_str()).ok_or(())
.and_then(|s| (0..s.len()).step_by(2)
.map(|i| u8::from_str_radix(&s[i..i+2], 16).map_err(|_| ()))
.collect::<Result<Vec<u8>, ()>>()
),
) else {
return_err!("Invalid body: {body:?}");
};
Ok(json!(
addr.and_then(|addr| d.write_bytes(addr, &payload)).is_some()
))
},
"getClass" => {
let_dolphin!(d);
let Ok(addr) = body.get("addr").ok_or(()).and_then(|o| d.resolve_addr(o)) else {
return_err!("Invalid body: {body:?}");
};
Ok(addr
.and_then(|addr| d.read::<Addr>(addr))
.map(|a| json!(d.get_class_string(a)))
.unwrap_or_else(|| json!(null)))
},
"getFields" => {
let Some(type_) = body.as_str() else {
return_err!("body must be a string");
};
let_obj_params_fields!(fields, type_);
Ok(match fields {
// [offsets, name, value, notes, type, class]
ObjectType::Primitive(_) =>
json!([["0", "value", "", type_, type_]]),
ObjectType::Class(fields) => JsonValue::Array(fields.iter().map(|r| json!([
r.offset.to_string(), r.name, r.notes, r.type_, r.class,
])).collect()),
})
},
"reload" => {
let mut lock_obj_params = env.obj_params_result.lock().await;
load_obj_params(&env.obj_params_dir)
.map(|db| {
*lock_obj_params = Ok(db);
json!(null)
})
.map_err(|e| json!(e.to_string()))
},
_ => {
return_err!("Unknown command: {command}")
},
}
}

138
src/server/http.rs Normal file
View file

@ -0,0 +1,138 @@
/// SPDX-FileCopyrightText: 2023 sup39 <sms@sup39.dev>
/// SPDX-License-Identifier: MIT OR Apache-2.0
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::net::TcpListener;
use tokio::fs::File;
use tokio_util::codec::{BytesCodec, FramedRead};
use hyper::{Body, Request, Response, StatusCode};
use hyper_tungstenite::tungstenite;
use urlencoding;
use mime_guess;
use crate::{
sms::SMSDolphin,
obj_params::{load_obj_params, ObjParamsLoadResult},
server::ws::serve_websocket,
};
pub struct HttpEnv {
static_dir: Box<Path>,
pub obj_params_dir: Box<Path>,
pub obj_params_result: Mutex<ObjParamsLoadResult<SMSDolphin>>,
}
pub async fn serve(listener: TcpListener, root_dir: Box<Path>) -> Result<(), tungstenite::Error> {
let obj_params_dir = {
let mut dir = root_dir.to_path_buf();
dir.push("res/ObjectParameters");
dir.into_boxed_path()
};
let obj_params_result = Mutex::new(load_obj_params(&obj_params_dir));
let env = Arc::new(HttpEnv {
static_dir: {
let mut static_dir = root_dir.to_path_buf();
static_dir.push("www");
static_dir.into_boxed_path()
},
obj_params_dir,
obj_params_result,
});
let http = hyper::server::conn::Http::new();
loop {
let env = env.clone();
let (stream, _) = listener.accept().await?;
let connection = http
.serve_connection(stream, hyper::service::service_fn(move |req| handle_request(req, env.clone())))
.with_upgrades();
tokio::spawn(async move {
if let Err(err) = connection.await {
println!("Error serving HTTP connection: {:?}", err);
}
});
}
}
#[inline]
fn response_text<T>(status: T, e: &dyn std::fmt::Display) -> Response<Body>
where
StatusCode: TryFrom<T>,
<StatusCode as TryFrom<T>>::Error: Into<hyper::http::Error>,
{
Response::builder()
.status(status)
.header("Content-Type", "text/plain; charset=utf-8")
.body(Body::from(format!("{}", e)))
.unwrap()
}
async fn handle_request(
mut req: Request<Body>,
env: Arc<HttpEnv>,
) -> Result<Response<Body>, tungstenite::Error> {
let is_upgrade = hyper_tungstenite::is_upgrade_request(&req);
let mut lock_obj_params = env.obj_params_result.lock().await;
if lock_obj_params.is_err() {
*lock_obj_params = load_obj_params(&env.obj_params_dir);
if let Err(e) = &*lock_obj_params {
return Ok(response_text(500, &format!(
"Fail to load ObjectParameters at {}: {e}",
env.obj_params_dir.to_string_lossy(),
)));
}
}
if is_upgrade {
let (res, ws) = hyper_tungstenite::upgrade(&mut req, None)?;
let env = env.clone();
tokio::spawn(async move {
if let Err(e) = serve_websocket(ws, env).await {
eprintln!("Error in websocket connection: {}", e);
}
});
Ok(res)
} else {
let url_path = match urlencoding::decode(req.uri().path()) {
Ok(p) if p.starts_with('/') => p,
_ => return Ok(Response::builder().status(400).body(Body::empty()).unwrap()),
};
let mut file_path = PathBuf::new();
file_path.push(&*env.static_dir);
file_path.push(if url_path.ends_with('/') {
"index.html"
} else {
&url_path[1..]
});
Ok(serve_file(&file_path).await)
}
}
async fn serve_file(path: &Path) -> Response<Body> {
match File::open(path).await {
Err(err) => response_text(404, &match path.canonicalize() {
Ok(e) => format!("\"{}\" not found: {err}", e.to_string_lossy()),
Err(_) => {
match std::env::current_dir() {
Ok(mut cwd) => {
cwd.push(path);
format!("\"{}\" not found: {err}", cwd.to_string_lossy())
},
Err(_) => format!("\"{}\" not found: {err}", path.to_string_lossy()),
}
},
}),
Ok(file) => {
let mut res = Response::builder();
if let Some(mime) = mime_guess::from_path(path).first() {
res = res.header("Content-Type", format!("{mime}; charset=utf-8"));
}
let stream = FramedRead::new(file, BytesCodec::new());
res.body(Body::wrap_stream(stream)).unwrap()
},
}
}

6
src/server/mod.rs Normal file
View file

@ -0,0 +1,6 @@
/// SPDX-FileCopyrightText: 2023 sup39 <sms@sup39.dev>
/// SPDX-License-Identifier: MIT OR Apache-2.0
pub mod http;
pub mod ws;
pub mod api;

63
src/server/ws.rs Normal file
View file

@ -0,0 +1,63 @@
/// SPDX-FileCopyrightText: 2023 sup39 <sms@sup39.dev>
/// SPDX-License-Identifier: MIT OR Apache-2.0
use crate::{
sms::SMSDolphin,
server::{http::HttpEnv, api::handle_command},
};
use std::sync::Arc;
use futures_util::{SinkExt, StreamExt};
use hyper_tungstenite::{tungstenite::{self, Message}, HyperWebsocket};
use serde_json::{self, json, Value as JsonValue};
pub async fn serve_websocket(
ws: HyperWebsocket,
env: Arc<HttpEnv>,
) -> Result<(), tungstenite::Error> {
let mut ws = ws.await?;
let mut dolphin: Option<SMSDolphin> = None;
macro_rules! return_err {
($($msg:expr),+) => {
eprintln!($($msg),+);
return None;
}
}
while let Some(msg) = ws.next().await {
let Ok(msg) = msg else {continue};
if let Some(res) = (|| async {match msg {
Message::Text(payload) => {
let Ok(payload) = serde_json::from_str::<JsonValue>(&payload) else {
eprintln!("Invalid payload (failed to deserialize): {payload}");
return None;
};
let Some((Some(id), Some(command), body)) = payload.as_array()
.and_then(|v| if v.len() == 3 {Some(v)} else {None})
.map(|args| (
// id must be positive
args[0].as_i64().and_then(|x| if x<=0 {None} else {Some(x)}),
args[1].as_str(),
&args[2],
))
else {
return_err!("Invalid payload (invalid format): {payload}");
};
match handle_command(&env, &mut dolphin, command, body).await {
Ok(body) => Some(json!([id, body])),
Err(msg) => Some(json!([-id, msg])),
}
},
Message::Binary(payload) => {
Some(json!(format!("{}", payload.len())))
},
_ => None,
}})().await {
if let Err(e) = ws.send(Message::Text(res.to_string())).await {
eprintln!("Fail to send message: {e}");
}
}
}
Ok(())
}

92
src/sms/mod.rs Normal file
View file

@ -0,0 +1,92 @@
/// SPDX-FileCopyrightText: 2023 sup39 <sms@sup39.dev>
/// SPDX-License-Identifier: MIT OR Apache-2.0
#[derive(Debug, Clone, Copy)]
pub enum SMSVersion {
GMSJ01, GMSE01, GMSP01, GMSJ0A,
}
pub mod vt;
use crate::addr::Addr;
use crate::dolphin::{DolphinMemory, Dolphin};
pub struct SMSDolphin {
d: DolphinMemory,
ver: SMSVersion,
}
impl Dolphin for SMSDolphin {
#[inline]
unsafe fn mem<T: Into<isize>>(&self, offset: T) -> *mut u8 {
self.d.mem(offset)
}
}
impl SMSDolphin {
#[inline]
pub fn pid(&self) -> usize {
self.d.pid()
}
#[inline]
pub fn ver(&self) -> SMSVersion {
self.ver
}
pub fn from_dolphin_memory(d: DolphinMemory) -> Result<SMSDolphin, Option<[u8; 8]>> {
match d.read::<&[u8; 8]>(Addr(0x80000000)) {
None => Err(None),
Some(rver) => match rver {
b"GMSJ01\x00\x00" => Ok(SMSVersion::GMSJ01),
b"GMSE01\x00\x30" => Ok(SMSVersion::GMSE01),
b"GMSP01\x00\x00" => Ok(SMSVersion::GMSP01),
b"GMSJ01\x00\x01" => Ok(SMSVersion::GMSJ0A),
_ => Err(Some(rver.to_owned())),
}.map(|ver| SMSDolphin {d, ver}),
}
}
pub fn get_class(&self, addr: Addr) -> Option<&'static str> {
vt::get_class(self.ver, addr)
}
pub fn get_class_string(&self, addr: Addr) -> String {
vt::get_class_string(self.ver, addr)
}
}
#[derive(Debug)]
pub enum SMSDolphinFindOneError {
DolphinNotRunning,
NoGameRunning,
SMSNotRunning,
}
impl std::fmt::Display for SMSDolphinFindOneError {
fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
match self {
SMSDolphinFindOneError::DolphinNotRunning => write!(fmt, "Dolphin is not running"),
SMSDolphinFindOneError::NoGameRunning => write!(fmt, "Dolphin is found, but no game is running"),
SMSDolphinFindOneError::SMSNotRunning => write!(fmt, "SMS is not running"),
}
}
}
impl SMSDolphin {
pub fn find_one() -> Result<SMSDolphin, SMSDolphinFindOneError> {
let mut dolphin_running = false;
let mut game_running = false;
for (_pid, d) in DolphinMemory::list_dolphin() {
match d {
Ok(d) => {
if let Ok(o) = SMSDolphin::from_dolphin_memory(d) {
return Ok(o)
}
game_running = true;
},
Err(_) => dolphin_running = true,
}
}
Err(if game_running {
SMSDolphinFindOneError::SMSNotRunning
} else if dolphin_running {
SMSDolphinFindOneError::NoGameRunning
} else {
SMSDolphinFindOneError::DolphinNotRunning
})
}
}

1514
src/sms/vt/GMSE01.json Normal file

File diff suppressed because it is too large Load diff

1510
src/sms/vt/GMSJ01.json Normal file

File diff suppressed because it is too large Load diff

1471
src/sms/vt/GMSJ0A.json Normal file

File diff suppressed because it is too large Load diff

1472
src/sms/vt/GMSP01.json Normal file

File diff suppressed because it is too large Load diff

21
src/sms/vt/mod.rs Normal file
View file

@ -0,0 +1,21 @@
/// SPDX-FileCopyrightText: 2023 sup39 <sms@sup39.dev>
/// SPDX-License-Identifier: MIT OR Apache-2.0
use crate::addr::Addr;
use crate::sms::SMSVersion;
use sup_smsac_derive::match_class_from_json;
pub fn get_class(ver: SMSVersion, addr: Addr) -> Option<&'static str> {
match ver {
SMSVersion::GMSJ01 => match_class_from_json!("src/sms/vt/GMSJ01.json")(addr.0),
SMSVersion::GMSE01 => match_class_from_json!("src/sms/vt/GMSE01.json")(addr.0),
SMSVersion::GMSP01 => match_class_from_json!("src/sms/vt/GMSP01.json")(addr.0),
SMSVersion::GMSJ0A => match_class_from_json!("src/sms/vt/GMSJ0A.json")(addr.0),
}
}
pub fn get_class_string(ver: SMSVersion, addr: Addr) -> String {
match get_class(ver, addr) {
Some(s) => s.to_string(),
None => format!("({addr})"),
}
}

5
src/sys/mod.rs Normal file
View file

@ -0,0 +1,5 @@
/// SPDX-FileCopyrightText: 2023 sup39 <sms@sup39.dev>
/// SPDX-License-Identifier: MIT OR Apache-2.0
pub mod process;
pub mod shared_memory;

65
src/sys/process.rs Normal file
View file

@ -0,0 +1,65 @@
/// SPDX-FileCopyrightText: 2023 sup39 <sms@sup39.dev>
/// SPDX-License-Identifier: MIT OR Apache-2.0
use std::ffi::OsString;
use std::os::windows::ffi::OsStringExt;
use windows::Win32::Foundation::{HANDLE, BOOL};
use windows::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot,
TH32CS_SNAPPROCESS,
PROCESSENTRY32W,
Process32FirstW,
Process32NextW,
};
pub struct Process32Iterator {
hsnapshot: HANDLE,
fn_next: unsafe fn(HANDLE, *mut PROCESSENTRY32W) -> BOOL,
}
// https://learn.microsoft.com/en-us/windows/win32/api/tlhelp32/nf-tlhelp32-createtoolhelp32snapshot
impl Process32Iterator {
pub fn new() -> Self {
Process32Iterator {
hsnapshot: unsafe {CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0).unwrap()},
fn_next: Process32FirstW,
}
}
}
impl Default for Process32Iterator {
fn default() -> Self {
Self::new()
}
}
impl Iterator for Process32Iterator {
type Item = PROCESSENTRY32W;
fn next(&mut self) -> Option<PROCESSENTRY32W> {
let mut lppe = PROCESSENTRY32W {
dwSize: std::mem::size_of::<PROCESSENTRY32W>() as u32,
..Default::default()
};
match unsafe {(self.fn_next)(self.hsnapshot, &mut lppe)}.as_bool() {
false => None,
true => {
self.fn_next = Process32NextW;
Some(lppe)
},
}
}
}
pub type PidType = usize;
pub trait ProcessInfo {
fn pid(&self) -> PidType;
fn get_name(&self) -> OsString;
}
impl ProcessInfo for PROCESSENTRY32W {
fn pid(&self) -> PidType {
self.th32ProcessID as PidType
}
fn get_name(&self) -> OsString {
let len = self.szExeFile.iter().position(|p| *p==0).unwrap_or(self.szExeFile.len());
OsString::from_wide(&self.szExeFile[..len])
}
}

66
src/sys/shared_memory.rs Normal file
View file

@ -0,0 +1,66 @@
/// SPDX-FileCopyrightText: 2023 sup39 <sms@sup39.dev>
/// SPDX-License-Identifier: MIT OR Apache-2.0
use windows::core::PCSTR;
use windows::Win32::Foundation::{HANDLE, CloseHandle};
use windows::Win32::System::Memory::{
OpenFileMappingA,
FILE_MAP_ALL_ACCESS,
MapViewOfFile,
UnmapViewOfFile,
MEMORYMAPPEDVIEW_HANDLE,
};
#[derive(Debug)]
pub enum SharedMemoryOpenError {
OpenFileFailure(String),
MapViewFailure(String),
MemoryUninitialized,
}
pub struct SharedMemory {
h_file_mapping: HANDLE,
h_map_view: MEMORYMAPPEDVIEW_HANDLE,
}
impl SharedMemory {
#[inline]
pub fn get_ptr(&self) -> *mut u8 {
self.h_map_view.0 as *mut u8
}
}
impl SharedMemory {
pub fn open(name: &str) -> Result<Self, SharedMemoryOpenError> {
let name = name.to_owned() + "\0";
// open file mapping
let h_file_mapping = unsafe {
OpenFileMappingA(FILE_MAP_ALL_ACCESS.0, false, PCSTR::from_raw(name.as_ptr()))
.map_err(|e| SharedMemoryOpenError::OpenFileFailure(e.message().to_string()))?
};
// create map view
let h_map_view = unsafe {
MapViewOfFile(h_file_mapping, FILE_MAP_ALL_ACCESS, 0, 0, 0).map_err(|e| {
CloseHandle(h_file_mapping);
SharedMemoryOpenError::MapViewFailure(e.message().to_string())
})?
};
if h_map_view.is_invalid() {
unsafe {CloseHandle(h_file_mapping)};
return Err(SharedMemoryOpenError::MemoryUninitialized);
}
// create SharedMemory successfully
Ok(Self {h_file_mapping, h_map_view})
}
}
impl Drop for SharedMemory {
fn drop(&mut self) {
unsafe {
UnmapViewOfFile(self.h_map_view);
CloseHandle(self.h_file_mapping);
}
}
}

76
sup-smsac-derive/Cargo.lock generated Normal file
View file

@ -0,0 +1,76 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "itoa"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38"
[[package]]
name = "proc-macro2"
version = "1.0.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b368fba921b0dce7e60f5e04ec15e565b3303972b42bcfde1d0713b881959eb"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "573015e8ab27661678357f27dc26460738fd2b6c86e46f386fde94cb5d913105"
dependencies = [
"proc-macro2",
]
[[package]]
name = "ryu"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741"
[[package]]
name = "serde"
version = "1.0.171"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30e27d1e4fd7659406c492fd6cfaf2066ba8773de45ca75e855590f856dc34a9"
[[package]]
name = "serde_json"
version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d03b412469450d4404fe8499a268edd7f8b79fecb074b0d812ad64ca21f4031b"
dependencies = [
"itoa",
"ryu",
"serde",
]
[[package]]
name = "sup-smsac-derive"
version = "0.1.0-beta.1"
dependencies = [
"quote",
"serde_json",
"syn",
]
[[package]]
name = "syn"
version = "1.0.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22049a19f4a68748a168c0fc439f9516686aa045927ff767eca0a85101fb6e73"

View file

@ -0,0 +1,14 @@
[package]
name = "sup-smsac-derive"
version = "0.1.0-beta.1"
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://github.com/sup39/sup-smsac"
[lib]
proc-macro = true
[dependencies]
syn = "1.0"
quote = "1.0"
serde_json = "1.0.103"

109
sup-smsac-derive/src/lib.rs Normal file
View file

@ -0,0 +1,109 @@
/// SPDX-FileCopyrightText: 2023 sup39 <sms@sup39.dev>
/// SPDX-License-Identifier: MIT OR Apache-2.0
use proc_macro::TokenStream;
use quote::quote;
use syn;
#[proc_macro_derive(DecodeBE)]
pub fn decode_be_derive(input: TokenStream) -> TokenStream {
let ast: syn::DeriveInput = syn::parse(input).expect("Fail to parse input token stream");
let syn::Data::Struct(data) = &ast.data else {
panic!("Only struct can derives DecodeBE");
};
let type_name = &ast.ident;
let type_generics = &ast.generics;
let type_generics_params = ast.generics.params.iter().map(|e| match e {
syn::GenericParam::Type(ty) => {let ident = &ty.ident; quote! {#ident}},
syn::GenericParam::Lifetime(ty) => {let lifetime = &ty.lifetime; quote! {#lifetime}},
syn::GenericParam::Const(ty) => {let ident = &ty.ident; quote! {#ident}},
});
let q_impl = quote! {impl #type_generics DecodeBE for #type_name<#(#type_generics_params),*>};
let fields = match &data.fields {
syn::Fields::Named(fields) => &fields.named,
syn::Fields::Unnamed(fields) => &fields.unnamed,
syn::Fields::Unit => panic!("Unit type cannot derive DecodeBE"),
};
let q_size = fields.iter().map(|e| {
let ty = &e.ty;
quote! {<#ty>::PACKED_SIZE}
});
// TODO empty struct
let mut ty0: Option<&syn::Type> = None;
let q_new_self = match &data.fields {
syn::Fields::Named(_) => {
let q_decode_fields = fields.iter().map(|e| {
let name = &e.ident;
let ty = &e.ty;
let ty_last = ty0;
ty0 = Some(ty);
if let Some(ty0) = ty_last {
quote! {#name: {ptr = ptr.add(#ty0::PACKED_SIZE); <#ty>::decode_be(ptr)}}
} else {
quote! {#name: <#ty>::decode_be(ptr)}
}
});
quote! {
Self{#(#q_decode_fields),*}
}
},
syn::Fields::Unnamed(_) => {
let q_decode_fields = fields.iter().map(|e| {
let ty = &e.ty;
let ty_last = ty0;
ty0 = Some(ty);
if let Some(ty0) = ty_last {
quote! {{ptr = ptr.add(#ty0::PACKED_SIZE); <#ty>::decode_be(ptr)}}
} else {
quote! {<#ty>::decode_be(ptr)}
}
});
quote! {
Self(#(#q_decode_fields),*)
}
},
syn::Fields::Unit => panic!("Unit type cannot derive DecodeBE"),
};
quote! {
#q_impl {
const PACKED_SIZE: usize = #(#q_size)+*;
unsafe fn decode_be(ptr: *const u8) -> Self {
let mut ptr: *const u8 = ptr;
#q_new_self
}
}
}.into()
}
use std::collections::HashMap;
use std::fs::File;
use std::io::BufReader;
#[proc_macro]
pub fn match_class_from_json(input: TokenStream) -> TokenStream {
let file_name = input.to_string();
let file_name = if file_name.starts_with('\"') && file_name.ends_with('\"') {
&file_name[1..file_name.len()-1]
} else {
&file_name
};
// let file = File::open(&file_name).unwrap();
// let cwd = std::env::current_dir().unwrap();
let file = File::open(&file_name).expect(&file_name);
let reader = BufReader::new(file);
let vt: HashMap<String, String> = serde_json::from_reader(reader)
.expect("The JSON file is not in the form of HashMap<String, String>");
let entries = vt.iter().map(|e| {
let (addr, name) = e;
let addr = u32::from_str_radix(addr, 16).unwrap();
quote! {#addr => Some(#name)}
});
quote! {
|x| match x {
#(#entries),*,
_ => None,
}
}.into()
}

5660
www/LICENSE.html Normal file

File diff suppressed because it is too large Load diff

137
www/api.js Normal file
View file

@ -0,0 +1,137 @@
// @ts-check
/**
* @typedef {number|number[]} ReqAddr
*/
/** @param {string} s */
const hex2dv = s => new DataView(Uint8Array.from(
(s.match(/../g) ?? /**@type{string[]}*/([]))
.map(s => parseInt(s, 16))).buffer
);
/**
* @param {{
* onClose?: null | ((this: WebSocket, ev: CloseEvent)=>void)
* }} options
*/
function Client({onClose = null}={}) {
/** @type {Map<number, {rsv: (res: any)=>void, rjt: (res: any)=>void}>} */
const reqs = new Map();
/** @type {WebSocket|null} */
let ws = null;
let nextId = 1;
/**
* @template T
* @param {string} action
* @param {any} payload
* @returns {Promise<T>}
*/
const request = (action, payload) => new Promise((rsv, rjt) => {
if (ws == null) throw Error('Client is not connected to server. Use `client.connect()` first.');
const id = nextId++;
reqs.set(id, {rsv, rjt});
ws.send(JSON.stringify([id, action, payload]));
});
return {
connect: (url=`ws://${window.location.host}/`, protocol=undefined) => new Promise((rsv, rjt) => {
const ws1 = new WebSocket(url, protocol);
ws1.onmessage = ({data}) => {
if (typeof data !== 'string') return; // TODO
const [id, body] = JSON.parse(data);
if (id > 0) {
reqs.get(id)?.rsv(body);
reqs.delete(id);
} else {
reqs.get(-id)?.rjt(body);
reqs.delete(-id);
}
};
ws1.onopen = rsv;
ws1.onerror = rjt; // TODO auto reconnect
ws1.onclose = onClose;
ws = ws1;
}),
get ws() {return ws},
request,
api: {
/**
* @returns {Promise<number|null>}
*/
init: () => request('init', null),
/**
* @param {ReqAddr} addr
* @param {string} type
*/
read: (addr, type) => request('read', {
addr: addr instanceof Array ? addr : [addr],
type,
}).then((/**@type{string[]|string|null}*/s) => s),
/**
* @param {ReqAddr} addr
* @param {number} size
*/
readBytes: (addr, size) => request('read', {
addr: addr instanceof Array ? addr : [addr],
size,
}).then((/**@type{string|null}*/s) => s == null ? null : hex2dv(s)),
/**
* @param {ReqAddr} addr
* @returns {Promise<string|null>}
*/
readString: addr => request('readString', {
addr: typeof addr === 'number' ? [addr] : addr,
}),
/**
* @param {ReqAddr} addr
* @param {string|ArrayBuffer|ArrayBufferView} payload
* @returns {Promise<boolean>}
*/
write: (addr, payload) => request('write', {
addr: typeof addr === 'number' ? [addr] : addr,
payload: typeof payload === 'string' ? payload : Array.from(
new Uint8Array(payload instanceof ArrayBuffer ? payload : payload.buffer),
x => x.toString(16).padStart(2, '0'),
).join(''),
}),
/**
* @param {ReqAddr} addr
* @returns {Promise<string|null>}
*/
getClass: addr => request('getClass', {
addr: typeof addr === 'number' ? [addr] : addr,
}),
/**
* @param {string} type
* @returns {Promise<[
* offsets: string,
* name: string,
* notes: string,
* type: string,
* class_: string,
* ][]>}
*/
getFields: type => request('getFields', type),
getManagers: () => request('getManagers', 0)
.then((/**@type{[addr: number, cls: string, name: string, count: number][]}*/rows) =>
rows.map(row => ({addr: row[0], cls: row[1], name: row[2], count: row[3]}))),
/**
* @param {ReqAddr} addr
*/
getManagees: addr => request('getManagees', addr)
.then((/**@type{[addr: number, cls: string, name: string][]}*/rows) =>
rows.map(row => ({addr: row[0], cls: row[1], name: row[2]}))),
reload: () => request('reload', null),
},
};
}

36
www/icon.svg Normal file
View file

@ -0,0 +1,36 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="-60,-66 120,120">
<defs>
<path id="limb" d="M50,0 l-20,10 v-20 z"/>
<g id="palp">
<path d="M55,0 l-25,10 v-20 z"/>
<circle cx="55" cy="0" r="7.5"/>
</g>
<ellipse id="eye" cx="8.5" cy="-4" rx="4" ry="8"/>
<style>
g {
stroke: #111;
stroke-width: 2;
fill: #ff9;
stroke-linejoin: round;
}
#eye {
fill: #111;
stroke: none;
}
</style>
</defs>
<g>
<use xlink:href="#limb"/>
<use xlink:href="#limb" transform="rotate(45)"/>
<use xlink:href="#limb" transform="rotate(90)"/>
<use xlink:href="#limb" transform="rotate(135)"/>
<use xlink:href="#limb" transform="rotate(180)"/>
<use xlink:href="#palp" transform="rotate(-45)"/>
<use xlink:href="#palp" transform="rotate(-90)"/>
<use xlink:href="#palp" transform="rotate(-135)"/>
<circle cx="0" cy="0" r="35"/>
<circle cx="0" cy="0" r="28.5"/>
<use xlink:href="#eye"/>
<use xlink:href="#eye" transform="scale(-1,1)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 986 B

106
www/index.css Normal file
View file

@ -0,0 +1,106 @@
:root {
--bg: #fff;
--fg: #000;
--bg-stripe: #0001;
--bg-red: #b00;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #282830;
--fg: #f7f7f7;
--bg-stripe: #fff1;
--fg-link: #72e5db;
}
}
table.list tr:nth-child(2n + 1) {
background: var(--bg-stripe);
}
button {
background: none;
color: var(--fg);
cursor: pointer;
border-radius: 4px;
}
td > button {
width: 100%;
}
table.list td {
padding: 1px 0.3em;
white-space: pre-line;
}
#managers td:nth-child(1),
#managers td:nth-child(5) {
padding: 0 2px
}
#managers tr.managee > td:nth-child(2) {
padding-left: 1em
}
#fields-viewer td:nth-child(1),
#fields-viewer td:nth-child(3) {
text-align: right;
}
.flex {
display: flex;
align-items: flex-start;
}
.flex > * {
margin-right: 4px;
}
.hidden {
display: none;
}
body {
background: var(--bg);
color: var(--fg);
font-variant-numeric: tabular-nums;
font-family: "Consolas";
font-size: 14px;
padding: 4px;
}
body.disconnected #msg {
background: var(--bg-red);
}
h1 {
margin-block-start: 0;
margin-block-end: 0.5em;
}
a {
color: var(--fg-link);
}
header {
margin-block-end: 0.75em;
padding-left: 4px;
}
details {
border: solid 1px var(--fg);
padding: 0.5em 1em;
margin-block-start: 0.5em;
margin-block-end: 0.5em;
}
details > summary {
padding: 4px 0.5em;
margin: -0.5em -1em -0.5em;
}
details[open] {
padding-bottom: 0;
}
details[open] > summary {
border-bottom: 1px solid;
margin: -0.5em -1em 1em;
}
details p {
margin-block-start: 0.5em;
margin-block-end: 0.5em;
}
#license {
width: 100%;
max-width: 800px;
height: 100px;
}

32
www/index.html Normal file
View file

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html>
<head>
<title>SMS Web Object Viewer (v0.1.0-beta.1)</title>
<link rel="stylesheet" type="text/css" href="index.css">
<link rel="icon" type="image/svg+xml" href="icon.svg">
<script src="api.js"></script>
<script src="index.js"></script>
</head>
<body>
<h1>SMS Web Object Viewer</h1>
<header>
<div id="msg"></div>
<details>
<summary>LICENSE</summary>
<p>Repository: <a href="https://github.com/sup39/sup-smsac" target="_blank" rel="noopener noreferrer">https://github.com/sup39/sup-smsac</a></p>
<p>Author: <a href="https://misskey.sup39.dev/@sms" target="_blank" rel="noopener noreferrer">sup39</a></p>
<p>License: MIT OR Apache-2.0</p>
<p><a href="/LICENSE.html" target="_blank" rel="noopener noreferrer">Full license text:</a></p>
<iframe id="license" src="/LICENSE.html" title="license"></iframe>
</details>
</header>
<section>
<button id="btn-reload" class="hidden">Reload Object Parameters</button>
</section>
<section class="flex-wrapper">
<div class="flex">
<table id="managers" class="list"></table>
<table id="fields-viewer" class="list"></table>
</div>
</section>
</body>

182
www/index.js Normal file
View file

@ -0,0 +1,182 @@
// @ts-check
/**
* @typedef {{addr: number, cls: string, name: string, count: number}} Manager
* @typedef {{addr: number, cls: string, name: string}} Managee
* @typedef {(td: HTMLTableCellElement) => void} CellFactory
* @typedef {CellFactory[]} RowFactory
* @typedef {{name: string, notes: string, offset: string|string[], type: string}} Field
* @typedef {(Omit<Field, 'offset'> & {offset: number[], srcType: string})} FieldView
* @typedef {{offsets: Field[]}} ObjParamsDBEntry
* @typedef {Record<string, null|ObjParamsDBEntry>} ObjParamsDB
*/
const fmt = {
/** @param {number} x */
hex: x => x.toString(16).toUpperCase(),
/** @type {(x: number) => string} */
float: (LOG_10_2 => x => {
const u = Math.floor(LOG_10_2*(Math.log2(Math.abs(x))-23));
return x === 0 ? '0.0' : u > 0 || u < -8 ? x.toExponential(7) :
x.toFixed(-u);
})(Math.log10(2)),
};
/**
* @param {HTMLTableElement} table
* @param {((td: HTMLTableCellElement)=>void)[][]} gTable
*/
function initTable(table, gTable) {
const nRow = gTable.length;
for (let r=table.rows.length; r<nRow; r++) table.insertRow();
for (let r=table.rows.length; r>nRow; r--) table.deleteRow(-1);
gTable.forEach((gRow, r) => {
const row = table.rows[r];
const nCol = gRow.length;
for (let c=row.cells.length; c<nCol; c++) row.insertCell();
for (let c=row.cells.length; c>nCol; c--) row.deleteCell(-1);
gRow.forEach((g, c) => g(row.cells[c]));
});
}
document.addEventListener('DOMContentLoaded', async () => {
const elmMsg = /**@type {HTMLDivElement}*/(document.getElementById('msg'));
const btnReload = /**@type {HTMLButtonElement}*/(document.getElementById('btn-reload'));
const elmManagers = /**@type {HTMLTableElement}*/(document.getElementById('managers'));
const elmFieldsViewer = /**@type {HTMLTableElement}*/(document.getElementById('fields-viewer'));
const client = Client({
onClose() {
elmMsg.textContent = `Disconnected from server. Please reload the page.`;
document.body.classList.add('disconnected');
},
});
const {api} = client;
Object.assign(window, {client, api}); // TODO
/**************** UI definition ****************/
/**
* @param {HTMLTableElement} elm
*/
function FieldsViewer(elm) {
const tdidxVal = 2;
let hAnm = NaN;
let t0 = 0;
/** @type {Managee|null} */
let target = null;
async function readValues() {
if (target == null) return [];
const values = await api.read([target.addr], target.cls);
return values instanceof Array ? values : [values];
}
/** @param {DOMHighResTimeStamp} t */
async function render(t) {
if (t-t0 >= 33) { // TODO configurable fps
(await readValues())
.forEach((s, i) => elm.rows[i].cells[tdidxVal].textContent = s);
t0 = t;
}
hAnm = requestAnimationFrame(render);
}
const methods = {
reload() {
api.reload().then(() => {
target != null && methods.view(target);
}, err => {
elmMsg.textContent = err;
});
},
/** @param {Manager|Managee} o */
async view(o) {
btnReload.classList.remove('hidden');
cancelAnimationFrame(hAnm);
target = o;
const fields = await api.getFields(o.cls);
const values = readValues();
initTable(elm, fields.map((r, i) => [
td => td.textContent = r[0],
td => td.textContent = r[1],
td => td.textContent = values[i],
// td => td.textContent = r[2], // TODO
td => td.textContent = r[3],
td => td.textContent = r[4],
]));
if (fields.length) hAnm = requestAnimationFrame(render);
},
}
return methods;
}
const fieldsViewer = FieldsViewer(elmFieldsViewer);
btnReload.addEventListener('click', () => {
fieldsViewer.reload();
});
/** @type {(o: Manager) => RowFactory} */
const makeManagersRowFactory = o => [
td => {
const btn = document.createElement('button');
let open = false;
/** @type {HTMLTableRowElement[] | null} */
let trManagees = null;
btn.textContent = '>';
btn.addEventListener('click', async () => {
open = !open;
btn.textContent = open ? 'v' : '>';
if (open) {
if (trManagees == null) {
const managees = await api.getManagees(o.addr);
const trP = td.parentElement?.parentElement;
const tr1 = td.parentElement?.nextSibling ?? null;
trManagees = managees.map((o, i) => {
const tr = document.createElement('tr');
tr.classList.add('managee');
trP?.insertBefore(tr, tr1);
makeManageesRowFactory(o, i).forEach(f => f(tr.insertCell()));
return tr;
});
} else {
trManagees.forEach(e => e.classList.remove('hidden'));
}
} else {
trManagees?.forEach(e => e.classList.add('hidden'));
}
});
td.appendChild(btn);
},
td => td.textContent = `${o.name} (${o.count})`,
td => td.textContent = `${o.cls}`,
td => td.textContent = `${fmt.hex(o.addr)}`,
makeViewerNavigatorFactory(o),
];
/** @type {(o: Managee, i: number) => ((td: HTMLTableCellElement) => void)[]} */
const makeManageesRowFactory = (o, i) => [
_ => {},
td => td.textContent = `${i}: (${o.name})`,
td => td.textContent = `${o.cls}`,
td => td.textContent = `${fmt.hex(o.addr)}`,
makeViewerNavigatorFactory(o),
];
/** @type {(o: Manager|Managee) => CellFactory} */
const makeViewerNavigatorFactory = o => td => {
const btn = document.createElement('button');
btn.textContent = '>';
btn.addEventListener('click', () => {
fieldsViewer.view(o).catch(e => elmMsg.textContent = e);
});
td.appendChild(btn);
};
/**************** UI initialization ****************/
try {
await client.connect(); // TODO url
const pid = await api.init();
console.log('pid:', pid);
const managers = await api.getManagers();
initTable(elmManagers, managers.map(makeManagersRowFactory));
} catch(e) {
elmMsg.textContent = e;
return; // TODO
}
});