Compare commits

..

131 Commits

Author SHA1 Message Date
4cb8fd1c7d
support output-folder option for git-related features
Repository-based logic seems to automatically be made relative to the top-level path the repo was initialised with, so we can actually support an output folder with no issues here
2024-10-01 20:44:58 +01:00
b575274116
fix decompiling history into git
Why does this work and the version with the directory doesn't? yeah idk. But hey, this one successfully stages the files, so ill take it
2024-10-01 20:32:59 +01:00
607f964928 Merge pull request 'add documentation, update dependencies' (#6) from staging into master
Reviewed-on: StrafesNET/asset-tool#6
2024-09-17 04:44:30 +00:00
bc11997e88 v0.4.6 documentation + update deps 2024-09-16 21:44:10 -07:00
4b5ceef5d4 update deps 2024-09-16 21:40:13 -07:00
00f8cddde0 write some documentation 2024-08-24 12:22:21 -07:00
87993d0f52 improve directions 2024-08-17 11:31:14 -07:00
8dc7c96f2d Merge pull request 'Create multiple assets concurrently' (#5) from staging into master
Reviewed-on: StrafesNET/asset-tool#5
2024-08-17 18:00:27 +00:00
40c166fcca mistake from clippy changes! 2024-08-17 10:57:23 -07:00
9c52957a03 spaces 2024-08-17 10:46:10 -07:00
f5d50a0de2 optimize backoff math 2024-08-17 10:46:10 -07:00
61c2026bff v0.4.5 create-asset-medias 2024-08-17 09:44:06 -07:00
0259284940 update deps 2024-08-17 09:44:06 -07:00
7870723b31 RobloxOperation is private
hide this garbage inside the api module bruh
2024-08-17 09:44:06 -07:00
136dbb3054 plumb path to the end in an ugly way (patches welcome) 2024-08-16 23:48:17 -07:00
b9d2a1fbc7 redo it without filter_map - errors are propagated through 2024-08-16 23:47:46 -07:00
57a163dfb1 create-asset-medias 2024-08-16 23:29:03 -07:00
2d88f5bb51 cloud: clone for Creator 2024-08-16 18:34:42 -07:00
206f551663 cloud: create_asset returns a queryable object 2024-08-16 17:54:44 -07:00
4ea8cc609b cloud: extend operation 2024-08-16 17:27:17 -07:00
99f62d090b cloud: change error names 2024-08-16 17:27:17 -07:00
eecb1b3679 cloud: spaces 2024-08-16 17:27:17 -07:00
f072cbf1f8 asset create requires a non-empty string for file name that is thrown away (undocumented) 2024-08-16 14:32:57 -07:00
fc4bca9802 clippy W 2024-07-19 11:34:51 -07:00
68d751f81f Merge pull request 'use old api for download, error on http status' (#4) from staging into master
Reviewed-on: StrafesNET/asset-tool#4
2024-07-16 18:21:06 +00:00
9a5afb9953 v0.4.4 use old api for download 2024-07-16 11:14:32 -07:00
f32cd104b6 use old api for download 2024-07-16 11:14:32 -07:00
b14915a63a rbx_asset v0.2.2 error on http status 2024-07-16 11:14:32 -07:00
abea7dd712 refactor get_asset (worthless, you can't actually download assets) 2024-07-16 11:11:57 -07:00
2d2ebcece0 error for status for all 2024-07-16 10:09:42 -07:00
44d8670738 print upload response for compile-upload-place 2024-07-10 13:33:46 -07:00
c2052be036 Merge pull request 'use old api for compile-upload-asset' (#3) from staging into master
Reviewed-on: StrafesNET/asset-tool#3
2024-07-10 17:18:05 +00:00
7ab064e61e v0.4.3 use old api for compile-upload-asset 2024-07-10 10:16:11 -07:00
4c00d7fe1a use old asset api for compile-upload-asset 2024-07-10 10:14:35 -07:00
74b84f6de0 typo 2024-07-10 10:07:21 -07:00
2e9485dea6 Merge pull request 'add old asset upload api' (#2) from staging into master
Reviewed-on: StrafesNET/asset-tool#2
2024-07-10 16:45:17 +00:00
6111ebd0fe v0.4.2 old asset api 2024-07-10 09:38:31 -07:00
b1a118c29e add old asset api 2024-07-10 09:33:45 -07:00
8222cb3457 don't hard code asset type, expected price 2024-07-10 09:33:45 -07:00
04d092c76f rename limited commands 2024-07-10 09:08:20 -07:00
0d92221a27 Merge pull request 'Update for ci build' (#1) from feature/CI into master
Reviewed-on: StrafesNET/asset-tool#1
2024-07-08 20:39:48 +00:00
9c862717a5 Add build status 2024-07-04 18:33:41 -04:00
11fee65354 Update for ci build 2024-07-04 18:31:06 -04:00
c947691f75 fix regex and remove --! because luau doesn't like it 2024-07-04 13:33:13 -07:00
cb984a9f20 v0.4.1 partially revert api changes because the new one is horrible 2024-07-04 13:32:20 -07:00
e46ad7a6a8 rbx_asset v0.2.1 old api is back 2024-07-04 13:32:20 -07:00
4805f3bc08 use old api for some things 2024-07-04 13:32:20 -07:00
9638672dde revive old api code 2024-07-04 13:32:20 -07:00
c945036d60 rename context to cloud 2024-07-04 13:32:20 -07:00
f9bdfd0e00 improve type safety for secrets 2024-07-04 13:32:20 -07:00
d468113e51 manually specify creator :/ 2024-07-04 13:32:20 -07:00
b72bed638d v0.4.0 new roblox api 2024-07-04 13:32:20 -07:00
452c00d53e refactor for new api 2024-07-04 13:32:20 -07:00
b89a787af2 move type conversion to argument stuff 2024-07-04 12:45:28 -07:00
5085f6587f v0.3.4 download-decompile 2024-07-04 12:45:28 -07:00
c856301aa6 download-decompile 2024-07-04 12:45:28 -07:00
d38152600e v0.3.3 compile-upload 2024-07-04 12:45:28 -07:00
c08ff63033 compile-upload 2024-07-04 12:45:28 -07:00
6720f6213f fix compiling with no template 2024-07-04 12:45:28 -07:00
db34436d64 v0.3.2 rox_compiler 2024-07-04 12:45:28 -07:00
a6ae26a93e refactor rox_compiler into module 2024-07-04 12:45:28 -07:00
64ac70f946 update deps 2024-07-01 10:49:37 -07:00
13786f941b update deps 2024-05-31 04:45:24 -07:00
dde85fbf8a use an empty dom instead of an empty template 2024-05-04 19:37:33 -07:00
59deac0155 implicitly set input/output folder to current directory 2024-04-28 18:41:03 -07:00
66104be2d9 v0.3.1 asset lib 2024-04-27 23:57:42 -07:00
ca81d06998 use asset lib 2024-04-27 23:57:42 -07:00
4bea61558e asset lib 2024-04-27 23:57:42 -07:00
bd382ef885 decode to UploadResponse struct 2024-04-25 23:28:55 -07:00
809d6df3f0 silence FALSE "dead code" 2024-04-25 23:27:10 -07:00
e28a58d747 v0.3.0 rewrite clap usage + create asset + get inventory 2024-04-25 23:13:16 -07:00
c29396f44b update deps 2024-04-25 23:13:16 -07:00
ae181fd401 more options for create 2024-04-25 18:50:57 -07:00
312c0e7cd9 get inventory function 2024-04-25 17:45:58 -07:00
5ceb118ba1 create assets 2024-04-25 01:09:53 -07:00
c084219534 clarify 2024-04-25 00:28:54 -07:00
90aa03f86e change arg 2024-04-25 00:28:54 -07:00
53755caad5 found out why clap seemed insane 2024-04-19 00:08:47 -07:00
561a94c4d3 what a bug! 2024-04-10 03:59:04 -07:00
6b689c8d6c hack for model uploads 2024-04-10 03:45:22 -07:00
a96715ecaf error for bad status 2024-04-10 03:45:11 -07:00
eac6546aed fix upload 2024-03-15 11:07:30 -07:00
c43ed400c9 try this 2024-03-15 10:52:49 -07:00
91aa21a993 v0.2.0 compile+decompile 2024-03-08 10:32:27 -08:00
1dff5d6856 rewrite clap usage 2024-03-08 09:50:56 -08:00
b64da4511c import PathBuf 2024-03-08 09:48:47 -08:00
8f2115ebf2 eternal license 2024-03-08 09:42:05 -08:00
f23be6f445 tweak history gen 2024-03-08 09:42:05 -08:00
0d1692f253 compile feature 2024-03-08 09:42:05 -08:00
7ad7fa3d41 misc fixes 2024-03-08 09:42:05 -08:00
b8d5efe168 update deps 2024-03-08 09:42:05 -08:00
48ab23bae5 tabs 2024-01-22 20:28:24 -08:00
97286612b7 path 2024-01-22 12:28:32 -08:00
8e589f7f0f download asset list 2024-01-16 21:50:35 -08:00
cbb5805d54 fix download path 2024-01-14 18:43:03 -08:00
d6add611fb fix error message 2024-01-14 13:24:33 -08:00
ebd5bb526e force update 2024-01-14 11:12:57 -08:00
858ed8a89c todo 2024-01-14 11:07:33 -08:00
35657d4d51 versions not correctly updated 2024-01-13 23:08:30 -08:00
15e8c8208f add --continue feature for download history 2024-01-13 21:24:50 -08:00
5574c34045 end_version for download_history + catch download error and write versions.json 2024-01-13 20:49:06 -08:00
e5322ad711 todo 2024-01-13 20:32:54 -08:00
0d0f1b1792 limit concurrent downloads using JoinSet 2024-01-12 17:07:06 -08:00
1b3a8be142 start version argument 2024-01-12 16:43:37 -08:00
eb20f8056d make download_history faster 2024-01-12 16:43:34 -08:00
b3f5dd6c4d opti 2024-01-12 15:53:25 -08:00
10f1ac313c DecompileStyle 2024-01-12 13:14:21 -08:00
2260d9a565 fix analyzer 2024-01-12 11:23:45 -08:00
ee8e59b898 rearrange and comment args 2024-01-12 11:20:22 -08:00
a9a40cd2f0 I think this is better 2024-01-11 22:25:00 -08:00
cef0f38482 pray rayon doesn't spawn 600 threads and crash 2024-01-11 22:07:01 -08:00
c2041b33e4 add rayon dep 2024-01-11 22:06:43 -08:00
29b4211484 limit stupid bs 2024-01-11 22:02:10 -08:00
bbbbd6bf64 wrong tab 2024-01-11 21:32:28 -08:00
e8ef06c2cb tokio looks like this on some random dude's webpage 2024-01-11 21:29:06 -08:00
9dc67a22c1 call this function because it makes it work better 2024-01-11 21:18:37 -08:00
67048cccf6 update deleted or moved files 2024-01-11 21:18:37 -08:00
b9eaefe056 note about default signature 2024-01-11 21:18:37 -08:00
1bdfbf96c3 write no empty commits 2024-01-11 21:18:37 -08:00
db44cf12be fix code duplication 2024-01-11 21:18:37 -08:00
30169ddc5b respect config 2024-01-11 21:18:37 -08:00
4d77895260 use less ram lol 2024-01-11 21:18:37 -08:00
aeb72503f0 print for each 2024-01-11 17:13:05 -08:00
1ba8d636f1 not sure if this drops file earlier 2024-01-11 17:13:05 -08:00
a5c01ff9cf DownloadAndDecompileHistoryIntoGit 2024-01-11 16:11:46 -08:00
c3ca545119 write some stupid stuff using unlimited scoped threads 2024-01-11 16:06:12 -08:00
def0d1a69a async decompile 2024-01-11 14:51:31 -08:00
00f4788169 prepare to write files with TOKIO!!! 2024-01-11 13:59:32 -08:00
e12c13a1dd add args for committer name and email 2024-01-11 13:48:57 -08:00
816461e9f4 async download -> unzip -> decompile -> git history 2024-01-11 02:47:20 -08:00
13ebb96ed6 refactor 2024-01-11 02:46:47 -08:00
16 changed files with 3779 additions and 1157 deletions

24
.drone.yml Normal file
View File

@ -0,0 +1,24 @@
---
kind: pipeline
type: docker
platform:
os: linux
arch: amd64
steps:
- name: image
image: plugins/docker
settings:
registry: git.itzana.me
repo: git.itzana.me/strafesnet/asset-tool
tags:
- latest
username:
from_secret: GIT_USER
password:
from_secret: GIT_PASS
dockerfile: Containerfile
when:
branch:
- master

1213
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,25 +1,24 @@
workspace = { members = ["rbx_asset", "rox_compiler"] }
[package]
name = "asset-tool"
version = "0.1.0"
version = "0.4.6"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
anyhow = "1.0.75"
chrono = { version = "0.4.31", features = ["serde"] }
clap = { version = "4.4.2", features = ["derive"] }
flate2 = "1.0.28"
futures = "0.3.30"
git2 = "0.18.1"
lazy-regex = "3.1.0"
pollster = "0.3.0"
rbx_binary = "0.7.1"
rbx_dom_weak = "2.5.0"
rbx_reflection_database = "0.2.7"
rbx_xml = "0.13.1"
reqwest = { version = "0.11.23", features = ["cookies", "json"] }
serde = { version = "1.0.195", features = ["derive"] }
rbx_asset = { path = "rbx_asset" }
rbx_binary = "0.7.4"
rbx_dom_weak = "2.7.0"
rbx_reflection_database = "0.2.10"
rbx_xml = "0.13.3"
rox_compiler = { path = "rox_compiler" }
serde_json = "1.0.111"
tokio = { version = "1.35.1", features = ["macros", "rt-multi-thread", "fs"] }

View File

@ -1,6 +1,6 @@
# Using the `rust-musl-builder` as base image, instead of
# the official Rust toolchain
FROM clux/muslrust:stable AS chef
FROM docker.io/clux/muslrust:stable AS chef
USER root
RUN cargo install cargo-chef
WORKDIR /app
@ -16,8 +16,8 @@ RUN cargo chef cook --release --target x86_64-unknown-linux-musl --recipe-path r
COPY . .
RUN cargo build --release --target x86_64-unknown-linux-musl --bin asset-tool
FROM alpine AS runtime
FROM docker.io/alpine:latest AS runtime
RUN addgroup -S myuser && adduser -S myuser -G myuser
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/asset-tool /usr/local/bin/
USER myuser
CMD ["/usr/local/bin/asset-tool"]
ENTRYPOINT ["/usr/local/bin/asset-tool"]

28
LICENSE
View File

@ -1,9 +1,23 @@
MIT License
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
Copyright (c) 2023 Quaternions
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.

View File

@ -1,3 +1,21 @@
# asset-tool
[![Build Status](https://ci.itzana.me/api/badges/StrafesNET/asset-tool/status.svg?ref=refs/heads/master)](https://ci.itzana.me/StrafesNET/asset-tool)
For uploading and downloading roblox assets.
See [releases](https://git.itzana.me/StrafesNET/asset-tool/releases) for downloads.
To get started, you will need an api key and/or cookie depending on which command you use. Api keys can be created from the open cloud section on the creator hub, cookies must be extracted from a browser session. Do not share your cookie file with anyone or use it with tools that you do not trust, as it gives unrestricted permissions to do any account actions on your behalf. The api keys are significantly more safe because you can choose exactly what permissions the given key has, and which ip addresses can use it.
The help text lists available commands:
`asset-tool --help`
For help with a specific command:
`asset-tool download --help`
Example incantation to download a list of models:
`asset-tool download --cookie-file Documents\mycookie.txt 255299419 1213190363`

15
rbx_asset/Cargo.toml Normal file
View File

@ -0,0 +1,15 @@
[package]
name = "rbx_asset"
version = "0.2.2"
edition = "2021"
publish = ["strafesnet"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
chrono = { version = "0.4.38", features = ["serde"] }
flate2 = "1.0.29"
reqwest = { version = "0.12.4", features = ["json","multipart"] }
serde = { version = "1.0.199", features = ["derive"] }
serde_json = "1.0.111"
url = "2.5.0"

475
rbx_asset/src/cloud.rs Normal file
View File

@ -0,0 +1,475 @@
#[derive(Debug,serde::Deserialize,serde::Serialize)]
#[allow(nonstandard_style,dead_code)]
pub enum AssetType{
Audio,
Decal,
Model,
}
#[derive(Debug,serde::Deserialize,serde::Serialize)]
#[allow(nonstandard_style,dead_code)]
pub struct CreateAssetRequest{
pub assetType:AssetType,
pub creationContext:CreationContext,
pub description:String,
pub displayName:String,
}
#[derive(Debug)]
pub enum AssetOperationError{
Operation(OperationError),
Serialize(serde_json::Error),
}
impl std::fmt::Display for AssetOperationError{
fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
write!(f,"{self:?}")
}
}
impl std::error::Error for AssetOperationError{}
#[derive(Debug)]
pub struct AssetOperation{
operation:RobloxOperation,
}
impl AssetOperation{
pub async fn try_get_asset(&self,context:&CloudContext)->Result<AssetResponse,AssetOperationError>{
serde_json::from_value(
self.operation
.try_get_reponse(context).await
.map_err(AssetOperationError::Operation)?
).map_err(AssetOperationError::Serialize)
}
}
#[derive(Debug)]
pub enum CreateError{
Parse(url::ParseError),
Serialize(serde_json::Error),
Reqwest(reqwest::Error),
}
impl std::fmt::Display for CreateError{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f,"{self:?}")
}
}
impl std::error::Error for CreateError{}
#[derive(Debug,serde::Deserialize,serde::Serialize)]
#[allow(nonstandard_style,dead_code)]
pub struct UpdateAssetRequest{
pub assetId:u64,
pub displayName:Option<String>,
pub description:Option<String>,
}
//woo nested roblox stuff
#[derive(Clone,Debug,serde::Deserialize,serde::Serialize)]
#[allow(nonstandard_style,dead_code)]
pub enum Creator{
userId(String),//u64 string
groupId(String),//u64 string
}
#[derive(Debug,serde::Deserialize,serde::Serialize)]
#[allow(nonstandard_style,dead_code)]
pub struct CreationContext{
pub creator:Creator,
pub expectedPrice:Option<u64>,
}
#[derive(Debug,serde::Deserialize,serde::Serialize)]
#[allow(nonstandard_style,dead_code)]
pub enum ModerationState{
Reviewing,
Rejected,
Approved,
}
#[derive(Debug,serde::Deserialize,serde::Serialize)]
#[allow(nonstandard_style,dead_code)]
pub struct ModerationResult{
pub moderationState:ModerationState,
}
#[derive(Debug,serde::Deserialize,serde::Serialize)]
#[allow(nonstandard_style,dead_code)]
pub struct Preview{
pub asset:String,
pub altText:String,
}
#[allow(nonstandard_style,dead_code)]
pub struct UpdatePlaceRequest{
pub universeId:u64,
pub placeId:u64,
}
#[derive(Debug,serde::Deserialize,serde::Serialize)]
#[allow(nonstandard_style,dead_code)]
pub struct UpdatePlaceResponse{
pub versionNumber:u64,
}
#[derive(Debug)]
pub enum UpdateError{
ParseError(url::ParseError),
SerializeError(serde_json::Error),
Reqwest(reqwest::Error),
}
impl std::fmt::Display for UpdateError{
fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
write!(f,"{self:?}")
}
}
impl std::error::Error for UpdateError{}
struct GetAssetOperationRequest{
operation_id:String,
}
pub struct GetAssetInfoRequest{
pub asset_id:u64,
}
/*
{
"assetId": "5692158972",
"assetType": "Model",
"creationContext":{
"creator":
{
"groupId": "6980477"
}
},
"description": "DisplayName: Ares\nCreator: titanicguy54",
"displayName": "bhop_ares.rbxmx",
"path": "assets/5692158972",
"revisionCreateTime": "2020-09-14T16:08:05.063Z",
"revisionId": "1",
"moderationResult":{
"moderationState": "Approved"
},
"state": "Active"
}
*/
#[derive(Debug,serde::Deserialize,serde::Serialize)]
#[allow(nonstandard_style,dead_code)]
pub struct AssetResponse{
pub assetId:String,//u64 wrapped in quotes wohoo!!
pub assetType:AssetType,
pub creationContext:CreationContext,
pub description:String,
pub displayName:String,
pub path:String,
pub revisionCreateTime:chrono::DateTime<chrono::Utc>,
pub revisionId:String,//u64
pub moderationResult:ModerationResult,
pub icon:Option<String>,
pub previews:Option<Vec<Preview>>,
}
#[allow(nonstandard_style,dead_code)]
pub struct GetAssetVersionRequest{
pub asset_id:u64,
pub version:u64,
}
#[allow(nonstandard_style,dead_code)]
pub struct GetAssetRequest{
pub asset_id:u64,
pub version:Option<u64>,
}
#[derive(Debug)]
pub enum GetError{
ParseError(url::ParseError),
Reqwest(reqwest::Error),
IO(std::io::Error)
}
impl std::fmt::Display for GetError{
fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
write!(f,"{self:?}")
}
}
impl std::error::Error for GetError{}
pub struct AssetVersionsRequest{
pub asset_id:u64,
pub cursor:Option<String>,
}
#[derive(Debug,serde::Deserialize,serde::Serialize)]
#[allow(nonstandard_style,dead_code)]
pub struct AssetVersion{
pub Id:u64,
pub assetId:u64,
pub assetVersionNumber:u64,
pub creatorType:String,
pub creatorTargetId:u64,
pub creatingUniverseId:Option<u64>,
pub created:chrono::DateTime<chrono::Utc>,
pub isPublished:bool,
}
#[derive(Debug,serde::Deserialize)]
#[allow(nonstandard_style,dead_code)]
pub struct AssetVersionsResponse{
pub previousPageCursor:Option<String>,
pub nextPageCursor:Option<String>,
pub data:Vec<AssetVersion>,
}
#[derive(Debug)]
pub enum AssetVersionsError{
ParseError(url::ParseError),
Reqwest(reqwest::Error),
}
impl std::fmt::Display for AssetVersionsError{
fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
write!(f,"{self:?}")
}
}
impl std::error::Error for AssetVersionsError{}
pub struct InventoryPageRequest{
pub group:u64,
pub cursor:Option<String>,
}
#[derive(Debug,serde::Deserialize,serde::Serialize)]
#[allow(nonstandard_style,dead_code)]
pub struct InventoryItem{
pub id:u64,
pub name:String,
}
#[derive(Debug,serde::Deserialize,serde::Serialize)]
#[allow(nonstandard_style,dead_code)]
pub struct InventoryPageResponse{
pub totalResults:u64,//up to 50
pub filteredKeyword:Option<String>,//""
pub searchDebugInfo:Option<String>,//null
pub spellCheckerResult:Option<String>,//null
pub queryFacets:Option<String>,//null
pub imageSearchStatus:Option<String>,//null
pub previousPageCursor:Option<String>,
pub nextPageCursor:Option<String>,
pub data:Vec<InventoryItem>,
}
#[derive(Debug)]
pub enum InventoryPageError{
ParseError(url::ParseError),
Reqwest(reqwest::Error),
}
impl std::fmt::Display for InventoryPageError{
fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
write!(f,"{self:?}")
}
}
impl std::error::Error for InventoryPageError{}
#[derive(Debug)]
pub enum OperationError{
Get(GetError),
NoOperationId,
NotDone,
}
impl std::fmt::Display for OperationError{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f,"{self:?}")
}
}
impl std::error::Error for OperationError{}
#[derive(Debug,serde::Deserialize,serde::Serialize)]
#[allow(nonstandard_style,dead_code)]
struct RobloxOperation{
pub path:Option<String>,
pub metadata:Option<String>,
pub done:Option<bool>,
pub error:Option<String>,
pub response:Option<serde_json::Value>,
pub operationId:Option<String>,
}
impl RobloxOperation{
pub fn operation_id(&self)->Option<&str>{
match self.operationId.as_deref(){
//try getting it from undocumented operationId first
Some(operation_id)=>Some(operation_id),
//skip the first 11 characters
//operations/[uuid]
None=>self.path.as_deref()?.get(11..),
}
}
pub async fn try_get_reponse(&self,context:&CloudContext)->Result<serde_json::Value,OperationError>{
context.get_asset_operation(GetAssetOperationRequest{
operation_id:self.operation_id()
.ok_or(OperationError::NoOperationId)?
.to_owned(),
}).await.map_err(OperationError::Get)?
.response.ok_or(OperationError::NotDone)
}
}
//idk how to do this better
enum ReaderType<R:std::io::Read>{
GZip(flate2::read::GzDecoder<std::io::BufReader<R>>),
Raw(std::io::BufReader<R>),
}
fn maybe_gzip_decode<R:std::io::Read>(input:R)->std::io::Result<ReaderType<R>>{
let mut buf=std::io::BufReader::new(input);
let peek=std::io::BufRead::fill_buf(&mut buf)?;
match &peek[0..2]{
b"\x1f\x8b"=>Ok(ReaderType::GZip(flate2::read::GzDecoder::new(buf))),
_=>Ok(ReaderType::Raw(buf)),
}
}
fn read_readable(mut readable:impl std::io::Read)->std::io::Result<Vec<u8>>{
let mut contents=Vec::new();
readable.read_to_end(&mut contents)?;
Ok(contents)
}
#[derive(Clone)]
pub struct ApiKey(String);
impl ApiKey{
pub fn new(api_key:String)->Self{
Self(api_key)
}
pub fn get(self)->String{
self.0
}
}
#[derive(Clone)]
pub struct CloudContext{
pub api_key:String,
pub client:reqwest::Client,
}
impl CloudContext{
pub fn new(api_key:ApiKey)->Self{
Self{
api_key:api_key.get(),
client:reqwest::Client::new(),
}
}
async fn get(&self,url:impl reqwest::IntoUrl)->Result<reqwest::Response,reqwest::Error>{
self.client.get(url)
.header("x-api-key",self.api_key.as_str())
.send().await
}
async fn post(&self,url:url::Url,body:impl Into<reqwest::Body>+Clone)->Result<reqwest::Response,reqwest::Error>{
self.client.post(url)
.header("x-api-key",self.api_key.as_str())
.body(body)
.send().await
}
async fn patch_form(&self,url:url::Url,form:reqwest::multipart::Form)->Result<reqwest::Response,reqwest::Error>{
self.client.patch(url)
.header("x-api-key",self.api_key.as_str())
.multipart(form)
.send().await
}
async fn post_form(&self,url:url::Url,form:reqwest::multipart::Form)->Result<reqwest::Response,reqwest::Error>{
self.client.post(url)
.header("x-api-key",self.api_key.as_str())
.multipart(form)
.send().await
}
pub async fn create_asset(&self,config:CreateAssetRequest,body:impl Into<std::borrow::Cow<'static,[u8]>>)->Result<AssetOperation,CreateError>{
let url=reqwest::Url::parse("https://apis.roblox.com/assets/v1/assets").map_err(CreateError::Parse)?;
let request_config=serde_json::to_string(&config).map_err(CreateError::Serialize)?;
let part=reqwest::multipart::Part::bytes(body)
//you must have a file name or roblox will 400!!!!!!!!!
.file_name("image");
let form=reqwest::multipart::Form::new()
.text("request",request_config)
.part("fileContent",part);
let operation=self.post_form(url,form).await
.map_err(CreateError::Reqwest)?
.error_for_status().map_err(CreateError::Reqwest)?
.json::<RobloxOperation>().await.map_err(CreateError::Reqwest)?;
Ok(AssetOperation{
operation,
})
}
pub async fn update_asset(&self,config:UpdateAssetRequest,body:impl Into<std::borrow::Cow<'static,[u8]>>)->Result<AssetOperation,UpdateError>{
let raw_url=format!("https://apis.roblox.com/assets/v1/assets/{}",config.assetId);
let url=reqwest::Url::parse(raw_url.as_str()).map_err(UpdateError::ParseError)?;
let request_config=serde_json::to_string(&config).map_err(UpdateError::SerializeError)?;
let form=reqwest::multipart::Form::new()
.text("request",request_config)
.part("fileContent",reqwest::multipart::Part::bytes(body));
let operation=self.patch_form(url,form).await
.map_err(UpdateError::Reqwest)?
//roblox api documentation is very poor, just give the status code and drop the json
.error_for_status().map_err(UpdateError::Reqwest)?
.json::<RobloxOperation>().await.map_err(UpdateError::Reqwest)?;
Ok(AssetOperation{
operation,
})
}
async fn get_asset_operation(&self,config:GetAssetOperationRequest)->Result<RobloxOperation,GetError>{
let raw_url=format!("https://apis.roblox.com/assets/v1/operations/{}",config.operation_id);
let url=reqwest::Url::parse(raw_url.as_str()).map_err(GetError::ParseError)?;
self.get(url).await.map_err(GetError::Reqwest)?
.error_for_status().map_err(GetError::Reqwest)?
.json::<RobloxOperation>().await.map_err(GetError::Reqwest)
}
pub async fn get_asset_info(&self,config:GetAssetInfoRequest)->Result<AssetResponse,GetError>{
let raw_url=format!("https://apis.roblox.com/assets/v1/assets/{}",config.asset_id);
let url=reqwest::Url::parse(raw_url.as_str()).map_err(GetError::ParseError)?;
self.get(url).await.map_err(GetError::Reqwest)?
.error_for_status().map_err(GetError::Reqwest)?
.json::<AssetResponse>().await.map_err(GetError::Reqwest)
}
pub async fn get_asset_version(&self,config:GetAssetVersionRequest)->Result<Vec<u8>,GetError>{
let raw_url=format!("https://apis.roblox.com/assets/v1/assets/{}/versions/{}",config.asset_id,config.version);
let url=reqwest::Url::parse(raw_url.as_str()).map_err(GetError::ParseError)?;
let body=self.get(url).await.map_err(GetError::Reqwest)?
.error_for_status().map_err(GetError::Reqwest)?
.bytes().await.map_err(GetError::Reqwest)?;
match maybe_gzip_decode(&mut std::io::Cursor::new(body)){
Ok(ReaderType::GZip(readable))=>read_readable(readable),
Ok(ReaderType::Raw(readable))=>read_readable(readable),
Err(e)=>Err(e),
}.map_err(GetError::IO)
}
pub async fn get_asset(&self,config:GetAssetRequest)->Result<Vec<u8>,GetError>{
let version=match config.version{
Some(version)=>version,
None=>self.get_asset_info(GetAssetInfoRequest{asset_id:config.asset_id}).await?.revisionId.parse().unwrap(),
};
self.get_asset_version(GetAssetVersionRequest{
asset_id:config.asset_id,
version,
}).await
}
pub async fn get_asset_versions(&self,config:AssetVersionsRequest)->Result<AssetVersionsResponse,AssetVersionsError>{
let raw_url=format!("https://apis.roblox.com/assets/v1/assets/{}/versions",config.asset_id);
let url=reqwest::Url::parse(raw_url.as_str()).map_err(AssetVersionsError::ParseError)?;
self.get(url).await.map_err(AssetVersionsError::Reqwest)?
.error_for_status().map_err(AssetVersionsError::Reqwest)?
.json::<AssetVersionsResponse>().await.map_err(AssetVersionsError::Reqwest)
}
pub async fn inventory_page(&self,config:InventoryPageRequest)->Result<InventoryPageResponse,InventoryPageError>{
let mut url=reqwest::Url::parse(format!("https://apis.roblox.com/toolbox-service/v1/creations/group/{}/10?limit=50",config.group).as_str()).map_err(InventoryPageError::ParseError)?;
//url borrow scope
{
let mut query=url.query_pairs_mut();//borrow here
if let Some(cursor)=config.cursor.as_deref(){
query.append_pair("cursor",cursor);
}
}
self.get(url).await.map_err(InventoryPageError::Reqwest)?
.error_for_status().map_err(InventoryPageError::Reqwest)?
.json::<InventoryPageResponse>().await.map_err(InventoryPageError::Reqwest)
}
pub async fn update_place(&self,config:UpdatePlaceRequest,body:impl Into<reqwest::Body>+Clone)->Result<UpdatePlaceResponse,UpdateError>{
let raw_url=format!("https://apis.roblox.com/universes/v1/{}/places/{}/versions",config.universeId,config.placeId);
let mut url=reqwest::Url::parse(raw_url.as_str()).map_err(UpdateError::ParseError)?;
//url borrow scope
{
let mut query=url.query_pairs_mut();//borrow here
query.append_pair("versionType","Published");
}
self.post(url,body).await.map_err(UpdateError::Reqwest)?
.error_for_status().map_err(UpdateError::Reqwest)?
.json::<UpdatePlaceResponse>().await.map_err(UpdateError::Reqwest)
}
}

322
rbx_asset/src/cookie.rs Normal file
View File

@ -0,0 +1,322 @@
#[derive(Debug)]
pub enum PostError{
Reqwest(reqwest::Error),
CSRF,
}
impl std::fmt::Display for PostError{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f,"{self:?}")
}
}
impl std::error::Error for PostError{}
#[derive(Debug,serde::Deserialize,serde::Serialize)]
#[allow(nonstandard_style,dead_code)]
pub struct CreateRequest{
pub name:String,
pub description:String,
pub ispublic:bool,
pub allowComments:bool,
pub groupId:Option<u64>,
}
#[derive(Debug)]
pub enum CreateError{
ParseError(url::ParseError),
PostError(PostError),
Reqwest(reqwest::Error),
}
impl std::fmt::Display for CreateError{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f,"{self:?}")
}
}
impl std::error::Error for CreateError{}
#[allow(nonstandard_style,dead_code)]
pub struct UploadRequest{
pub assetid:u64,
pub name:Option<String>,
pub description:Option<String>,
pub ispublic:Option<bool>,
pub allowComments:Option<bool>,
pub groupId:Option<u64>,
}
#[derive(Debug)]
pub enum UploadError{
ParseError(url::ParseError),
PostError(PostError),
Reqwest(reqwest::Error),
AssetIdIsZero,
}
impl std::fmt::Display for UploadError{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f,"{self:?}")
}
}
impl std::error::Error for UploadError{}
#[derive(Debug,serde::Deserialize,serde::Serialize)]
#[allow(nonstandard_style,dead_code)]
pub struct UploadResponse{
pub AssetId:u64,
pub AssetVersionId:u64,
}
#[allow(nonstandard_style,dead_code)]
pub struct GetAssetRequest{
pub asset_id:u64,
pub version:Option<u64>,
}
#[derive(Debug)]
pub enum GetError{
ParseError(url::ParseError),
Reqwest(reqwest::Error),
IO(std::io::Error)
}
impl std::fmt::Display for GetError{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f,"{self:?}")
}
}
impl std::error::Error for GetError{}
pub struct AssetVersionsPageRequest{
pub asset_id:u64,
pub cursor:Option<String>,
}
#[derive(serde::Deserialize,serde::Serialize)]
#[allow(nonstandard_style,dead_code)]
pub struct AssetVersion{
pub Id:u64,
pub assetId:u64,
pub assetVersionNumber:u64,
pub creatorType:String,
pub creatorTargetId:u64,
pub creatingUniverseId:Option<u64>,
pub created:chrono::DateTime<chrono::Utc>,
pub isPublished:bool,
}
#[derive(serde::Deserialize)]
#[allow(nonstandard_style,dead_code)]
pub struct AssetVersionsPageResponse{
pub previousPageCursor:Option<String>,
pub nextPageCursor:Option<String>,
pub data:Vec<AssetVersion>,
}
#[derive(Debug)]
pub enum AssetVersionsPageError{
ParseError(url::ParseError),
Reqwest(reqwest::Error),
}
impl std::fmt::Display for AssetVersionsPageError{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f,"{self:?}")
}
}
impl std::error::Error for AssetVersionsPageError{}
pub struct InventoryPageRequest{
pub group:u64,
pub cursor:Option<String>,
}
#[derive(serde::Deserialize,serde::Serialize)]
#[allow(nonstandard_style,dead_code)]
pub struct InventoryItem{
pub id:u64,
pub name:String,
}
#[derive(serde::Deserialize,serde::Serialize)]
#[allow(nonstandard_style,dead_code)]
pub struct InventoryPageResponse{
pub totalResults:u64,//up to 50
pub filteredKeyword:Option<String>,//""
pub searchDebugInfo:Option<String>,//null
pub spellCheckerResult:Option<String>,//null
pub queryFacets:Option<String>,//null
pub imageSearchStatus:Option<String>,//null
pub previousPageCursor:Option<String>,
pub nextPageCursor:Option<String>,
pub data:Vec<InventoryItem>,
}
#[derive(Debug)]
pub enum InventoryPageError{
ParseError(url::ParseError),
Reqwest(reqwest::Error),
}
impl std::fmt::Display for InventoryPageError{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f,"{self:?}")
}
}
impl std::error::Error for InventoryPageError{}
//idk how to do this better
enum ReaderType<R:std::io::Read>{
GZip(flate2::read::GzDecoder<std::io::BufReader<R>>),
Raw(std::io::BufReader<R>),
}
fn maybe_gzip_decode<R:std::io::Read>(input:R)->std::io::Result<ReaderType<R>>{
let mut buf=std::io::BufReader::new(input);
let peek=std::io::BufRead::fill_buf(&mut buf)?;
match &peek[0..2]{
b"\x1f\x8b"=>Ok(ReaderType::GZip(flate2::read::GzDecoder::new(buf))),
_=>Ok(ReaderType::Raw(buf)),
}
}
fn read_readable(mut readable:impl std::io::Read)->std::io::Result<Vec<u8>>{
let mut contents=Vec::new();
readable.read_to_end(&mut contents)?;
Ok(contents)
}
#[derive(Clone)]
pub struct Cookie(String);
impl Cookie{
pub fn new(cookie:String)->Self{
Self(cookie)
}
pub fn get(self)->String{
self.0
}
}
#[derive(Clone)]
pub struct CookieContext{
pub cookie:String,
pub client:reqwest::Client,
}
impl CookieContext{
pub fn new(cookie:Cookie)->Self{
Self{
cookie:cookie.get(),
client:reqwest::Client::new(),
}
}
async fn get(&self,url:impl reqwest::IntoUrl)->Result<reqwest::Response,reqwest::Error>{
self.client.get(url)
.header("Cookie",self.cookie.as_str())
.send().await
}
async fn post(&self,url:url::Url,body:impl Into<reqwest::Body>+Clone)->Result<reqwest::Response,PostError>{
let mut resp=self.client.post(url.clone())
.header("Cookie",self.cookie.as_str())
.body(body.clone())
.send().await.map_err(PostError::Reqwest)?;
//This is called a CSRF challenge apparently
if resp.status()==reqwest::StatusCode::FORBIDDEN{
if let Some(csrf_token)=resp.headers().get("X-CSRF-Token"){
resp=self.client.post(url)
.header("X-CSRF-Token",csrf_token)
.header("Cookie",self.cookie.as_str())
.body(body)
.send().await.map_err(PostError::Reqwest)?;
}else{
Err(PostError::CSRF)?;
}
}
Ok(resp)
}
pub async fn create(&self,config:CreateRequest,body:impl Into<reqwest::Body>+Clone)->Result<UploadResponse,CreateError>{
let mut url=reqwest::Url::parse("https://data.roblox.com/Data/Upload.ashx?json=1&type=Model&genreTypeId=1").map_err(CreateError::ParseError)?;
//url borrow scope
{
let mut query=url.query_pairs_mut();//borrow here
//archaic roblox api uses 0 for new asset
query.append_pair("assetid","0");
query.append_pair("name",config.name.as_str());
query.append_pair("description",config.description.as_str());
query.append_pair("ispublic",if config.ispublic{"True"}else{"False"});
query.append_pair("allowComments",if config.allowComments{"True"}else{"False"});
if let Some(group_id)=config.groupId{
query.append_pair("groupId",group_id.to_string().as_str());
}
}
self.post(url,body).await.map_err(CreateError::PostError)?
.error_for_status().map_err(CreateError::Reqwest)?
.json::<UploadResponse>().await.map_err(CreateError::Reqwest)
}
pub async fn upload(&self,config:UploadRequest,body:impl Into<reqwest::Body>+Clone)->Result<UploadResponse,UploadError>{
let mut url=reqwest::Url::parse("https://data.roblox.com/Data/Upload.ashx?json=1&type=Model&genreTypeId=1").map_err(UploadError::ParseError)?;
//url borrow scope
{
let mut query=url.query_pairs_mut();//borrow here
//archaic roblox api uses 0 for new asset
match config.assetid{
0=>return Err(UploadError::AssetIdIsZero),
assetid=>{query.append_pair("assetid",assetid.to_string().as_str());},
}
if let Some(name)=config.name.as_deref(){
query.append_pair("name",name);
}
if let Some(description)=config.description.as_deref(){
query.append_pair("description",description);
}
if let Some(ispublic)=config.ispublic{
query.append_pair("ispublic",if ispublic{"True"}else{"False"});
}
if let Some(allow_comments)=config.allowComments{
query.append_pair("allowComments",if allow_comments{"True"}else{"False"});
}
if let Some(group_id)=config.groupId{
query.append_pair("groupId",group_id.to_string().as_str());
}
}
self.post(url,body).await.map_err(UploadError::PostError)?
.error_for_status().map_err(UploadError::Reqwest)?
.json::<UploadResponse>().await.map_err(UploadError::Reqwest)
}
pub async fn get_asset(&self,config:GetAssetRequest)->Result<Vec<u8>,GetError>{
let mut url=reqwest::Url::parse("https://assetdelivery.roblox.com/v1/asset/").map_err(GetError::ParseError)?;
//url borrow scope
{
let mut query=url.query_pairs_mut();//borrow here
query.append_pair("ID",config.asset_id.to_string().as_str());
if let Some(version)=config.version{
query.append_pair("version",version.to_string().as_str());
}
}
let body=self.get(url).await.map_err(GetError::Reqwest)?
.error_for_status().map_err(GetError::Reqwest)?
.bytes().await.map_err(GetError::Reqwest)?;
match maybe_gzip_decode(&mut std::io::Cursor::new(body)){
Ok(ReaderType::GZip(readable))=>read_readable(readable),
Ok(ReaderType::Raw(readable))=>read_readable(readable),
Err(e)=>Err(e),
}.map_err(GetError::IO)
}
pub async fn get_asset_versions_page(&self,config:AssetVersionsPageRequest)->Result<AssetVersionsPageResponse,AssetVersionsPageError>{
let mut url=reqwest::Url::parse(format!("https://develop.roblox.com/v1/assets/{}/saved-versions",config.asset_id).as_str()).map_err(AssetVersionsPageError::ParseError)?;
//url borrow scope
{
let mut query=url.query_pairs_mut();//borrow here
//query.append_pair("sortOrder","Asc");
//query.append_pair("limit","100");
//query.append_pair("count","100");
if let Some(cursor)=config.cursor.as_deref(){
query.append_pair("cursor",cursor);
}
}
self.get(url).await.map_err(AssetVersionsPageError::Reqwest)?
.error_for_status().map_err(AssetVersionsPageError::Reqwest)?
.json::<AssetVersionsPageResponse>().await.map_err(AssetVersionsPageError::Reqwest)
}
pub async fn get_inventory_page(&self,config:InventoryPageRequest)->Result<InventoryPageResponse,InventoryPageError>{
let mut url=reqwest::Url::parse(format!("https://apis.roblox.com/toolbox-service/v1/creations/group/{}/10?limit=50",config.group).as_str()).map_err(InventoryPageError::ParseError)?;
//url borrow scope
{
let mut query=url.query_pairs_mut();//borrow here
if let Some(cursor)=config.cursor.as_deref(){
query.append_pair("cursor",cursor);
}
}
self.get(url).await.map_err(InventoryPageError::Reqwest)?
.error_for_status().map_err(InventoryPageError::Reqwest)?
.json::<InventoryPageResponse>().await.map_err(InventoryPageError::Reqwest)
}
}

2
rbx_asset/src/lib.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod cloud;
pub mod cookie;

12
rox_compiler/Cargo.toml Normal file
View File

@ -0,0 +1,12 @@
[package]
name = "rox_compiler"
version = "0.1.0"
edition = "2021"
[dependencies]
futures = "0.3.30"
lazy-regex = "3.1.0"
rayon = "1.8.0"
rbx_dom_weak = "2.7.0"
rbx_xml = "0.13.3"
tokio = { version = "1.35.1", features = ["fs"] }

View File

@ -0,0 +1,33 @@
#[derive(Clone,Copy,Debug)]
pub enum Style{
Rox,
Rojo,
RoxRojo,
}
#[derive(Default)]
pub(crate) struct PropertiesOverride{
pub name:Option<String>,
pub class:Option<String>,
}
impl PropertiesOverride{
pub fn is_some(&self)->bool{
self.name.is_some()
||self.class.is_some()
}
}
impl std::fmt::Display for PropertiesOverride{
fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
if let Some(name)=self.name.as_deref(){
writeln!(f,"-- Properties.Name = \"{}\"",name)?;
}
if let Some(class)=self.class.as_deref(){
writeln!(f,"-- Properties.ClassName = \"{}\"",class)?;
}
Ok(())
}
}
pub(crate) fn sanitize(s:&str)->std::borrow::Cow<'_,str>{
lazy_regex::regex!(r"[^A-Za-z0-9.-]").replace_all(s,"_")
}

560
rox_compiler/src/compile.rs Normal file
View File

@ -0,0 +1,560 @@
use std::path::{Path,PathBuf};
use futures::{StreamExt, TryStreamExt};
use tokio::io::AsyncReadExt;
use crate::common::{sanitize,Style,PropertiesOverride};
//holy smokes what am I doing lmao
//This giant machine is supposed to search for files according to style rules
//e.g. ScriptName.server.lua or init.lua
//Obviously I got carried away
//I could use an enum!
//I could use a struct!
//I could use a trait!
//I could use an error!
//I could use a match!
//I could use a function!
//eventually:
#[derive(Debug)]
#[allow(dead_code)]//idk why this thinks it's dead code, the errors are printed out in various places
pub enum QueryResolveError{
NotFound,//0 results
Ambiguous,//>1 results
JoinError(tokio::task::JoinError),
IO(std::io::Error),
}
impl std::fmt::Display for QueryResolveError{
fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
write!(f,"{self:?}")
}
}
impl std::error::Error for QueryResolveError{}
struct FileWithName{
file:tokio::fs::File,
name:String,
}
async fn get_file_async(mut path:PathBuf,file_name:impl AsRef<std::path::Path>)->Result<FileWithName,QueryResolveError>{
let name=file_name.as_ref().to_str().unwrap().to_owned();
path.push(file_name);
match tokio::fs::File::open(path).await{
Ok(file)=>Ok(FileWithName{file,name}),
Err(e)=>match e.kind(){
std::io::ErrorKind::NotFound=>Err(QueryResolveError::NotFound),
_=>Err(QueryResolveError::IO(e)),
},
}
}
type QueryHintResult=Result<FileHint,QueryResolveError>;
trait Query{
async fn resolve(self)->QueryHintResult;
}
type QueryHandle=tokio::task::JoinHandle<Result<FileWithName,QueryResolveError>>;
struct QuerySingle{
script:QueryHandle,
}
impl QuerySingle{
fn rox(search_path:&Path,search_name:&str)->Self{
Self{
script:tokio::spawn(get_file_async(search_path.to_owned(),format!("{}.lua",search_name)))
}
}
}
impl Query for QuerySingle{
async fn resolve(self)->QueryHintResult{
match self.script.await{
Ok(Ok(file))=>Ok(FileHint{file,hint:ScriptHint::ModuleScript}),
Ok(Err(e))=>Err(e),
Err(e)=>Err(QueryResolveError::JoinError(e)),
}
}
}
struct QueryTriple{
module:QueryHandle,
server:QueryHandle,
client:QueryHandle,
}
impl QueryTriple{
fn rox_rojo(search_path:&Path,search_name:&str,search_module:bool)->Self{
//this should be implemented as constructors of Triplet and Quadruplet to fully support Trey's suggestion
let module_name=if search_module{
format!("{}.module.lua",search_name)
}else{
format!("{}.lua",search_name)
};
Self{
module:tokio::spawn(get_file_async(search_path.to_owned(),module_name)),
server:tokio::spawn(get_file_async(search_path.to_owned(),format!("{}.server.lua",search_name))),
client:tokio::spawn(get_file_async(search_path.to_owned(),format!("{}.client.lua",search_name))),
}
}
fn rojo(search_path:&Path)->Self{
QueryTriple::rox_rojo(search_path,"init",false)
}
}
//these functions can be achieved with macros, but I have not learned that yet
fn mega_triple_join(query_triplet:(QueryHintResult,QueryHintResult,QueryHintResult))->QueryHintResult{
match query_triplet{
//unambiguously locate file
(Ok(f),Err(QueryResolveError::NotFound),Err(QueryResolveError::NotFound))
|(Err(QueryResolveError::NotFound),Ok(f),Err(QueryResolveError::NotFound))
|(Err(QueryResolveError::NotFound),Err(QueryResolveError::NotFound),Ok(f))=>Ok(f),
//multiple files located
(Ok(_),Ok(_),Err(QueryResolveError::NotFound))
|(Ok(_),Err(QueryResolveError::NotFound),Ok(_))
|(Err(QueryResolveError::NotFound),Ok(_),Ok(_))
|(Ok(_),Ok(_),Ok(_))=>Err(QueryResolveError::Ambiguous),
//no files located
(Err(QueryResolveError::NotFound),Err(QueryResolveError::NotFound),Err(QueryResolveError::NotFound))=>Err(QueryResolveError::NotFound),
//other error
(Err(e),_,_)
|(_,Err(e),_)
|(_,_,Err(e))=>Err(e),
}
}
//LETS GOOOOOOOOOOOOOOOO
fn mega_quadruple_join(query_quad:(QueryHintResult,QueryHintResult,QueryHintResult,QueryHintResult))->QueryHintResult{
match query_quad{
//unambiguously locate file
(Ok(f),Err(QueryResolveError::NotFound),Err(QueryResolveError::NotFound),Err(QueryResolveError::NotFound))
|(Err(QueryResolveError::NotFound),Ok(f),Err(QueryResolveError::NotFound),Err(QueryResolveError::NotFound))
|(Err(QueryResolveError::NotFound),Err(QueryResolveError::NotFound),Ok(f),Err(QueryResolveError::NotFound))
|(Err(QueryResolveError::NotFound),Err(QueryResolveError::NotFound),Err(QueryResolveError::NotFound),Ok(f))=>Ok(f),
//multiple files located
(Ok(_),Ok(_),Ok(_),Err(QueryResolveError::NotFound))
|(Ok(_),Ok(_),Err(QueryResolveError::NotFound),Ok(_))
|(Ok(_),Ok(_),Err(QueryResolveError::NotFound),Err(QueryResolveError::NotFound))
|(Ok(_),Err(QueryResolveError::NotFound),Ok(_),Ok(_))
|(Ok(_),Err(QueryResolveError::NotFound),Ok(_),Err(QueryResolveError::NotFound))
|(Ok(_),Err(QueryResolveError::NotFound),Err(QueryResolveError::NotFound),Ok(_))
|(Err(QueryResolveError::NotFound),Ok(_),Ok(_),Ok(_))
|(Err(QueryResolveError::NotFound),Ok(_),Ok(_),Err(QueryResolveError::NotFound))
|(Err(QueryResolveError::NotFound),Ok(_),Err(QueryResolveError::NotFound),Ok(_))
|(Err(QueryResolveError::NotFound),Err(QueryResolveError::NotFound),Ok(_),Ok(_))
|(Ok(_),Ok(_),Ok(_),Ok(_))=>Err(QueryResolveError::Ambiguous),
//no files located
(Err(QueryResolveError::NotFound),Err(QueryResolveError::NotFound),Err(QueryResolveError::NotFound),Err(QueryResolveError::NotFound))=>Err(QueryResolveError::NotFound),
//other error
(Err(e),_,_,_)
|(_,Err(e),_,_)
|(_,_,Err(e),_)
|(_,_,_,Err(e))=>Err(e),
}
}
impl Query for QueryTriple{
async fn resolve(self)->QueryHintResult{
let (module,server,client)=tokio::join!(self.module,self.server,self.client);
mega_triple_join((
module.map_err(QueryResolveError::JoinError)?.map(|file|FileHint{file,hint:ScriptHint::ModuleScript}),
server.map_err(QueryResolveError::JoinError)?.map(|file|FileHint{file,hint:ScriptHint::Script}),
client.map_err(QueryResolveError::JoinError)?.map(|file|FileHint{file,hint:ScriptHint::LocalScript}),
))
}
}
struct QueryQuad{
module_implicit:QueryHandle,
module_explicit:QueryHandle,
server:QueryHandle,
client:QueryHandle,
}
impl QueryQuad{
fn rox_rojo(search_path:&Path,search_name:&str)->Self{
let fill=QueryTriple::rox_rojo(search_path,search_name,true);
Self{
module_implicit:QuerySingle::rox(search_path,search_name).script,//Script.lua
module_explicit:fill.module,//Script.module.lua
server:fill.server,
client:fill.client,
}
}
}
impl Query for QueryQuad{
async fn resolve(self)->QueryHintResult{
let (module_implicit,module_explicit,server,client)=tokio::join!(self.module_implicit,self.module_explicit,self.server,self.client);
mega_quadruple_join((
module_implicit.map_err(QueryResolveError::JoinError)?.map(|file|FileHint{file,hint:ScriptHint::ModuleScript}),
module_explicit.map_err(QueryResolveError::JoinError)?.map(|file|FileHint{file,hint:ScriptHint::ModuleScript}),
server.map_err(QueryResolveError::JoinError)?.map(|file|FileHint{file,hint:ScriptHint::Script}),
client.map_err(QueryResolveError::JoinError)?.map(|file|FileHint{file,hint:ScriptHint::LocalScript}),
))
}
}
struct ScriptWithOverrides{
overrides:PropertiesOverride,
source:String,
}
#[derive(Debug)]
pub enum ScriptWithOverridesError{
UnimplementedProperty(String),
}
impl std::fmt::Display for ScriptWithOverridesError{
fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
write!(f,"{self:?}")
}
}
impl std::error::Error for ScriptWithOverridesError{}
impl ScriptWithOverrides{
fn from_source(mut source:String)->Result<Self,ScriptWithOverridesError>{
let mut overrides=PropertiesOverride::default();
let mut count=0;
for line in source.lines(){
//only string type properties are supported atm
if let Some(captures)=lazy_regex::regex!(r#"^\-\-\s*Properties\.([A-Za-z]\w*)\s*\=\s*"(\w+)"$"#)
.captures(line){
count+=line.len();
match &captures[1]{
"Name"=>overrides.name=Some(captures[2].to_owned()),
"ClassName"=>overrides.class=Some(captures[2].to_owned()),
other=>Err(ScriptWithOverridesError::UnimplementedProperty(other.to_owned()))?,
}
}else{
break;
}
}
Ok(ScriptWithOverrides{overrides,source:source.split_off(count)})
}
}
enum CompileClass{
Folder,
Script(String),
LocalScript(String),
ModuleScript(String),
Model(Vec<u8>),
}
struct CompileNode{
name:String,
blacklist:Option<String>,
class:CompileClass,
}
#[derive(Debug)]
pub enum CompileNodeError{
IO(std::io::Error),
ScriptWithOverrides(ScriptWithOverridesError),
InvalidClassOrHint{
class:Option<String>,
hint:ScriptHint
},
QueryResolveError(QueryResolveError),
/// Conversion from OsString to String failed
FileName(std::ffi::OsString),
ExtensionNotSupportedInStyle{
extension:String,
style:Option<Style>,
},
UnknownExtension,
}
impl std::fmt::Display for CompileNodeError{
fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
write!(f,"{self:?}")
}
}
impl std::error::Error for CompileNodeError{}
enum FileDiscernment{
Model,
Script(ScriptHint),
}
impl CompileNode{
async fn script(search_name:&str,mut file:FileWithName,hint:ScriptHint)->Result<Self,CompileNodeError>{
//read entire file
let mut buf=String::new();
file.file.read_to_string(&mut buf).await.map_err(CompileNodeError::IO)?;
//regex script according to Properties lines at the top
let script_with_overrides=ScriptWithOverrides::from_source(buf).map_err(CompileNodeError::ScriptWithOverrides)?;
//script
Ok(Self{
blacklist:Some(file.name),
name:script_with_overrides.overrides.name.unwrap_or_else(||search_name.to_owned()),
class:match (script_with_overrides.overrides.class.as_deref(),hint){
(Some("ModuleScript"),_)
|(None,ScriptHint::ModuleScript)=>CompileClass::ModuleScript(script_with_overrides.source),
(Some("LocalScript"),_)
|(None,ScriptHint::LocalScript)=>CompileClass::LocalScript(script_with_overrides.source),
(Some("Script"),_)
|(None,ScriptHint::Script)=>CompileClass::Script(script_with_overrides.source),
(class,hint)=>Err(CompileNodeError::InvalidClassOrHint{class:class.map(|s|s.to_owned()),hint})?,
},
})
}
async fn model(search_name:&str,mut file:FileWithName)->Result<Self,CompileNodeError>{
//read entire file
let mut buf=Vec::new();
file.file.read_to_end(&mut buf).await.map_err(CompileNodeError::IO)?;
//model
Ok(Self{
blacklist:Some(file.name),
name:search_name.to_owned(),//wrong but gets overwritten by internal model name
class:CompileClass::Model(buf),
})
}
async fn from_folder(entry:&tokio::fs::DirEntry,style:Option<Style>)->Result<Self,CompileNodeError>{
let contents_folder=entry.path();
let file_name=entry.file_name();
let search_name=file_name.to_str().unwrap();
//scan inside the folder for an object to define the class of the folder
let script_query=async {match style{
Some(Style::Rox)=>QuerySingle::rox(&contents_folder,search_name).resolve().await,
Some(Style::RoxRojo)=>QueryQuad::rox_rojo(&contents_folder,search_name).resolve().await,
Some(Style::Rojo)=>QueryTriple::rojo(&contents_folder).resolve().await,
//try all three and complain if there is ambiguity
None=>mega_triple_join(tokio::join!(
QuerySingle::rox(&contents_folder,search_name).resolve(),
//true=search for module here to avoid ambiguity with QuerySingle::rox results
QueryTriple::rox_rojo(&contents_folder,search_name,true).resolve(),
QueryTriple::rojo(&contents_folder).resolve(),
))
}};
//model files are rox & rox-rojo only, so it's a lot less work...
let model_query=get_file_async(contents_folder.clone(),format!("{}.rbxmx",search_name));
//model? script? both?
Ok(match tokio::join!(script_query,model_query){
(Ok(FileHint{file,hint}),Err(QueryResolveError::NotFound))=>Self::script(search_name,file,hint).await?,
(Err(QueryResolveError::NotFound),Ok(file))=>Self::model(search_name,file).await?,
(Ok(_),Ok(_))=>Err(CompileNodeError::QueryResolveError(QueryResolveError::Ambiguous))?,
//neither
(Err(QueryResolveError::NotFound),Err(QueryResolveError::NotFound))=>Self{
name:search_name.to_owned(),
blacklist:None,
class:CompileClass::Folder,
},
//other error
(Err(e),_)
|(_,Err(e))=>Err(CompileNodeError::QueryResolveError(e))?
})
}
async fn from_file(entry:&tokio::fs::DirEntry,style:Option<Style>)->Result<Self,CompileNodeError>{
let mut file_name=entry
.file_name()
.into_string()
.map_err(CompileNodeError::FileName)?;
//reject goobers
let is_goober=matches!(style,Some(Style::Rojo));
let (ext_len,file_discernment)={
if let Some(captures)=lazy_regex::regex!(r"^.*(\.module\.lua|\.client\.lua|\.server\.lua)$")
.captures(file_name.as_str()){
let ext=&captures[1];
(ext.len(),match ext{
".module.lua"=>{
if is_goober{
Err(CompileNodeError::ExtensionNotSupportedInStyle{extension:ext.to_owned(),style})?;
}
FileDiscernment::Script(ScriptHint::ModuleScript)
},
".client.lua"=>FileDiscernment::Script(ScriptHint::LocalScript),
".server.lua"=>FileDiscernment::Script(ScriptHint::Script),
_=>panic!("Regex failed"),
})
}else if let Some(captures)=lazy_regex::regex!(r"^.*(\.rbxmx|\.lua)$")
.captures(file_name.as_str()){
let ext=&captures[1];
(ext.len(),match ext{
".rbxmx"=>{
if is_goober{
Err(CompileNodeError::ExtensionNotSupportedInStyle{extension:ext.to_owned(),style})?;
}
FileDiscernment::Model
},
".lua"=>FileDiscernment::Script(ScriptHint::ModuleScript),
_=>panic!("Regex failed"),
})
}else{
return Err(CompileNodeError::UnknownExtension);
}
};
file_name.truncate(file_name.len()-ext_len);
let file=tokio::fs::File::open(entry.path()).await.map_err(CompileNodeError::IO)?;
Ok(match file_discernment{
FileDiscernment::Model=>Self::model(file_name.as_str(),FileWithName{file,name:file_name.clone()}).await?,
FileDiscernment::Script(hint)=>Self::script(file_name.as_str(),FileWithName{file,name:file_name.clone()},hint).await?,
})
}
}
#[derive(Debug)]
pub enum ScriptHint{
Script,
LocalScript,
ModuleScript,
}
struct FileHint{
file:FileWithName,
hint:ScriptHint,
}
enum PreparedData{
Model(rbx_dom_weak::WeakDom),
Builder(rbx_dom_weak::InstanceBuilder),
}
enum CompileStackInstruction{
TraverseReferent(rbx_dom_weak::types::Ref,Option<String>),
PopFolder,
}
fn script_builder(class:&str,name:&str,source:String)->rbx_dom_weak::InstanceBuilder{
let mut builder=rbx_dom_weak::InstanceBuilder::new(class);
builder.set_name(name);
builder.add_property("Source",rbx_dom_weak::types::Variant::String(source));
builder
}
enum TooComplicated<T>{
Stop,
Value(T),
Skip,
}
pub struct CompileConfig{
pub input_folder:PathBuf,
pub style:Option<Style>,
}
#[derive(Debug)]
pub enum CompileError{
NullChildRef,
IO(std::io::Error),
CompileNode(CompileNodeError),
DecodeError(rbx_xml::DecodeError),
JoinError(tokio::task::JoinError),
}
impl std::fmt::Display for CompileError{
fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
write!(f,"{self:?}")
}
}
impl std::error::Error for CompileError{}
pub async fn compile(config:CompileConfig,mut dom:&mut rbx_dom_weak::WeakDom)->Result<(),CompileError>{
//hack to traverse root folder as the root object
"src".clone_into(&mut dom.root_mut().name);
//add in scripts and models
let mut folder=config.input_folder.clone();
let mut stack:Vec<CompileStackInstruction>=vec![CompileStackInstruction::TraverseReferent(dom.root_ref(),None)];
while let Some(instruction)=stack.pop(){
match instruction{
CompileStackInstruction::TraverseReferent(item_ref,blacklist)=>{
//scope to avoid holding item ref
{
let item=dom.get_by_ref(item_ref).ok_or(CompileError::NullChildRef)?;
let folder_name=sanitize(item.name.as_str());
folder.push(folder_name.as_ref());
//drop item
}
stack.push(CompileStackInstruction::PopFolder);
//check if a folder exists with item.name
if let Ok(dir)=tokio::fs::read_dir(folder.as_path()).await{
let mut exist_names:std::collections::HashSet<String>={
let item=dom.get_by_ref(item_ref).ok_or(CompileError::NullChildRef)?;
//push existing dom children objects onto stack (unrelated to exist_names)
stack.extend(item.children().iter().map(|&referent|CompileStackInstruction::TraverseReferent(referent,None)));
//get names of existing objects
item.children().iter().map(|&child_ref|{
let child=dom.get_by_ref(child_ref).ok_or(CompileError::NullChildRef)?;
Ok::<_,CompileError>(sanitize(child.name.as_str()).to_string())
}).collect::<Result<_,CompileError>>()?
};
if let Some(dont)=blacklist{
exist_names.insert(dont);
}
//generate children from folder contents UNLESS! item already has a child of the same name
let style=config.style;
let exist_names=&exist_names;
futures::stream::unfold(dir,|mut dir1|async{
//thread the needle! follow the path that dir takes!
let ret1={
//capture a scoped mutable reference so we can forward dir to the next call even on an error
let dir2=&mut dir1;
async move{//error catcher so I can use ?
let ret2=if let Some(entry)=dir2.next_entry().await?{
//cull early even if supporting things with identical names is possible
if exist_names.contains(entry.file_name().to_str().unwrap()){
TooComplicated::Skip
}else{
TooComplicated::Value(entry)
}
}else{
TooComplicated::Stop
};
Ok(ret2)
}.await
};
match ret1{
Ok(TooComplicated::Stop)=>None,
Ok(TooComplicated::Skip)=>Some((Ok(None),dir1)),
Ok(TooComplicated::Value(v))=>Some((Ok(Some(v)),dir1)),
Err(e)=>Some((Err(CompileError::IO(e)),dir1)),
}
})
//gotta spawn off the worker threads (Model is slow)
.then(|bog|async{
match bog{
Ok(Some(entry))=>tokio::spawn(async move{
let met=entry.metadata().await.map_err(CompileError::IO)?;
//discern that bad boy
let compile_class={
let result=match met.is_dir(){
true=>CompileNode::from_folder(&entry,style).await,
false=>CompileNode::from_file(&entry,style).await,
};
match result{
Ok(compile_class)=>compile_class,
Err(e)=>{
println!("Ignoring file {entry:?} due to error {e}");
return Ok(None);
},
}
};
//prepare data structure
Ok(Some((compile_class.blacklist,match compile_class.class{
CompileClass::Folder=>PreparedData::Builder(rbx_dom_weak::InstanceBuilder::new("Folder").with_name(compile_class.name.as_str())),
CompileClass::Script(source)=>PreparedData::Builder(script_builder("Script",compile_class.name.as_str(),source)),
CompileClass::LocalScript(source)=>PreparedData::Builder(script_builder("LocalScript",compile_class.name.as_str(),source)),
CompileClass::ModuleScript(source)=>PreparedData::Builder(script_builder("ModuleScript",compile_class.name.as_str(),source)),
CompileClass::Model(buf)=>PreparedData::Model(rbx_xml::from_reader_default(std::io::Cursor::new(buf)).map_err(CompileError::DecodeError)?),
})))
}).await.map_err(CompileError::JoinError)?,
Ok(None)=>Ok(None),
Err(e)=>Err(e),
}
})
//is this even what I want?
.map(|f|async{f}).buffer_unordered(32)
//begin processing immediately
//TODO: fix dom being &mut &mut inside the closure
.try_fold((&mut stack,&mut dom),|(stack,dom),bog|async{
//push child objects onto dom serially as they arrive
if let Some((blacklist,data))=bog{
let referent=match data{
PreparedData::Model(mut model_dom)=>{
let referent=model_dom.root().children()[0];
model_dom.transfer(referent,dom,item_ref);
referent
},
PreparedData::Builder(script)=>dom.insert(item_ref,script),
};
//new children need to be traversed
stack.push(CompileStackInstruction::TraverseReferent(referent,blacklist));
}
Ok((stack,dom))
}).await?;
}
},
CompileStackInstruction::PopFolder=>assert!(folder.pop(),"pop folder bad"),
}
}
Ok(())
}

View File

@ -0,0 +1,320 @@
use std::path::PathBuf;
use rbx_dom_weak::types::Ref;
use crate::common::{sanitize,Style,PropertiesOverride};
#[derive(PartialEq)]
enum Class{
Folder,
ModuleScript,
LocalScript,
Script,
Model,
}
struct TreeNode{
name:String,
referent:Ref,
parent:Ref,
class:Class,
children:Vec<Ref>,
}
impl TreeNode{
fn new(name:String,referent:Ref,parent:Ref,class:Class)->Self{
Self{
name,
referent,
parent,
class,
children:Vec::new(),
}
}
}
enum TrimStackInstruction{
Referent(Ref),
IncrementScript,
DecrementScript,
}
enum WriteStackInstruction<'a>{
Node(&'a TreeNode,u32),//(Node,NameTally)
PushFolder(String),
PopFolder,
Destroy(Ref),
}
#[derive(Debug)]
pub enum WriteError{
ClassNotScript(String),
IO(std::io::Error),
EncodeError(rbx_xml::EncodeError),
}
impl std::fmt::Display for WriteError{
fn fmt(&self,f:&mut std::fmt::Formatter<'_>)->std::fmt::Result{
write!(f,"{self:?}")
}
}
impl std::error::Error for WriteError{}
fn write_item(dom:&rbx_dom_weak::WeakDom,mut file:PathBuf,node:&TreeNode,node_name_override:String,mut properties:PropertiesOverride,style:Style,write_models:bool,write_scripts:bool)->Result<(),WriteError>{
file.push(sanitize(node_name_override.as_str()).as_ref());
match node.class{
Class::Folder=>(),
Class::ModuleScript|Class::LocalScript|Class::Script=>{
if !write_scripts{
return Ok(())
}
//set extension
match style{
Style::Rox=>assert!(file.set_extension("lua"),"could not set extension"),
Style::RoxRojo|Style::Rojo=>{
match properties.class.as_deref(){
Some("LocalScript")=>{
file.set_extension("client.lua");
properties.class=None;
},
Some("Script")=>{
file.set_extension("server.lua");
properties.class=None;
},
// Some("ModuleScript")=>{
// file.set_extension("module");
// properties.class=None;
// },
None=>assert!(file.set_extension("lua"),"could not set extension"),
Some(other)=>Err(WriteError::ClassNotScript(other.to_owned()))?,
}
}
}
if let Some(item)=dom.get_by_ref(node.referent){
//TODO: delete disabled scripts
if let Some(rbx_dom_weak::types::Variant::String(source))=item.properties.get("Source"){
if properties.is_some(){
//rox style
let source=properties.to_string()+source.as_str();
std::fs::write(file,source).map_err(WriteError::IO)?;
}else{
std::fs::write(file,source).map_err(WriteError::IO)?;
}
}
}
},
Class::Model=>{
if !write_models{
return Ok(())
}
assert!(file.set_extension("rbxmx"));
let output=std::io::BufWriter::new(std::fs::File::create(file).map_err(WriteError::IO)?);
rbx_xml::to_writer_default(output,dom,&[node.referent]).map_err(WriteError::EncodeError)?;
},
}
Ok(())
}
pub struct WriteConfig{
pub style:Style,
pub output_folder:PathBuf,
pub write_template:bool,
pub write_models:bool,
pub write_scripts:bool,
}
pub struct DecompiledContext{
dom:rbx_dom_weak::WeakDom,
tree_refs:std::collections::HashMap<rbx_dom_weak::types::Ref,TreeNode>,
}
impl DecompiledContext{
/// Will panic on circular tree structure but otherwise infallible
pub fn from_dom(dom:rbx_dom_weak::WeakDom)->Self{
let mut tree_refs=std::collections::HashMap::new();
tree_refs.insert(dom.root_ref(),TreeNode::new(
"src".to_owned(),
dom.root_ref(),
Ref::none(),
Class::Folder
));
//run rules
let mut stack=vec![dom.root()];
while let Some(item)=stack.pop(){
let class=match item.class.as_str(){
"ModuleScript"=>Class::ModuleScript,
"LocalScript"=>Class::LocalScript,
"Script"=>Class::Script,
"Model"=>Class::Model,
_=>Class::Folder,
};
let skip=class==Class::Model;
if let Some(parent_node)=tree_refs.get_mut(&item.parent()){
let referent=item.referent();
let node=TreeNode::new(item.name.clone(),referent,parent_node.referent,class);
parent_node.children.push(referent);
tree_refs.insert(referent,node);
}
//look no further, turn this node and all its children into a model
if skip{
continue;
}
for &referent in item.children(){
if let Some(c)=dom.get_by_ref(referent){
stack.push(c);
}
}
}
//trim empty folders
let mut script_count=0;
let mut stack:Vec<TrimStackInstruction>=tree_refs.get(&dom.root_ref()).unwrap().children
.iter().map(|&c|TrimStackInstruction::Referent(c)).collect();
while let Some(instruction)=stack.pop(){
match instruction{
TrimStackInstruction::IncrementScript=>script_count+=1,
TrimStackInstruction::DecrementScript=>script_count-=1,
TrimStackInstruction::Referent(referent)=>{
let mut delete=None;
if let Some(node)=tree_refs.get_mut(&referent){
if node.class==Class::Folder&&script_count!=0{
node.class=Class::Model
}
if node.class==Class::Folder&&node.children.is_empty(){
delete=Some(node.parent);
}else{
//how the hell do I do this better without recursion
let is_script=matches!(
node.class,
Class::ModuleScript|Class::LocalScript|Class::Script
);
//stack is popped from back
if is_script{
stack.push(TrimStackInstruction::DecrementScript);
}
for &child_referent in &node.children{
stack.push(TrimStackInstruction::Referent(child_referent));
}
if is_script{
stack.push(TrimStackInstruction::IncrementScript);
}
}
}
//trim referent
if let Some(parent_ref)=delete{
let parent_node=tree_refs.get_mut(&parent_ref)
.expect("parent_ref does not exist in tree_refs");
parent_node.children.remove(
parent_node.children.iter()
.position(|&r|r==referent)
.expect("parent.children does not contain referent")
);
tree_refs.remove(&referent);
}
},
}
}
Self{
dom,
tree_refs,
}
}
pub async fn write_files(mut self,config:WriteConfig)->Result<(),WriteError>{
let mut write_queue=Vec::new();
let mut destroy_queue=Vec::new();
let mut name_tally=std::collections::HashMap::<String,u32>::new();
let mut folder=config.output_folder.clone();
let mut stack=vec![WriteStackInstruction::Node(self.tree_refs.get(&self.dom.root_ref()).unwrap(),0)];
while let Some(instruction)=stack.pop(){
match instruction{
WriteStackInstruction::PushFolder(component)=>folder.push(component),
WriteStackInstruction::PopFolder=>assert!(folder.pop(),"weirdness"),
WriteStackInstruction::Destroy(referent)=>destroy_queue.push(referent),
WriteStackInstruction::Node(node,name_count)=>{
//track properties that must be overriden to compile folder structure back into a place file
let mut properties=PropertiesOverride::default();
let has_children=!node.children.is_empty();
match node.class{
Class::Folder=>(),
Class::ModuleScript=>(),//.lua files are ModuleScript by default
Class::LocalScript=>properties.class=Some("LocalScript".to_owned()),
Class::Script=>properties.class=Some("Script".to_owned()),
Class::Model=>(),
}
let name_override=if 0<name_count{
properties.name=Some(node.name.clone());
format!("{}_{}",node.name,name_count)
}else{
node.name.clone()
};
if has_children{
//push temp subfolder
let mut subfolder=folder.clone();
subfolder.push(sanitize(name_override.as_str()).as_ref());
//make folder
tokio::fs::create_dir(subfolder.clone()).await.map_err(WriteError::IO)?;
let name_final=match config.style{
Style::Rox
|Style::RoxRojo=>name_override.clone(),
Style::Rojo=>"init".to_owned(),
};
//write item in subfolder
write_queue.push((subfolder,node,name_final,properties,config.style));
}else{
//write item
write_queue.push((folder.clone(),node,name_override.clone(),properties,config.style));
}
//queue item to be deleted from dom after child objects are handled (stack is popped from the back)
match node.class{
Class::Folder=>(),
_=>stack.push(WriteStackInstruction::Destroy(node.referent)),
}
if has_children{
stack.push(WriteStackInstruction::PopFolder);
name_tally.clear();
for referent in &node.children{
if let Some(c)=self.tree_refs.get(referent){
let v=name_tally.entry(c.name.clone()).and_modify(|v|*v+=1).or_default();
stack.push(WriteStackInstruction::Node(c,*v));
}
}
stack.push(WriteStackInstruction::PushFolder(sanitize(name_override.as_str()).to_string()));
}
},
}
}
//run the async
{
let dom=&self.dom;
let write_models=config.write_models;
let write_scripts=config.write_scripts;
let results:Vec<Result<(),WriteError>>=rayon::iter::ParallelIterator::collect(rayon::iter::ParallelIterator::map(rayon::iter::IntoParallelIterator::into_par_iter(write_queue),|(write_path,node,node_name_override,properties,style)|{
write_item(dom,write_path,node,node_name_override,properties,style,write_models,write_scripts)
}));
for result in results{
result?;
}
}
//run the destroy
for destroy_ref in destroy_queue{
self.dom.destroy(destroy_ref);
}
//write what remains in template.rbxlx
if config.write_template{
let mut file=config.output_folder.clone();
file.push("template");
assert!(file.set_extension("rbxlx"));
let output=std::io::BufWriter::new(std::fs::File::create(file).map_err(WriteError::IO)?);
rbx_xml::to_writer_default(output,&self.dom,self.dom.root().children()).map_err(WriteError::EncodeError)?;
}
Ok(())
}
}

9
rox_compiler/src/lib.rs Normal file
View File

@ -0,0 +1,9 @@
mod common;
mod compile;
mod decompile;
//export minimal interface
pub use common::Style;
pub use compile::CompileConfig;
pub use compile::compile;//cringe unstandardized interface
pub use decompile::WriteConfig;
pub use decompile::DecompiledContext;

File diff suppressed because it is too large Load Diff