forked from StrafesNET/map-tool
Compare commits
122 Commits
test-files
...
feature/li
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f933407a9 | |||
| 205db9a0db | |||
| ca50bf35c2 | |||
| 6522c255cd | |||
| a5079f21d7 | |||
| 349cd9c233 | |||
| d455cf4dc9 | |||
| 3227a6486a | |||
| 1ce51dd4da | |||
| 1ad9723905 | |||
| 41b28fa7d2 | |||
| a2ab23097b | |||
| 602061b44c | |||
| 1989369956 | |||
| a18aea828c | |||
| b7000ee9af | |||
| 2b77ea5712 | |||
| cf98f8e7bb | |||
|
a56c114d08
|
|||
|
b6a5324ae7
|
|||
| 6f5a3c5176 | |||
| 6bab31f3b3 | |||
| 9cdeed160f | |||
| d0c59b51a4 | |||
| 451f3ccecb | |||
| ed9701981d | |||
| 60e0197344 | |||
| 4d97a490c1 | |||
| 52ba44c6be | |||
| 95b6272b18 | |||
| 0172675b04 | |||
| 982b4aecac | |||
| c1ddcdb0c5 | |||
| c2d0a4487c | |||
| dc9fd2c442 | |||
| 4199d41d3f | |||
| 7fbcb206ff | |||
| a17901d473 | |||
| b88c6b899a | |||
| 835d4bbecd | |||
| b756dc979c | |||
| 1e888ebb01 | |||
| b9dccb1af5 | |||
| c6d293cc6b | |||
| a386f90f51 | |||
| 43115cbac6 | |||
| 35b5aff9a7 | |||
| 36419af870 | |||
| a7518bef46 | |||
| 6df1f41599 | |||
| 422d0a160d | |||
| 1727f9213c | |||
| afa9e7447d | |||
| ff85efa54f | |||
| fa69c53cfc | |||
| a57c228580 | |||
| 5dc69db885 | |||
| e54400a436 | |||
| e2a5edf8df | |||
| d6dd1b8abd | |||
| a2b793fcd3 | |||
| 9cb34f14c8 | |||
| bd2e3aa2d3 | |||
| 07f6053839 | |||
| 0d5b918ea1 | |||
| 20a568220a | |||
| d670d4129e | |||
| de7b0bd5cc | |||
| 01524146c7 | |||
| 45e8e415d0 | |||
| 4417bafc5c | |||
| 8553625738 | |||
| 3a3749eaeb | |||
| 53539f290b | |||
| 479dd37f53 | |||
| 34b6a869f0 | |||
| 19a455ee5e | |||
| 9904b7a044 | |||
| 6efa811eb6 | |||
| 81e4a201bd | |||
| 8fd5618af2 | |||
| 54c26d6e1e | |||
| 110ec94a08 | |||
| 980da5a6a7 | |||
| 1cd77984d4 | |||
| b0fe231388 | |||
| 5a4a39ab75 | |||
|
|
1b2324deeb | ||
| 4c485e76e4 | |||
| 7bbb9ca24f | |||
| eff55af1b4 | |||
| 0d05cc9996 | |||
| 2a55ef90df | |||
| 1a6202ae66 | |||
|
|
742f7b4ec0 | ||
| 2cb346f49a | |||
| e5cca9ed04 | |||
| 52d911a25a | |||
| 7ab20f36a7 | |||
| a7554da1c5 | |||
| 37f0dad7a1 | |||
| e309f15cb8 | |||
| 29374e4ff5 | |||
| b7d04d1f40 | |||
| 432ec11ea6 | |||
| 01449b1850 | |||
| 327d0a4992 | |||
| 420dbaa022 | |||
| cad29af4bb | |||
| e0e8744bfd | |||
| b434dce0f6 | |||
| 6ef8fd2f69 | |||
| 7234065bd8 | |||
| 41d8e700c5 | |||
| 4ca3d56f0f | |||
| 593b6902fd | |||
| 7523c4313a | |||
| 694440bd29 | |||
| 755e1d4d5b | |||
| 4334a6f330 | |||
| 553ad2cca5 | |||
| 3f15d2f5a8 |
2
.cargo/config.toml
Normal file
2
.cargo/config.toml
Normal file
@@ -0,0 +1,2 @@
|
||||
[registries.strafesnet]
|
||||
index = "sparse+https://git.itzana.me/api/packages/strafesnet/cargo/"
|
||||
3603
Cargo.lock
generated
3603
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
46
Cargo.toml
46
Cargo.toml
@@ -1,11 +1,49 @@
|
||||
[package]
|
||||
name = "map-tool"
|
||||
version = "0.1.0"
|
||||
version = "2.0.0"
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
name = "map_tool"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "map-tool"
|
||||
path = "src/main.rs"
|
||||
required-features = ["cli"]
|
||||
|
||||
[features]
|
||||
cli = ["dep:clap"]
|
||||
|
||||
[dependencies]
|
||||
rbx_binary = "0.7.1"
|
||||
rbx_dom_weak = "2.5.0"
|
||||
rbx_reflection_database = "0.2.7"
|
||||
anyhow = "1.0.75"
|
||||
clap = { version = "4.4.2", features = ["derive"], optional = true }
|
||||
flate2 = "1.0.27"
|
||||
futures = "0.3.31"
|
||||
image = "0.25.2"
|
||||
image_dds = "0.7.1"
|
||||
lazy-regex = "3.1.0"
|
||||
rbx_asset = { version = "0.2.5", registry = "strafesnet" }
|
||||
rbx_binary = { version = "0.7.4", registry = "strafesnet" }
|
||||
rbx_dom_weak = { version = "2.7.0", registry = "strafesnet" }
|
||||
rbx_reflection_database = { version = "0.2.10", registry = "strafesnet" }
|
||||
rbx_xml = { version = "0.13.3", registry = "strafesnet" }
|
||||
rbxassetid = { version = "0.1.0", registry = "strafesnet" }
|
||||
strafesnet_bsp_loader = { version = "0.3.0", registry = "strafesnet" }
|
||||
strafesnet_deferred_loader = { version = "0.5.0", registry = "strafesnet" }
|
||||
strafesnet_rbx_loader = { version = "0.6.0", registry = "strafesnet" }
|
||||
strafesnet_snf = { version = "0.3.0", registry = "strafesnet" }
|
||||
thiserror = "2.0.11"
|
||||
tokio = { version = "1.43.0", features = ["macros", "rt-multi-thread", "fs"] }
|
||||
vbsp = "0.6.0"
|
||||
vmdl = "0.2.0"
|
||||
vmt-parser = "0.2.0"
|
||||
vpk = "0.2.0"
|
||||
vtf = "0.3.0"
|
||||
|
||||
#[profile.release]
|
||||
#lto = true
|
||||
#strip = true
|
||||
#codegen-units = 1
|
||||
|
||||
28
LICENSE
28
LICENSE
@@ -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) <year> <copyright holders>
|
||||
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.
|
||||
Binary file not shown.
126
allowed/0.lua
126
allowed/0.lua
@@ -1,126 +0,0 @@
|
||||
--local Model=game:GetService'InsertService':LoadAsset(1079831188):GetChildren()[1] Model:SetModelCFrame(CFrame.new(0,Model:GetModelSize().y/2,0))
|
||||
--[[ Load ID list
|
||||
local ids={5692157375}
|
||||
local ServerStorage=game:GetService'ServerStorage'
|
||||
local function load(id)
|
||||
local Model=game:GetObjects("rbxassetid://"..id)[1]
|
||||
Model.Parent=workspace
|
||||
Model:MoveTo(Vector3.new(0,Model:GetExtentsSize().y/2,0))
|
||||
wait()
|
||||
Model.Parent=ServerStorage
|
||||
end
|
||||
for i=1,#ids do
|
||||
local succ,err=ypcall(load,ids[i])
|
||||
if not succ then
|
||||
print(ids[i],"error",err)
|
||||
end
|
||||
end
|
||||
--]]
|
||||
--[[ Format map names
|
||||
local c=game:GetService'ServerStorage':GetChildren()
|
||||
for i=1,#c do
|
||||
local le_name=c[i].Name:gsub("%s+","_"):lower()
|
||||
c[i].Name=le_name
|
||||
local DisplayName=c[i]:FindFirstChild("DisplayName",true)
|
||||
if DisplayName and DisplayName.ClassName=="StringValue" then
|
||||
local dn=DisplayName.Value
|
||||
local ndn={}
|
||||
for w in dn:gmatch'%S+' do
|
||||
ndn[#ndn+1]=w:sub(1,1):upper()..w:sub(2)
|
||||
end
|
||||
if table.concat(ndn," ")~=dn then
|
||||
print("Fix name:",le_name)
|
||||
end
|
||||
end
|
||||
end
|
||||
--]]
|
||||
--[[ Duplicate script labeler
|
||||
local IsA=game.IsA
|
||||
|
||||
local ID=0
|
||||
local SourceHash={}
|
||||
local SourceHashCount={}
|
||||
local NameHash={}
|
||||
local IDHash={}
|
||||
|
||||
local c=game:GetService'ServerStorage':GetDescendants()
|
||||
for i=1,#c do
|
||||
local s=c[i]
|
||||
if IsA(s,"LuaSourceContainer") then
|
||||
local src=s.Source
|
||||
NameHash[s]=s.Name
|
||||
local id=SourceHash[src]
|
||||
if id then
|
||||
s.Name="copy "..id
|
||||
SourceHashCount[id]=SourceHashCount[id]+1
|
||||
else
|
||||
ID=ID+1
|
||||
IDHash[ID]=s
|
||||
SourceHash[src]=ID
|
||||
SourceHashCount[ID]=1
|
||||
if src:find'getfenv' or src:find'require' then
|
||||
s.Name="flagged "..ID
|
||||
else
|
||||
s.Name="unique "..ID
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
for i=1,ID do
|
||||
local s=IDHash[i]
|
||||
local hc=SourceHashCount[i]
|
||||
s.Name=s.Name..(hc==1 and " (1 copy)" or " ("..hc.." copies)")
|
||||
end
|
||||
_G.NameHash=NameHash
|
||||
--]]
|
||||
--[[ Undo labeler
|
||||
local NameHash=_G.NameHash
|
||||
for s,n in next,NameHash do
|
||||
s.Name=n
|
||||
end
|
||||
--]]
|
||||
local IsA=workspace.IsA
|
||||
local GetChildren=workspace.GetChildren
|
||||
local function rsearch(search,cond1,cond2)
|
||||
local found={}
|
||||
for _,thing in next,GetChildren(search) do
|
||||
if not cond1 or cond1(thing) then
|
||||
found[#found+1]=thing
|
||||
end
|
||||
if not cond2 or cond2(thing) then
|
||||
local nfound=#found
|
||||
local r=rsearch(thing,cond1,cond2)
|
||||
for i=1,#r do
|
||||
found[nfound+i]=r[i]
|
||||
end
|
||||
end
|
||||
end
|
||||
return found
|
||||
end
|
||||
local function cond1(thing)
|
||||
return IsA(thing,"LuaSourceContainer")
|
||||
end
|
||||
local Maps=GetChildren(game:GetService'ServerStorage')
|
||||
for i=1,#Maps do
|
||||
local Map=Maps[i]
|
||||
if Map.ClassName=="Model" then
|
||||
local Scripts=rsearch(Map,cond1)
|
||||
if #Scripts>0 then
|
||||
local ScriptHolder=Instance.new("Model",workspace)
|
||||
ScriptHolder.Name=Map.Name.."("..#Scripts..")"
|
||||
for i=1,#Scripts do
|
||||
local sc=Scripts[i]
|
||||
local scd
|
||||
if sc.ClassName~="ModuleScript" then
|
||||
sc.Disabled=true
|
||||
end
|
||||
local s=sc:Clone()
|
||||
s.Name=sc:GetFullName()
|
||||
s.Parent=ScriptHolder
|
||||
if sc.ClassName~="ModuleScript" then
|
||||
sc.Disabled=scd
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Binary file not shown.
13180
bhop_easyhop.rbxmx
13180
bhop_easyhop.rbxmx
File diff suppressed because it is too large
Load Diff
126
blocked/0.lua
126
blocked/0.lua
@@ -1,126 +0,0 @@
|
||||
--local Model=game:GetService'InsertService':LoadAsset(1079831188):GetChildren()[1] Model:SetModelCFrame(CFrame.new(0,Model:GetModelSize().y/2,0))
|
||||
--[[ Load ID list
|
||||
local ids={5692157375}
|
||||
local ServerStorage=game:GetService'ServerStorage'
|
||||
local function load(id)
|
||||
local Model=game:GetObjects("rbxassetid://"..id)[1]
|
||||
Model.Parent=workspace
|
||||
Model:MoveTo(Vector3.new(0,Model:GetExtentsSize().y/2,0))
|
||||
wait()
|
||||
Model.Parent=ServerStorage
|
||||
end
|
||||
for i=1,#ids do
|
||||
local succ,err=ypcall(load,ids[i])
|
||||
if not succ then
|
||||
print(ids[i],"error",err)
|
||||
end
|
||||
end
|
||||
--]]
|
||||
--[[ Format map names
|
||||
local c=game:GetService'ServerStorage':GetChildren()
|
||||
for i=1,#c do
|
||||
local le_name=c[i].Name:gsub("%s+","_"):lower()
|
||||
c[i].Name=le_name
|
||||
local DisplayName=c[i]:FindFirstChild("DisplayName",true)
|
||||
if DisplayName and DisplayName.ClassName=="StringValue" then
|
||||
local dn=DisplayName.Value
|
||||
local ndn={}
|
||||
for w in dn:gmatch'%S+' do
|
||||
ndn[#ndn+1]=w:sub(1,1):upper()..w:sub(2)
|
||||
end
|
||||
if table.concat(ndn," ")~=dn then
|
||||
print("Fix name:",le_name)
|
||||
end
|
||||
end
|
||||
end
|
||||
--]]
|
||||
--[[ Duplicate script labeler
|
||||
local IsA=game.IsA
|
||||
|
||||
local ID=0
|
||||
local SourceHash={}
|
||||
local SourceHashCount={}
|
||||
local NameHash={}
|
||||
local IDHash={}
|
||||
|
||||
local c=game:GetService'ServerStorage':GetDescendants()
|
||||
for i=1,#c do
|
||||
local s=c[i]
|
||||
if IsA(s,"LuaSourceContainer") then
|
||||
local src=s.Source
|
||||
NameHash[s]=s.Name
|
||||
local id=SourceHash[src]
|
||||
if id then
|
||||
s.Name="copy "..id
|
||||
SourceHashCount[id]=SourceHashCount[id]+1
|
||||
else
|
||||
ID=ID+1
|
||||
IDHash[ID]=s
|
||||
SourceHash[src]=ID
|
||||
SourceHashCount[ID]=1
|
||||
if src:find'getfenv' or src:find'require' then
|
||||
s.Name="flagged "..ID
|
||||
else
|
||||
s.Name="unique "..ID
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
for i=1,ID do
|
||||
local s=IDHash[i]
|
||||
local hc=SourceHashCount[i]
|
||||
s.Name=s.Name..(hc==1 and " (1 copy)" or " ("..hc.." copies)")
|
||||
end
|
||||
_G.NameHash=NameHash
|
||||
--]]
|
||||
--[[ Undo labeler
|
||||
local NameHash=_G.NameHash
|
||||
for s,n in next,NameHash do
|
||||
s.Name=n
|
||||
end
|
||||
--]]
|
||||
local IsA=workspace.IsA
|
||||
local GetChildren=workspace.GetChildren
|
||||
local function rsearch(search,cond1,cond2)
|
||||
local found={}
|
||||
for _,thing in next,GetChildren(search) do
|
||||
if not cond1 or cond1(thing) then
|
||||
found[#found+1]=thing
|
||||
end
|
||||
if not cond2 or cond2(thing) then
|
||||
local nfound=#found
|
||||
local r=rsearch(thing,cond1,cond2)
|
||||
for i=1,#r do
|
||||
found[nfound+i]=r[i]
|
||||
end
|
||||
end
|
||||
end
|
||||
return found
|
||||
end
|
||||
local function cond1(thing)
|
||||
return IsA(thing,"LuaSourceContainer")
|
||||
end
|
||||
local Maps=GetChildren(game:GetService'ServerStorage')
|
||||
for i=1,#Maps do
|
||||
local Map=Maps[i]
|
||||
if Map.ClassName=="Model" then
|
||||
local Scripts=rsearch(Map,cond1)
|
||||
if #Scripts>0 then
|
||||
local ScriptHolder=Instance.new("Model",workspace)
|
||||
ScriptHolder.Name=Map.Name.."("..#Scripts..")"
|
||||
for i=1,#Scripts do
|
||||
local sc=Scripts[i]
|
||||
local scd
|
||||
if sc.ClassName~="ModuleScript" then
|
||||
sc.Disabled=true
|
||||
end
|
||||
local s=sc:Clone()
|
||||
s.Name=sc:GetFullName()
|
||||
s.Parent=ScriptHolder
|
||||
if sc.ClassName~="ModuleScript" then
|
||||
sc.Disabled=scd
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
2
src/lib.rs
Normal file
2
src/lib.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod roblox;
|
||||
pub mod source;
|
||||
90
src/main.rs
90
src/main.rs
@@ -1,73 +1,27 @@
|
||||
fn class_is_a(class: &str, superclass: &str) -> bool {
|
||||
if class==superclass {
|
||||
return true
|
||||
}
|
||||
let class_descriptor=rbx_reflection_database::get().classes.get(class);
|
||||
if let Some(descriptor) = &class_descriptor {
|
||||
if let Some(class_super) = &descriptor.superclass {
|
||||
return class_is_a(&class_super, superclass)
|
||||
}
|
||||
}
|
||||
return false
|
||||
use clap::{Parser,Subcommand};
|
||||
use anyhow::Result as AResult;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
#[command(propagate_version = true)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
fn recursive_collect_scripts(scripts: &mut std::vec::Vec<rbx_dom_weak::types::Ref>,dom: &rbx_dom_weak::WeakDom, instance: &rbx_dom_weak::Instance){
|
||||
for &referent in instance.children() {
|
||||
if let Some(c) = dom.get_by_ref(referent) {
|
||||
if class_is_a(c.class.as_str(), "LuaSourceContainer") {
|
||||
scripts.push(c.referent());//copy ref
|
||||
}
|
||||
recursive_collect_scripts(scripts,dom,c);
|
||||
}
|
||||
}
|
||||
#[derive(Subcommand)]
|
||||
enum Commands{
|
||||
#[command(flatten)]
|
||||
Roblox(map_tool::roblox::Commands),
|
||||
#[command(flatten)]
|
||||
Source(map_tool::source::Commands),
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Using buffered I/O is recommended with rbx_binary
|
||||
let input = std::io::BufReader::new(std::fs::File::open("map.rbxm")?);
|
||||
|
||||
let dom = rbx_binary::from_reader(input)?;
|
||||
|
||||
//Construct allowed scripts
|
||||
let mut allowed = std::collections::HashSet::<String>::new();
|
||||
for entry in std::fs::read_dir("allowed")? {
|
||||
allowed.insert(std::fs::read_to_string(entry?.path())?);
|
||||
}
|
||||
|
||||
let mut scripts = std::vec::Vec::<rbx_dom_weak::types::Ref>::new();
|
||||
recursive_collect_scripts(&mut scripts, &dom, dom.root());
|
||||
|
||||
//check scribb
|
||||
let mut any_failed=false;
|
||||
for (i,&referent) in scripts.iter().enumerate() {
|
||||
if let Some(script) = dom.get_by_ref(referent) {
|
||||
if let Some(rbx_dom_weak::types::Variant::String(s)) = script.properties.get("Source") {
|
||||
if allowed.contains(s) {
|
||||
println!("pass");
|
||||
}else{
|
||||
println!("fail");
|
||||
any_failed=true;
|
||||
std::fs::write(format!("blocked/{}.lua",i),s)?;
|
||||
}
|
||||
}else{
|
||||
println!("failed to get source");
|
||||
any_failed=true;
|
||||
}
|
||||
}else{
|
||||
println!("failed to deref script");
|
||||
any_failed=true;
|
||||
}
|
||||
}
|
||||
if any_failed {
|
||||
println!("One or more scripts are not allowed.");
|
||||
return Ok(())//everything is not ok but idk how to return an error LMAO
|
||||
}
|
||||
println!("All scripts passed!");
|
||||
// std::process::Command::new("rbxcompiler")
|
||||
// .arg("--compile=false")
|
||||
// .arg("--group=6980477")
|
||||
// .arg("--asset=5692139100")
|
||||
// .arg("--input=map.rbxm")
|
||||
// .spawn()?;
|
||||
Ok(())
|
||||
#[tokio::main]
|
||||
async fn main()->AResult<()>{
|
||||
let cli=Cli::parse();
|
||||
match cli.command{
|
||||
Commands::Roblox(commands)=>commands.run().await,
|
||||
Commands::Source(commands)=>commands.run().await,
|
||||
}
|
||||
}
|
||||
|
||||
457
src/roblox.rs
Normal file
457
src/roblox.rs
Normal file
@@ -0,0 +1,457 @@
|
||||
use std::io::{Cursor,Read,Seek};
|
||||
use std::collections::HashSet;
|
||||
use rbx_dom_weak::Instance;
|
||||
use strafesnet_deferred_loader::deferred_loader::LoadFailureMode;
|
||||
use rbxassetid::RobloxAssetId;
|
||||
|
||||
// === Public library API ===
|
||||
|
||||
/// Unique asset IDs referenced by a Roblox place/model file.
|
||||
#[derive(Default)]
|
||||
pub struct UniqueAssets{
|
||||
pub meshes:HashSet<RobloxAssetId>,
|
||||
pub unions:HashSet<RobloxAssetId>,
|
||||
pub textures:HashSet<RobloxAssetId>,
|
||||
}
|
||||
|
||||
#[derive(Debug,thiserror::Error)]
|
||||
pub enum LoadDomError{
|
||||
#[error("IO error {0:?}")]
|
||||
IO(#[from]std::io::Error),
|
||||
#[error("Binary decode error {0:?}")]
|
||||
Binary(rbx_binary::DecodeError),
|
||||
#[error("XML decode error {0:?}")]
|
||||
Xml(rbx_xml::DecodeError),
|
||||
#[error("Unknown file format")]
|
||||
UnknownFormat,
|
||||
}
|
||||
|
||||
/// Parse a Roblox file (binary or XML) from bytes into a WeakDom.
|
||||
pub fn load_dom(data:&[u8])->Result<rbx_dom_weak::WeakDom,LoadDomError>{
|
||||
load_dom_reader(Cursor::new(data))
|
||||
}
|
||||
|
||||
fn load_dom_reader<R:Read+Seek>(mut input:R)->Result<rbx_dom_weak::WeakDom,LoadDomError>{
|
||||
let mut first_8=[0u8;8];
|
||||
input.read_exact(&mut first_8)?;
|
||||
input.rewind()?;
|
||||
match &first_8{
|
||||
b"<roblox!"=>rbx_binary::from_reader(input).map_err(LoadDomError::Binary),
|
||||
b"<roblox "=>rbx_xml::from_reader(input,rbx_xml::DecodeOptions::default()).map_err(LoadDomError::Xml),
|
||||
_=>Err(LoadDomError::UnknownFormat),
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan a parsed DOM and return all unique asset IDs (meshes, textures, unions).
|
||||
pub fn get_unique_assets(dom:rbx_dom_weak::WeakDom)->UniqueAssets{
|
||||
let mut assets=UniqueAssets::default();
|
||||
for object in dom.into_raw().1.into_values(){
|
||||
assets.collect(&object);
|
||||
}
|
||||
assets
|
||||
}
|
||||
|
||||
/// Scan a Roblox file (bytes) and return all unique asset IDs.
|
||||
pub fn get_unique_assets_from_file(data:&[u8])->Result<UniqueAssets,LoadDomError>{
|
||||
let dom=load_dom(data)?;
|
||||
Ok(get_unique_assets(dom))
|
||||
}
|
||||
|
||||
#[derive(Debug,thiserror::Error)]
|
||||
pub enum ConvertTextureError{
|
||||
#[error("Image error {0:?}")]
|
||||
Image(#[from]image::ImageError),
|
||||
#[error("DDS create error {0:?}")]
|
||||
DDS(#[from]image_dds::CreateDdsError),
|
||||
#[error("DDS write error {0:?}")]
|
||||
DDSWrite(#[from]image_dds::ddsfile::Error),
|
||||
}
|
||||
|
||||
/// Convert image bytes (PNG, JPEG, etc.) into DDS texture bytes.
|
||||
pub fn convert_texture_to_dds(image_data:&[u8])->Result<Vec<u8>,ConvertTextureError>{
|
||||
let image=image::load_from_memory(image_data)?.to_rgba8();
|
||||
|
||||
let format=if image.width()%4!=0||image.height()%4!=0{
|
||||
image_dds::ImageFormat::Rgba8UnormSrgb
|
||||
}else{
|
||||
image_dds::ImageFormat::BC7RgbaUnormSrgb
|
||||
};
|
||||
|
||||
let dds=image_dds::dds_from_image(
|
||||
&image,
|
||||
format,
|
||||
image_dds::Quality::Slow,
|
||||
image_dds::Mipmaps::GeneratedAutomatic,
|
||||
)?;
|
||||
|
||||
let mut buf=Vec::new();
|
||||
dds.write(&mut Cursor::new(&mut buf))?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
#[derive(Debug,thiserror::Error)]
|
||||
pub enum ConvertError{
|
||||
#[error("IO error {0:?}")]
|
||||
IO(#[from]std::io::Error),
|
||||
#[error("SNF map error {0:?}")]
|
||||
SNFMap(strafesnet_snf::map::Error),
|
||||
#[error("Roblox read error {0:?}")]
|
||||
RobloxRead(strafesnet_rbx_loader::ReadError),
|
||||
#[error("Roblox load error {0:?}")]
|
||||
RobloxLoad(strafesnet_rbx_loader::LoadError),
|
||||
}
|
||||
|
||||
/// Convert a Roblox place/model file (bytes) to SNF map format (bytes).
|
||||
pub fn convert_to_snf(data:&[u8])->Result<Vec<u8>,ConvertError>{
|
||||
let model=strafesnet_rbx_loader::read(
|
||||
Cursor::new(data)
|
||||
).map_err(ConvertError::RobloxRead)?;
|
||||
|
||||
let mut place=model.into_place();
|
||||
place.run_scripts();
|
||||
|
||||
let map=place.to_snf(LoadFailureMode::DefaultToNone).map_err(ConvertError::RobloxLoad)?;
|
||||
|
||||
let mut buf=Vec::new();
|
||||
strafesnet_snf::map::write_map(Cursor::new(&mut buf),map).map_err(ConvertError::SNFMap)?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Download a single asset from Roblox by ID. Returns raw asset bytes.
|
||||
pub async fn download_asset(context:&rbx_asset::cookie::CookieContext,asset_id:u64)->Result<Vec<u8>,rbx_asset::cookie::GetError>{
|
||||
context.get_asset(rbx_asset::cookie::GetAssetRequest{
|
||||
asset_id,
|
||||
version:None,
|
||||
}).await
|
||||
}
|
||||
|
||||
/// Download a single asset with retry and exponential backoff for rate limiting.
|
||||
/// Returns None if all retries are exhausted or a non-rate-limit error occurs.
|
||||
pub async fn download_asset_retry(context:&rbx_asset::cookie::CookieContext,asset_id:u64)->Option<Vec<u8>>{
|
||||
const BACKOFF_MUL:f32=1.3956124250860895286;
|
||||
let mut backoff=1000f32;
|
||||
for _ in 0..12{
|
||||
match download_asset(context,asset_id).await{
|
||||
Ok(data)=>return Some(data),
|
||||
Err(rbx_asset::cookie::GetError::Response(rbx_asset::ResponseError::StatusCodeWithUrlAndBody(scwuab)))=>{
|
||||
if scwuab.status_code.as_u16()==429{
|
||||
tokio::time::sleep(std::time::Duration::from_millis(backoff as u64)).await;
|
||||
backoff*=BACKOFF_MUL;
|
||||
}else{
|
||||
return None;
|
||||
}
|
||||
},
|
||||
Err(_)=>return None,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// === Private helpers ===
|
||||
|
||||
impl UniqueAssets{
|
||||
fn collect(&mut self,object:&Instance){
|
||||
match object.class.as_str(){
|
||||
"Beam"=>accumulate_content_id(&mut self.textures,object,"Texture"),
|
||||
"Decal"=>accumulate_content_id(&mut self.textures,object,"Texture"),
|
||||
"Texture"=>accumulate_content_id(&mut self.textures,object,"Texture"),
|
||||
"FileMesh"=>accumulate_content_id(&mut self.textures,object,"TextureId"),
|
||||
"MeshPart"=>{
|
||||
accumulate_content_id(&mut self.textures,object,"TextureID");
|
||||
accumulate_content_id(&mut self.meshes,object,"MeshId");
|
||||
},
|
||||
"SpecialMesh"=>accumulate_content_id(&mut self.meshes,object,"MeshId"),
|
||||
"ParticleEmitter"=>accumulate_content_id(&mut self.textures,object,"Texture"),
|
||||
"Sky"=>{
|
||||
accumulate_content_id(&mut self.textures,object,"MoonTextureId");
|
||||
accumulate_content_id(&mut self.textures,object,"SkyboxBk");
|
||||
accumulate_content_id(&mut self.textures,object,"SkyboxDn");
|
||||
accumulate_content_id(&mut self.textures,object,"SkyboxFt");
|
||||
accumulate_content_id(&mut self.textures,object,"SkyboxLf");
|
||||
accumulate_content_id(&mut self.textures,object,"SkyboxRt");
|
||||
accumulate_content_id(&mut self.textures,object,"SkyboxUp");
|
||||
accumulate_content_id(&mut self.textures,object,"SunTextureId");
|
||||
},
|
||||
"UnionOperation"=>accumulate_content_id(&mut self.unions,object,"AssetId"),
|
||||
_=>(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn accumulate_content_id(content_list:&mut HashSet<RobloxAssetId>,object:&Instance,property:&str){
|
||||
if let Some(rbx_dom_weak::types::Variant::Content(content))=object.properties.get(property){
|
||||
let url:&str=content.as_ref();
|
||||
if let Ok(asset_id)=url.parse(){
|
||||
content_list.insert(asset_id);
|
||||
}else{
|
||||
println!("Content failed to parse into AssetID: {:?}",content);
|
||||
}
|
||||
}else{
|
||||
println!("property={} does not exist for class={}",property,object.class.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
// === CLI ===
|
||||
|
||||
#[cfg(feature="cli")]
|
||||
mod cli{
|
||||
use super::*;
|
||||
use std::path::{Path,PathBuf};
|
||||
use clap::{Args,Subcommand};
|
||||
use anyhow::Result as AResult;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
const DOWNLOAD_LIMIT:usize=16;
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum Commands{
|
||||
RobloxToSNF(RobloxToSNFSubcommand),
|
||||
DownloadAssets(DownloadAssetsSubcommand),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct RobloxToSNFSubcommand {
|
||||
#[arg(long)]
|
||||
output_folder:PathBuf,
|
||||
#[arg(required=true)]
|
||||
input_files:Vec<PathBuf>,
|
||||
}
|
||||
#[derive(Args)]
|
||||
pub struct DownloadAssetsSubcommand{
|
||||
#[arg(required=true)]
|
||||
roblox_files:Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl Commands{
|
||||
pub async fn run(self)->AResult<()>{
|
||||
match self{
|
||||
Commands::RobloxToSNF(subcommand)=>cli_roblox_to_snf(subcommand.input_files,subcommand.output_folder).await,
|
||||
Commands::DownloadAssets(subcommand)=>cli_download_assets(
|
||||
subcommand.roblox_files,
|
||||
rbx_asset::cookie::Cookie::new("".to_string()),
|
||||
).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_entire_file(path:impl AsRef<Path>)->Result<Vec<u8>,std::io::Error>{
|
||||
let mut file=tokio::fs::File::open(path).await?;
|
||||
let mut data=Vec::new();
|
||||
file.read_to_end(&mut data).await?;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
enum DownloadType{
|
||||
Texture(RobloxAssetId),
|
||||
Mesh(RobloxAssetId),
|
||||
Union(RobloxAssetId),
|
||||
}
|
||||
impl DownloadType{
|
||||
fn path(&self)->PathBuf{
|
||||
match self{
|
||||
DownloadType::Texture(asset_id)=>format!("downloaded_textures/{}",asset_id.0.to_string()).into(),
|
||||
DownloadType::Mesh(asset_id)=>format!("meshes/{}",asset_id.0.to_string()).into(),
|
||||
DownloadType::Union(asset_id)=>format!("unions/{}",asset_id.0.to_string()).into(),
|
||||
}
|
||||
}
|
||||
fn asset_id(&self)->u64{
|
||||
match self{
|
||||
DownloadType::Texture(asset_id)=>asset_id.0,
|
||||
DownloadType::Mesh(asset_id)=>asset_id.0,
|
||||
DownloadType::Union(asset_id)=>asset_id.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
enum DownloadResult{
|
||||
Cached(PathBuf),
|
||||
Data(Vec<u8>),
|
||||
Failed,
|
||||
}
|
||||
#[derive(Default,Debug)]
|
||||
struct Stats{
|
||||
total_assets:u32,
|
||||
cached_assets:u32,
|
||||
downloaded_assets:u32,
|
||||
failed_downloads:u32,
|
||||
timed_out_downloads:u32,
|
||||
}
|
||||
async fn download_retry(stats:&mut Stats,context:&rbx_asset::cookie::CookieContext,download_instruction:DownloadType)->Result<DownloadResult,std::io::Error>{
|
||||
stats.total_assets+=1;
|
||||
let path=download_instruction.path();
|
||||
if tokio::fs::try_exists(path.as_path()).await?{
|
||||
stats.cached_assets+=1;
|
||||
return Ok(DownloadResult::Cached(path));
|
||||
}
|
||||
let asset_id=download_instruction.asset_id();
|
||||
let mut retry=0;
|
||||
const BACKOFF_MUL:f32=1.3956124250860895286;
|
||||
let mut backoff=1000f32;
|
||||
loop{
|
||||
let asset_result=context.get_asset(rbx_asset::cookie::GetAssetRequest{
|
||||
asset_id,
|
||||
version:None,
|
||||
}).await;
|
||||
match asset_result{
|
||||
Ok(asset_result)=>{
|
||||
stats.downloaded_assets+=1;
|
||||
tokio::fs::write(path,&asset_result).await?;
|
||||
break Ok(DownloadResult::Data(asset_result));
|
||||
},
|
||||
Err(rbx_asset::cookie::GetError::Response(rbx_asset::ResponseError::StatusCodeWithUrlAndBody(scwuab)))=>{
|
||||
if scwuab.status_code.as_u16()==429{
|
||||
if retry==12{
|
||||
println!("Giving up asset download {asset_id}");
|
||||
stats.timed_out_downloads+=1;
|
||||
break Ok(DownloadResult::Failed);
|
||||
}
|
||||
println!("Hit roblox rate limit, waiting {:.0}ms...",backoff);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(backoff as u64)).await;
|
||||
backoff*=BACKOFF_MUL;
|
||||
retry+=1;
|
||||
}else{
|
||||
stats.failed_downloads+=1;
|
||||
println!("weird scuwab error: {scwuab:?}");
|
||||
break Ok(DownloadResult::Failed);
|
||||
}
|
||||
},
|
||||
Err(e)=>{
|
||||
stats.failed_downloads+=1;
|
||||
println!("sadly error: {e}");
|
||||
break Ok(DownloadResult::Failed);
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn cli_convert_texture(asset_id:RobloxAssetId,download_result:DownloadResult)->Result<(),CliConvertTextureError>{
|
||||
let data=match download_result{
|
||||
DownloadResult::Cached(path)=>tokio::fs::read(path).await?,
|
||||
DownloadResult::Data(data)=>data,
|
||||
DownloadResult::Failed=>return Ok(()),
|
||||
};
|
||||
let dds_data=convert_texture_to_dds(&data)?;
|
||||
let file_name=format!("textures/{}.dds",asset_id.0);
|
||||
tokio::fs::write(file_name,dds_data).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug,thiserror::Error)]
|
||||
enum CliConvertTextureError{
|
||||
#[error("IO error {0:?}")]
|
||||
Io(#[from]std::io::Error),
|
||||
#[error("Convert texture error {0:?}")]
|
||||
Convert(#[from]ConvertTextureError),
|
||||
}
|
||||
|
||||
async fn cli_download_assets(paths:Vec<PathBuf>,cookie:rbx_asset::cookie::Cookie)->AResult<()>{
|
||||
tokio::try_join!(
|
||||
tokio::fs::create_dir_all("downloaded_textures"),
|
||||
tokio::fs::create_dir_all("textures"),
|
||||
tokio::fs::create_dir_all("meshes"),
|
||||
tokio::fs::create_dir_all("unions"),
|
||||
)?;
|
||||
let thread_limit=std::thread::available_parallelism()?.get();
|
||||
let (send_assets,mut recv_assets)=tokio::sync::mpsc::channel(DOWNLOAD_LIMIT);
|
||||
let (send_texture,mut recv_texture)=tokio::sync::mpsc::channel(thread_limit);
|
||||
tokio::spawn(async move{
|
||||
let mut it=paths.into_iter();
|
||||
static SEM:tokio::sync::Semaphore=tokio::sync::Semaphore::const_new(0);
|
||||
SEM.add_permits(thread_limit);
|
||||
while let (Ok(permit),Some(path))=(SEM.acquire().await,it.next()){
|
||||
let send=send_assets.clone();
|
||||
tokio::spawn(async move{
|
||||
let data=read_entire_file(path.as_path()).await;
|
||||
let result=data.map_err(LoadDomError::from).and_then(|d|{
|
||||
let dom=load_dom(&d)?;
|
||||
Ok(get_unique_assets(dom))
|
||||
});
|
||||
_=send.send(result).await;
|
||||
drop(permit);
|
||||
});
|
||||
}
|
||||
});
|
||||
let mut stats=Stats::default();
|
||||
let context=rbx_asset::cookie::CookieContext::new(cookie);
|
||||
let mut globally_unique_assets=UniqueAssets::default();
|
||||
let download_thread=tokio::spawn(async move{
|
||||
while let Some(result)=recv_assets.recv().await{
|
||||
let unique_assets=match result{
|
||||
Ok(unique_assets)=>unique_assets,
|
||||
Err(e)=>{
|
||||
println!("error: {e:?}");
|
||||
continue;
|
||||
},
|
||||
};
|
||||
for texture_id in unique_assets.textures{
|
||||
if globally_unique_assets.textures.insert(texture_id){
|
||||
let data=download_retry(&mut stats,&context,DownloadType::Texture(texture_id)).await?;
|
||||
send_texture.send((texture_id,data)).await?;
|
||||
}
|
||||
}
|
||||
for mesh_id in unique_assets.meshes{
|
||||
if globally_unique_assets.meshes.insert(mesh_id){
|
||||
download_retry(&mut stats,&context,DownloadType::Mesh(mesh_id)).await?;
|
||||
}
|
||||
}
|
||||
for union_id in unique_assets.unions{
|
||||
if globally_unique_assets.unions.insert(union_id){
|
||||
download_retry(&mut stats,&context,DownloadType::Union(union_id)).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
dbg!(stats);
|
||||
Ok::<(),anyhow::Error>(())
|
||||
});
|
||||
static SEM:tokio::sync::Semaphore=tokio::sync::Semaphore::const_new(0);
|
||||
SEM.add_permits(thread_limit);
|
||||
while let (Ok(permit),Some((asset_id,download_result)))=(SEM.acquire().await,recv_texture.recv().await){
|
||||
tokio::spawn(async move{
|
||||
let result=cli_convert_texture(asset_id,download_result).await;
|
||||
drop(permit);
|
||||
result.unwrap();
|
||||
});
|
||||
}
|
||||
download_thread.await??;
|
||||
_=SEM.acquire_many(thread_limit as u32).await.unwrap();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cli_roblox_to_snf(paths:Vec<std::path::PathBuf>,output_folder:PathBuf)->AResult<()>{
|
||||
let start=std::time::Instant::now();
|
||||
|
||||
let thread_limit=std::thread::available_parallelism()?.get();
|
||||
let mut it=paths.into_iter();
|
||||
static SEM:tokio::sync::Semaphore=tokio::sync::Semaphore::const_new(0);
|
||||
SEM.add_permits(thread_limit);
|
||||
|
||||
while let (Ok(permit),Some(path))=(SEM.acquire().await,it.next()){
|
||||
let output_folder=output_folder.clone();
|
||||
tokio::spawn(async move{
|
||||
let result=cli_convert_to_snf(path.as_path(),output_folder).await;
|
||||
drop(permit);
|
||||
match result{
|
||||
Ok(())=>(),
|
||||
Err(e)=>println!("Convert error: {e:?}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
_=SEM.acquire_many(thread_limit as u32).await.unwrap();
|
||||
|
||||
println!("elapsed={:?}", start.elapsed());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cli_convert_to_snf(path:&Path,output_folder:PathBuf)->AResult<()>{
|
||||
let entire_file=tokio::fs::read(path).await?;
|
||||
let snf_data=convert_to_snf(&entire_file)?;
|
||||
|
||||
let mut dest=output_folder;
|
||||
dest.push(path.file_stem().unwrap());
|
||||
dest.set_extension("snfm");
|
||||
tokio::fs::write(dest,snf_data).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
#[cfg(feature="cli")]
|
||||
pub use cli::Commands;
|
||||
442
src/source.rs
Normal file
442
src/source.rs
Normal file
@@ -0,0 +1,442 @@
|
||||
use std::io::Cursor;
|
||||
use strafesnet_deferred_loader::deferred_loader::LoadFailureMode;
|
||||
|
||||
// === Public library API ===
|
||||
|
||||
#[derive(Debug,thiserror::Error)]
|
||||
pub enum ConvertTextureError{
|
||||
#[error("Vtf error {0:?}")]
|
||||
Vtf(#[from]vtf::Error),
|
||||
#[error("DDS create error {0:?}")]
|
||||
DDS(#[from]image_dds::CreateDdsError),
|
||||
#[error("DDS write error {0:?}")]
|
||||
DDSWrite(#[from]image_dds::ddsfile::Error),
|
||||
}
|
||||
|
||||
/// Convert VTF texture bytes to DDS texture bytes.
|
||||
pub fn convert_texture_to_dds(vtf_data:&[u8])->Result<Vec<u8>,ConvertTextureError>{
|
||||
let vtf_data=vtf_data.to_vec();
|
||||
let image=vtf::from_bytes(&vtf_data)?.highres_image.decode(0)?.to_rgba8();
|
||||
|
||||
let format=if image.width()%4!=0||image.height()%4!=0{
|
||||
image_dds::ImageFormat::Rgba8UnormSrgb
|
||||
}else{
|
||||
image_dds::ImageFormat::BC7RgbaUnormSrgb
|
||||
};
|
||||
|
||||
let dds=image_dds::dds_from_image(
|
||||
&image,
|
||||
format,
|
||||
image_dds::Quality::Slow,
|
||||
image_dds::Mipmaps::GeneratedAutomatic,
|
||||
)?;
|
||||
|
||||
let mut buf=Vec::new();
|
||||
dds.write(&mut Cursor::new(&mut buf))?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
#[derive(Debug,thiserror::Error)]
|
||||
pub enum ConvertError{
|
||||
#[error("BSP read error {0:?}")]
|
||||
BspRead(strafesnet_bsp_loader::ReadError),
|
||||
#[error("BSP load error {0:?}")]
|
||||
BspLoad(strafesnet_bsp_loader::LoadError),
|
||||
#[error("SNF map error {0:?}")]
|
||||
SNFMap(strafesnet_snf::map::Error),
|
||||
#[error("BSP parse error {0:?}")]
|
||||
BspParse(#[from]vbsp::BspError),
|
||||
}
|
||||
|
||||
/// Convert a Source BSP file (bytes) to SNF map format (bytes).
|
||||
pub fn convert_to_snf(bsp_data:&[u8],vpk_list:&[vpk::VPK])->Result<Vec<u8>,ConvertError>{
|
||||
let bsp=strafesnet_bsp_loader::read(
|
||||
Cursor::new(bsp_data)
|
||||
).map_err(ConvertError::BspRead)?;
|
||||
|
||||
let map=bsp.to_snf(LoadFailureMode::DefaultToNone,vpk_list).map_err(ConvertError::BspLoad)?;
|
||||
|
||||
let mut buf=Vec::new();
|
||||
strafesnet_snf::map::write_map(Cursor::new(&mut buf),map).map_err(ConvertError::SNFMap)?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Read VPK archives from paths. Useful for loading VPKs needed by `convert_to_snf`.
|
||||
pub async fn read_vpks(vpk_paths:Vec<std::path::PathBuf>,thread_limit:usize)->Vec<vpk::VPK>{
|
||||
use futures::StreamExt;
|
||||
futures::stream::iter(vpk_paths).map(|vpk_path|async{
|
||||
tokio::task::spawn_blocking(move||vpk::VPK::read(&vpk_path)).await.unwrap().unwrap()
|
||||
})
|
||||
.buffer_unordered(thread_limit)
|
||||
.collect().await
|
||||
}
|
||||
|
||||
// === CLI ===
|
||||
|
||||
#[cfg(feature="cli")]
|
||||
mod cli{
|
||||
use super::*;
|
||||
use std::path::{Path,PathBuf};
|
||||
use std::borrow::Cow;
|
||||
use clap::{Args,Subcommand};
|
||||
use anyhow::Result as AResult;
|
||||
use strafesnet_bsp_loader::loader::BspFinder;
|
||||
use strafesnet_deferred_loader::loader::Loader;
|
||||
use strafesnet_deferred_loader::deferred_loader::{MeshDeferredLoader,RenderConfigDeferredLoader};
|
||||
|
||||
enum VMTContent{
|
||||
VMT(String),
|
||||
VTF(String),
|
||||
Patch(vmt_parser::material::PatchMaterial),
|
||||
Unsupported,
|
||||
Unresolved,
|
||||
}
|
||||
impl VMTContent{
|
||||
fn vtf(opt:Option<String>)->Self{
|
||||
match opt{
|
||||
Some(s)=>Self::VTF(s),
|
||||
None=>Self::Unresolved,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_some_texture(material:vmt_parser::material::Material)->VMTContent{
|
||||
match material{
|
||||
vmt_parser::material::Material::LightMappedGeneric(mat)=>VMTContent::vtf(Some(mat.base_texture)),
|
||||
vmt_parser::material::Material::VertexLitGeneric(mat)=>VMTContent::vtf(mat.base_texture.or(mat.decal_texture)),
|
||||
vmt_parser::material::Material::VertexLitGenericDx6(mat)=>VMTContent::vtf(mat.base_texture.or(mat.decal_texture)),
|
||||
vmt_parser::material::Material::UnlitGeneric(mat)=>VMTContent::vtf(mat.base_texture),
|
||||
vmt_parser::material::Material::UnlitTwoTexture(mat)=>VMTContent::vtf(mat.base_texture),
|
||||
vmt_parser::material::Material::Water(mat)=>VMTContent::vtf(mat.base_texture),
|
||||
vmt_parser::material::Material::WorldVertexTransition(mat)=>VMTContent::vtf(Some(mat.base_texture)),
|
||||
vmt_parser::material::Material::EyeRefract(mat)=>VMTContent::vtf(Some(mat.cornea_texture)),
|
||||
vmt_parser::material::Material::SubRect(mat)=>VMTContent::VMT(mat.material),
|
||||
vmt_parser::material::Material::Sprite(mat)=>VMTContent::vtf(Some(mat.base_texture)),
|
||||
vmt_parser::material::Material::SpriteCard(mat)=>VMTContent::vtf(mat.base_texture),
|
||||
vmt_parser::material::Material::Cable(mat)=>VMTContent::vtf(Some(mat.base_texture)),
|
||||
vmt_parser::material::Material::Refract(mat)=>VMTContent::vtf(mat.base_texture),
|
||||
vmt_parser::material::Material::Modulate(mat)=>VMTContent::vtf(Some(mat.base_texture)),
|
||||
vmt_parser::material::Material::DecalModulate(mat)=>VMTContent::vtf(Some(mat.base_texture)),
|
||||
vmt_parser::material::Material::Sky(mat)=>VMTContent::vtf(Some(mat.base_texture)),
|
||||
vmt_parser::material::Material::Replacements(_mat)=>VMTContent::Unsupported,
|
||||
vmt_parser::material::Material::Patch(mat)=>VMTContent::Patch(mat),
|
||||
_=>unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug,thiserror::Error)]
|
||||
enum GetVMTError{
|
||||
#[error("Bsp error {0:?}")]
|
||||
Bsp(#[from]vbsp::BspError),
|
||||
#[error("Utf8 error {0:?}")]
|
||||
Utf8(#[from]std::str::Utf8Error),
|
||||
#[error("Vdf error {0:?}")]
|
||||
Vdf(#[from]vmt_parser::VdfError),
|
||||
#[error("Vmt not found")]
|
||||
NotFound,
|
||||
}
|
||||
|
||||
fn get_vmt(finder:BspFinder,search_name:&str)->Result<vmt_parser::material::Material,GetVMTError>{
|
||||
let vmt_data=finder.find(search_name)?.ok_or(GetVMTError::NotFound)?;
|
||||
let vmt_str=core::str::from_utf8(&vmt_data)?;
|
||||
let material=vmt_parser::from_str(vmt_str)?;
|
||||
Ok(material)
|
||||
}
|
||||
|
||||
#[derive(Debug,thiserror::Error)]
|
||||
enum LoadVMTError{
|
||||
#[error("Bsp error {0:?}")]
|
||||
Bsp(#[from]vbsp::BspError),
|
||||
#[error("GetVMT error {0:?}")]
|
||||
GetVMT(#[from]GetVMTError),
|
||||
#[error("FromUtf8 error {0:?}")]
|
||||
FromUtf8(#[from]std::string::FromUtf8Error),
|
||||
#[error("Vdf error {0:?}")]
|
||||
Vdf(#[from]vmt_parser::VdfError),
|
||||
#[error("Vmt unsupported")]
|
||||
Unsupported,
|
||||
#[error("Vmt unresolved")]
|
||||
Unresolved,
|
||||
#[error("Vmt not found")]
|
||||
NotFound,
|
||||
}
|
||||
fn recursive_vmt_loader<'bsp,'vpk,'a>(finder:BspFinder<'bsp,'vpk>,material:vmt_parser::material::Material)->Result<Option<Cow<'a,[u8]>>,LoadVMTError>
|
||||
where
|
||||
'bsp:'a,
|
||||
'vpk:'a,
|
||||
{
|
||||
match get_some_texture(material){
|
||||
VMTContent::VMT(s)=>recursive_vmt_loader(finder,get_vmt(finder,s.as_str())?),
|
||||
VMTContent::VTF(s)=>{
|
||||
let mut texture_file_name=PathBuf::from("materials");
|
||||
texture_file_name.push(s);
|
||||
texture_file_name.set_extension("vtf");
|
||||
Ok(finder.find(texture_file_name.to_str().unwrap())?)
|
||||
},
|
||||
VMTContent::Patch(mat)=>recursive_vmt_loader(finder,
|
||||
mat.resolve(|search_name|
|
||||
match finder.find(search_name)?{
|
||||
Some(bytes)=>Ok(String::from_utf8(bytes.into_owned())?),
|
||||
None=>Err(LoadVMTError::NotFound),
|
||||
}
|
||||
)?
|
||||
),
|
||||
VMTContent::Unsupported=>Err(LoadVMTError::Unsupported),
|
||||
VMTContent::Unresolved=>Err(LoadVMTError::Unresolved),
|
||||
}
|
||||
}
|
||||
fn load_texture<'bsp,'vpk,'a>(finder:BspFinder<'bsp,'vpk>,texture_name:&str)->Result<Option<Cow<'a,[u8]>>,LoadVMTError>
|
||||
where
|
||||
'bsp:'a,
|
||||
'vpk:'a,
|
||||
{
|
||||
let mut texture_file_name=PathBuf::from("materials");
|
||||
let texture_file_name_lowercase=texture_name.to_lowercase();
|
||||
texture_file_name.push(texture_file_name_lowercase.clone());
|
||||
let stem=PathBuf::from(texture_file_name.file_stem().unwrap());
|
||||
texture_file_name.pop();
|
||||
texture_file_name.push(stem);
|
||||
if let Some(stuff)=finder.find(texture_file_name.to_str().unwrap())?{
|
||||
return Ok(Some(stuff))
|
||||
}
|
||||
let mut texture_file_name_vmt=texture_file_name.clone();
|
||||
texture_file_name.set_extension("vtf");
|
||||
texture_file_name_vmt.set_extension("vmt");
|
||||
recursive_vmt_loader(finder,get_vmt(finder,texture_file_name_vmt.to_str().unwrap())?)
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum Commands{
|
||||
SourceToSNF(SourceToSNFSubcommand),
|
||||
ExtractTextures(ExtractTexturesSubcommand),
|
||||
VPKContents(VPKContentsSubcommand),
|
||||
BSPContents(BSPContentsSubcommand),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
pub struct SourceToSNFSubcommand {
|
||||
#[arg(long)]
|
||||
output_folder:PathBuf,
|
||||
#[arg(required=true)]
|
||||
input_files:Vec<PathBuf>,
|
||||
#[arg(long)]
|
||||
vpks:Vec<PathBuf>,
|
||||
}
|
||||
#[derive(Args)]
|
||||
pub struct ExtractTexturesSubcommand{
|
||||
#[arg(required=true)]
|
||||
bsp_files:Vec<PathBuf>,
|
||||
#[arg(long)]
|
||||
vpks:Vec<PathBuf>,
|
||||
}
|
||||
#[derive(Args)]
|
||||
pub struct VPKContentsSubcommand {
|
||||
#[arg(long)]
|
||||
input_file:PathBuf,
|
||||
}
|
||||
#[derive(Args)]
|
||||
pub struct BSPContentsSubcommand {
|
||||
#[arg(long)]
|
||||
input_file:PathBuf,
|
||||
}
|
||||
|
||||
impl Commands{
|
||||
pub async fn run(self)->AResult<()>{
|
||||
match self{
|
||||
Commands::SourceToSNF(subcommand)=>cli_source_to_snf(subcommand.input_files,subcommand.output_folder,subcommand.vpks).await,
|
||||
Commands::ExtractTextures(subcommand)=>cli_extract_textures(subcommand.bsp_files,subcommand.vpks).await,
|
||||
Commands::VPKContents(subcommand)=>vpk_contents(subcommand.input_file),
|
||||
Commands::BSPContents(subcommand)=>bsp_contents(subcommand.input_file),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug,thiserror::Error)]
|
||||
enum ExtractTextureError{
|
||||
#[error("Io error {0:?}")]
|
||||
Io(#[from]std::io::Error),
|
||||
#[error("Bsp error {0:?}")]
|
||||
Bsp(#[from]vbsp::BspError),
|
||||
#[error("MeshLoad error {0:?}")]
|
||||
MeshLoad(#[from]strafesnet_bsp_loader::loader::MeshError),
|
||||
#[error("Load VMT error {0:?}")]
|
||||
LoadVMT(#[from]LoadVMTError),
|
||||
}
|
||||
async fn gimme_them_textures(path:&Path,vpk_list:&[vpk::VPK],send_texture:tokio::sync::mpsc::Sender<(Vec<u8>,String)>)->Result<(),ExtractTextureError>{
|
||||
let bsp=vbsp::Bsp::read(tokio::fs::read(path).await?.as_ref())?;
|
||||
let loader_bsp=strafesnet_bsp_loader::Bsp::new(bsp);
|
||||
let bsp=loader_bsp.as_ref();
|
||||
|
||||
let mut texture_deferred_loader=RenderConfigDeferredLoader::new();
|
||||
for texture in bsp.textures(){
|
||||
texture_deferred_loader.acquire_render_config_id(Some(Cow::Borrowed(texture.name())));
|
||||
}
|
||||
|
||||
let mut mesh_deferred_loader=MeshDeferredLoader::new();
|
||||
for prop in bsp.static_props(){
|
||||
mesh_deferred_loader.acquire_mesh_id(prop.model());
|
||||
}
|
||||
|
||||
let finder=BspFinder{
|
||||
bsp:&loader_bsp,
|
||||
vpks:vpk_list
|
||||
};
|
||||
|
||||
let mut mesh_loader=strafesnet_bsp_loader::loader::ModelLoader::new(finder);
|
||||
for model_path in mesh_deferred_loader.into_indices(){
|
||||
let model:vmdl::Model=match mesh_loader.load(model_path){
|
||||
Ok(model)=>model,
|
||||
Err(e)=>{
|
||||
println!("Model={model_path} Load model error: {e}");
|
||||
continue;
|
||||
},
|
||||
};
|
||||
for texture in model.textures(){
|
||||
for search_path in &texture.search_paths{
|
||||
let mut path=PathBuf::from(search_path.as_str());
|
||||
path.push(texture.name.as_str());
|
||||
let path=path.to_str().unwrap().to_owned();
|
||||
texture_deferred_loader.acquire_render_config_id(Some(Cow::Owned(path)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for texture_path in texture_deferred_loader.into_indices(){
|
||||
match load_texture(finder,&texture_path){
|
||||
Ok(Some(texture))=>send_texture.send(
|
||||
(texture.into_owned(),texture_path.into_owned())
|
||||
).await.unwrap(),
|
||||
Ok(None)=>(),
|
||||
Err(e)=>println!("Texture={texture_path} Load error: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug,thiserror::Error)]
|
||||
enum CliConvertTextureError{
|
||||
#[error("IO error {0:?}")]
|
||||
Io(#[from]std::io::Error),
|
||||
#[error("Convert texture error {0:?}")]
|
||||
Convert(#[from]ConvertTextureError),
|
||||
}
|
||||
|
||||
async fn cli_convert_texture(texture:Vec<u8>,write_file_name:impl AsRef<Path>)->Result<(),CliConvertTextureError>{
|
||||
let dds_data=convert_texture_to_dds(&texture)?;
|
||||
|
||||
let mut dest=PathBuf::from("textures");
|
||||
dest.push(write_file_name);
|
||||
dest.set_extension("dds");
|
||||
std::fs::create_dir_all(dest.parent().unwrap())?;
|
||||
let mut writer=std::io::BufWriter::new(std::fs::File::create(dest)?);
|
||||
std::io::Write::write_all(&mut writer,&dds_data)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cli_extract_textures(paths:Vec<PathBuf>,vpk_paths:Vec<PathBuf>)->AResult<()>{
|
||||
tokio::try_join!(
|
||||
tokio::fs::create_dir_all("extracted_textures"),
|
||||
tokio::fs::create_dir_all("textures"),
|
||||
tokio::fs::create_dir_all("meshes"),
|
||||
)?;
|
||||
let thread_limit=std::thread::available_parallelism()?.get();
|
||||
|
||||
let vpk_list=read_vpks(vpk_paths,thread_limit).await;
|
||||
let vpk_list:&[vpk::VPK]=vpk_list.leak();
|
||||
|
||||
let (send_texture,mut recv_texture)=tokio::sync::mpsc::channel(thread_limit);
|
||||
let mut it=paths.into_iter();
|
||||
let extract_thread=tokio::spawn(async move{
|
||||
static SEM:tokio::sync::Semaphore=tokio::sync::Semaphore::const_new(0);
|
||||
SEM.add_permits(thread_limit);
|
||||
while let (Ok(permit),Some(path))=(SEM.acquire().await,it.next()){
|
||||
let send=send_texture.clone();
|
||||
tokio::spawn(async move{
|
||||
let result=gimme_them_textures(&path,vpk_list,send).await;
|
||||
drop(permit);
|
||||
match result{
|
||||
Ok(())=>(),
|
||||
Err(e)=>println!("Map={path:?} Decode error: {e:?}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
static SEM:tokio::sync::Semaphore=tokio::sync::Semaphore::const_new(0);
|
||||
SEM.add_permits(thread_limit);
|
||||
while let (Ok(permit),Some((data,dest)))=(SEM.acquire().await,recv_texture.recv().await){
|
||||
tokio::spawn(async move{
|
||||
let result=cli_convert_texture(data,dest).await;
|
||||
drop(permit);
|
||||
match result{
|
||||
Ok(())=>(),
|
||||
Err(e)=>println!("Convert error: {e:?}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
extract_thread.await?;
|
||||
_=SEM.acquire_many(thread_limit as u32).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn vpk_contents(vpk_path:PathBuf)->AResult<()>{
|
||||
let vpk_index=vpk::VPK::read(&vpk_path)?;
|
||||
for (label,entry) in vpk_index.tree.into_iter(){
|
||||
println!("vpk label={} entry={:?}",label,entry);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bsp_contents(path:PathBuf)->AResult<()>{
|
||||
let bsp=vbsp::Bsp::read(std::fs::read(path)?.as_ref())?;
|
||||
for file_name in bsp.pack.into_zip().into_inner().unwrap().file_names(){
|
||||
println!("file_name={:?}",file_name);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cli_convert_to_snf(path:&Path,vpk_list:&[vpk::VPK],output_folder:PathBuf)->AResult<()>{
|
||||
let entire_file=tokio::fs::read(path).await?;
|
||||
let snf_data=convert_to_snf(&entire_file,vpk_list)?;
|
||||
|
||||
let mut dest=output_folder;
|
||||
dest.push(path.file_stem().unwrap());
|
||||
dest.set_extension("snfm");
|
||||
tokio::fs::write(dest,snf_data).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cli_source_to_snf(paths:Vec<std::path::PathBuf>,output_folder:PathBuf,vpk_paths:Vec<PathBuf>)->AResult<()>{
|
||||
let start=std::time::Instant::now();
|
||||
|
||||
let thread_limit=std::thread::available_parallelism()?.get();
|
||||
|
||||
let vpk_list=read_vpks(vpk_paths,thread_limit).await;
|
||||
let vpk_list:&[vpk::VPK]=vpk_list.leak();
|
||||
|
||||
let mut it=paths.into_iter();
|
||||
static SEM:tokio::sync::Semaphore=tokio::sync::Semaphore::const_new(0);
|
||||
SEM.add_permits(thread_limit);
|
||||
|
||||
while let (Ok(permit),Some(path))=(SEM.acquire().await,it.next()){
|
||||
let output_folder=output_folder.clone();
|
||||
tokio::spawn(async move{
|
||||
let result=cli_convert_to_snf(path.as_path(),vpk_list,output_folder).await;
|
||||
drop(permit);
|
||||
match result{
|
||||
Ok(())=>(),
|
||||
Err(e)=>println!("Convert error: {e:?}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
_=SEM.acquire_many(thread_limit as u32).await.unwrap();
|
||||
|
||||
println!("elapsed={:?}", start.elapsed());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
#[cfg(feature="cli")]
|
||||
pub use cli::Commands;
|
||||
Reference in New Issue
Block a user