diff --git a/Cargo.lock b/Cargo.lock index 9fd403b..f355f8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -115,6 +115,17 @@ version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2" +[[package]] +name = "bsp" +version = "0.1.0" +dependencies = [ + "bincode", + "lzma-rs", + "serde", + "tsify", + "wasm-bindgen", +] + [[package]] name = "built" version = "0.7.5" @@ -178,6 +189,21 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "crc" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69e6e4d7b33a94f0991c26729976b10ebde1d34c3ee82408fb536164fa10d636" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + [[package]] name = "crc32fast" version = "1.4.2" @@ -285,6 +311,19 @@ dependencies = [ "weezl", ] +[[package]] +name = "gloo-utils" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037fcb07216cb3a30f7292bd0176b050b7b9a052ba830ef7d5d65f6dc64ba58e" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "half" version = "2.4.1" @@ -376,6 +415,12 @@ dependencies = [ "either", ] +[[package]] +name = "itoa" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" + [[package]] name = "jobserver" version = "0.1.32" @@ -444,6 +489,16 @@ dependencies = [ "imgref", ] +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -752,6 +807,12 @@ version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4" +[[package]] +name = "ryu" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea1a2d0a644769cc99faa24c3ad26b379b786fe7c36fd3c546254801650e6dd" + [[package]] name = "serde" version = "1.0.217" @@ -772,6 +833,29 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_derive_internals" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e578a843d40b4189a4d66bba51d7684f57da5bd7c304c64e14bd63efbef49509" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.138" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d434192e7da787e94a6ea7e9670b26a036d0ca41e0b7efb2676dd32bae872949" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + [[package]] name = "serde_spanned" version = "0.6.8" @@ -912,6 +996,31 @@ dependencies = [ "winnow", ] +[[package]] +name = "tsify" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6b26cf145f2f3b9ff84e182c448eaf05468e247f148cf3d2a7d67d78ff023a0" +dependencies = [ + "gloo-utils", + "serde", + "serde_json", + "tsify-macros", + "wasm-bindgen", +] + +[[package]] +name = "tsify-macros" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a94b0f0954b3e59bfc2c246b4c8574390d94a4ad4ad246aaf2fb07d7dfd3b47" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + [[package]] name = "unicode-ident" version = "1.0.16" diff --git a/Cargo.toml b/Cargo.toml index 9beecb3..f990771 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["packages/vtf", "packages/vtf-canvas", "packages/vtf-png"] +members = ["packages/bsp", "packages/vtf", "packages/vtf-canvas", "packages/vtf-png"] resolver = "2" [profile.release] diff --git a/packages/bsp/Cargo.toml b/packages/bsp/Cargo.toml new file mode 100644 index 0000000..02c5c5a --- /dev/null +++ b/packages/bsp/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "bsp" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +bincode = "2.0.0-rc.3" +lzma-rs = "0.3.0" +serde = { version = "1.0.217", features = ["derive"] } +tsify = "0.4.5" +wasm-bindgen = { workspace = true } diff --git a/packages/bsp/package.json b/packages/bsp/package.json new file mode 100644 index 0000000..cce9e65 --- /dev/null +++ b/packages/bsp/package.json @@ -0,0 +1,12 @@ +{ + "name": "bsp", + "main": "./pkg/bsp.js", + "types": "./pkg/bsp.d.ts", + "files": [ + "pkg" + ], + "scripts": { + "dev": "cargo watch -- wasm-pack build --dev --target bundler", + "build": "wasm-pack build --release --target bundler" + } +} diff --git a/packages/bsp/src/bsp.rs b/packages/bsp/src/bsp.rs new file mode 100644 index 0000000..4dbeef6 --- /dev/null +++ b/packages/bsp/src/bsp.rs @@ -0,0 +1,196 @@ +use std::{ + error::Error, + fmt::Display, + io::{BufReader, Cursor}, + vec, +}; + +use bincode::{ + Decode, Encode, + error::{DecodeError, EncodeError}, + impl_borrow_decode, +}; +use wasm_bindgen::{JsError, JsValue, prelude::wasm_bindgen}; + +use crate::{Entities, entities::EntitiesError}; + +#[wasm_bindgen] +#[derive(Debug, Decode)] +pub struct BSP { + buf: Vec, + pub header: BSPHeader, +} + +#[wasm_bindgen] +#[derive(Debug, Clone, Copy, Decode)] +pub struct BSPHeader { + pub signature: BSPSignature, + pub version: i32, + + #[wasm_bindgen(skip)] + pub lumps: [Lump; 64], + + pub map_revision: i32, +} + +#[wasm_bindgen] +#[derive(Debug, Clone, Copy)] +pub struct BSPSignature; + +impl Display for BSPSignature { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "VBSP") + } +} + +impl Decode for BSPSignature { + fn decode(decoder: &mut D) -> Result { + match <[u8; 4] as bincode::Decode>::decode(decoder)? { + [b'V', b'B', b'S', b'P'] => Ok(BSPSignature), + _ => Err(DecodeError::Other("VBSP")), + } + } +} + +impl_borrow_decode!(BSPSignature); + +#[wasm_bindgen] +#[derive(Debug, Decode, Clone, Copy)] +pub struct Lump { + pub offset: i32, + pub len: i32, + pub version: i32, + + #[wasm_bindgen(skip)] + pub four_cc: [u8; 4], +} + +#[derive(Debug, Decode)] +struct BSPLZMAHeader { + pub signature: LZMASignature, + pub actual_size: u32, + pub lzma_size: u32, + pub properties: [u8; 5], +} + +#[derive(Debug)] +struct LZMASignature; + +impl Display for LZMASignature { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "LZMA") + } +} + +impl Decode for LZMASignature { + fn decode(decoder: &mut D) -> Result { + match <[u8; 4] as bincode::Decode>::decode(decoder)? { + [b'L', b'Z', b'M', b'A'] => Ok(LZMASignature), + _ => Err(DecodeError::Other("LZMA")), + } + } +} + +impl_borrow_decode!(LZMASignature); + +#[derive(Debug, Encode)] +#[repr(C)] +pub struct LZMAHeader { + pub properties: [u8; 5], + pub actual_size: u64, +} + +#[derive(Debug)] +pub enum BSPError { + DecodeError(DecodeError), + EncodeError(EncodeError), + LZMA(lzma_rs::error::Error), +} + +impl Error for BSPError {} + +impl Display for BSPError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:#?}", self) + } +} + +impl From for BSPError { + fn from(value: DecodeError) -> Self { + BSPError::DecodeError(value) + } +} + +impl From for BSPError { + fn from(value: EncodeError) -> Self { + BSPError::EncodeError(value) + } +} + +impl From for BSPError { + fn from(value: lzma_rs::error::Error) -> Self { + BSPError::LZMA(value) + } +} + +impl Into for BSPError { + fn into(self) -> JsValue { + JsValue::from(JsError::new(&format!("{:?}", self))) + } +} + +#[wasm_bindgen] +impl BSP { + #[wasm_bindgen(constructor)] + pub fn new(buf: Vec) -> Result { + let mut reader = BufReader::new(Cursor::new(&buf)); + let config = bincode::config::standard().with_fixed_int_encoding(); + + let header: BSPHeader = bincode::decode_from_reader(&mut reader, config)?; + + Ok(BSP { buf, header }) + } + + pub fn lump(&self, i: usize) -> Result, BSPError> { + let lump = &self.header.lumps[i]; + let buf = &self + .buf + .get(lump.offset as usize..(lump.offset + lump.len) as usize) + .ok_or(DecodeError::UnexpectedEnd { additional: lump.len as usize })?; + + match buf[0..4] { + [b'L', b'Z', b'M', b'A'] => { + let config = bincode::config::standard().with_fixed_int_encoding(); + + let (bsp_header, bytes_read): (BSPLZMAHeader, usize) = bincode::decode_from_slice(&buf, config)?; + + let header = LZMAHeader { + properties: bsp_header.properties, + actual_size: bsp_header.actual_size as u64, + }; + + let lzma_header_size = 5 + 8; + + let mut lzma_data = vec![0u8; lzma_header_size + bsp_header.lzma_size as usize]; + + bincode::encode_into_slice(&header, &mut lzma_data, config)?; + lzma_data[lzma_header_size..].copy_from_slice(&buf[bytes_read..]); + + let mut out = vec![0u8; bsp_header.actual_size as usize]; + lzma_rs::lzma_decompress(&mut Cursor::new(&lzma_data), &mut Cursor::new(&mut out))?; + + Ok(out) + } + _ => Ok(buf.to_vec()), + } + } + + #[wasm_bindgen] + pub fn entities(&self) -> Result { + let buf = self.lump(0)?; + let text = String::from_utf8_lossy_owned(buf); + let entities = Entities::new(text)?; + + Ok(entities) + } +} diff --git a/packages/bsp/src/entities/mod.rs b/packages/bsp/src/entities/mod.rs new file mode 100644 index 0000000..a555e34 --- /dev/null +++ b/packages/bsp/src/entities/mod.rs @@ -0,0 +1,86 @@ +use std::{collections::HashMap, error::Error, fmt::Display}; + +use serde::Serialize; +use tokeniser::{Token, Tokeniser}; +use tsify::Tsify; +use wasm_bindgen::{JsError, JsValue}; + +use crate::bsp::BSPError; + +mod tokeniser; + +#[derive(Debug, Serialize, Tsify)] +#[tsify(into_wasm_abi)] +pub struct Entities(pub Vec>); + +impl Entities { + pub fn new(str: String) -> Result { + let mut tokens = Tokeniser { chars: str.chars() }; + let mut entities = vec![]; + + loop { + let mut entity = HashMap::::new(); + + match tokens.next() { + Some(Token::OpeningBrace) => loop { + match tokens.next().ok_or_else(|| ())? { + Token::String(key) => { + let value = tokens + .next() + .and_then(|token| match token { + Token::String(value) => Some(value), + _ => None, + }) + .ok_or_else(|| ())?; + + entity.insert(key, value); + } + Token::ClosingBrace => break, + _ => Err(())?, + }; + }, + Some(Token::EOF) => break, + None => break, + _ => Err(())?, + }; + + if entity.len() > 0 { + entities.push(entity); + } + } + + Ok(Entities(entities)) + } +} + +#[derive(Debug)] +pub enum EntitiesError { + BSPError(BSPError), + SyntaxError, +} + +impl Error for EntitiesError {} + +impl Display for EntitiesError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{:?}", self) + } +} + +impl From for EntitiesError { + fn from(value: BSPError) -> Self { + EntitiesError::BSPError(value) + } +} + +impl From<()> for EntitiesError { + fn from(_value: ()) -> Self { + EntitiesError::SyntaxError + } +} + +impl Into for EntitiesError { + fn into(self) -> JsValue { + JsValue::from(JsError::new(&format!("{:?}", self))) + } +} diff --git a/packages/bsp/src/entities/tokeniser.rs b/packages/bsp/src/entities/tokeniser.rs new file mode 100644 index 0000000..00b4032 --- /dev/null +++ b/packages/bsp/src/entities/tokeniser.rs @@ -0,0 +1,34 @@ +use std::{iter, str::Chars}; + +const WHITESPACE: [char; 4] = [' ', '\t', '\r', '\n']; + +pub struct Tokeniser<'a> { + pub chars: Chars<'a>, +} + +#[derive(Debug)] +pub enum Token { + OpeningBrace, + ClosingBrace, + String(String), + EOF, +} + +impl<'a> Iterator for Tokeniser<'a> { + type Item = Token; + + fn next(&mut self) -> Option { + match self.chars.by_ref().skip_while(|char| WHITESPACE.contains(char)).next()? { + '\0' => Some(Token::EOF), + '{' => Some(Token::OpeningBrace), + '}' => Some(Token::ClosingBrace), + '"' => Some(Token::String(self.chars.by_ref().take_while(|char| *char != '"').collect::())), + char => Some(Token::String( + iter::once(char) + .chain(self.chars.by_ref()) + .take_while(|char| !WHITESPACE.contains(char)) + .collect::(), + )), + } + } +} diff --git a/packages/bsp/src/lib.rs b/packages/bsp/src/lib.rs new file mode 100644 index 0000000..6ddb1c6 --- /dev/null +++ b/packages/bsp/src/lib.rs @@ -0,0 +1,10 @@ +#![feature(impl_trait_in_assoc_type)] +#![feature(iter_array_chunks)] +#![feature(iter_map_windows)] +#![feature(string_from_utf8_lossy_owned)] + +mod bsp; +mod entities; + +pub use bsp::BSP; +pub use entities::Entities; diff --git a/packages/client/package.json b/packages/client/package.json index e781d71..fa31247 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -5,6 +5,7 @@ }, "devDependencies": { "@trpc/server": "^10.45.2", + "bsp": "workspace:^", "common": "workspace:^", "minimatch": "^10.0.1", "tsconfig": "workspace:^", diff --git a/packages/client/src/TRPCClientRouter.ts b/packages/client/src/TRPCClientRouter.ts index fdc085a..9b3910c 100644 --- a/packages/client/src/TRPCClientRouter.ts +++ b/packages/client/src/TRPCClientRouter.ts @@ -1,4 +1,5 @@ import type { CombinedDataTransformer, initTRPC } from "@trpc/server" +import { BSP } from "bsp" import { Uri } from "common/Uri" import { firstValueFrom } from "rxjs" import { commands, languages, window, workspace } from "vscode" @@ -203,6 +204,26 @@ export function TRPCClientRouter( }) }), popfile: t.router({ + bsp: t.router({ + entities: t + .procedure + .input( + z.object({ + uri: Uri.schema + }) + ) + .query(async ({ input }) => { + try { + const buf = await workspace.fs.readFile(input.uri) + const bsp = new BSP(buf) + return bsp.entities() + } + catch (error) { + console.error(error) + return null + } + }) + }), vscript: t.router({ install: t .procedure diff --git a/packages/server/src/VDF/Popfile/PopfileLanguageServer.ts b/packages/server/src/VDF/Popfile/PopfileLanguageServer.ts index ca9956b..f163295 100644 --- a/packages/server/src/VDF/Popfile/PopfileLanguageServer.ts +++ b/packages/server/src/VDF/Popfile/PopfileLanguageServer.ts @@ -25,6 +25,7 @@ export class PopfileLanguageServer extends VDFLanguageServer<"popfile", PopfileT { type: "tf2", uri: teamFortress2Folder } ]), this.documents, + async (uri) => await this.trpc.client.popfile.bsp.entities.query({ uri }), refCountDispose ) } diff --git a/packages/server/src/VDF/Popfile/PopfileTextDocument.ts b/packages/server/src/VDF/Popfile/PopfileTextDocument.ts index 99065ce..eb2e8e3 100644 --- a/packages/server/src/VDF/Popfile/PopfileTextDocument.ts +++ b/packages/server/src/VDF/Popfile/PopfileTextDocument.ts @@ -1,5 +1,7 @@ +import type { Uri } from "common/Uri" import type { VSCodeVDFConfiguration } from "common/VSCodeVDFConfiguration" -import { concatMap, map, shareReplay, switchMap, type Observable } from "rxjs" +import { posix } from "path" +import { combineLatest, concatMap, from, map, of, shareReplay, switchMap, type Observable } from "rxjs" import type { VDFRange } from "vdf" import { type VDFDocumentSymbol, type VDFDocumentSymbols } from "vdf-documentsymbols" import { CodeActionKind, CompletionItem, CompletionItemKind, DiagnosticSeverity, InlayHint, InlayHintKind, InsertTextFormat } from "vscode-languageserver" @@ -162,6 +164,7 @@ export class PopfileTextDocument extends VDFTextDocument { documentConfiguration: Observable, fileSystem$: Observable, documents: TextDocuments, + getEntities: (uri: Uri) => Promise[] | null>, refCountDispose: (dispose: () => void) => void, ) { super(init, documentConfiguration, fileSystem$, documents, refCountDispose, { @@ -169,28 +172,98 @@ export class PopfileTextDocument extends VDFTextDocument { VDFParserOptions: { multilineStrings: new Set(["Param".toLowerCase()]) }, keyTransform: (key) => key, dependencies$: fileSystem$.pipe( - switchMap((fileSystem) => fileSystem.resolveFile("scripts/items/items_game.txt")), - concatMap(async (uri) => documents.get(uri!, true)), - switchMap((document) => document.documentSymbols$), - map((documentSymbols) => { - const items_game = documentSymbols.find((documentSymbol) => documentSymbol.key == "items_game") + switchMap((fileSystem) => { + return combineLatest({ + items: fileSystem.resolveFile("scripts/items/items_game.txt").pipe( + concatMap(async (uri) => documents.get(uri!, true)), + switchMap((document) => document.documentSymbols$), + map((documentSymbols) => { + const items_game = documentSymbols.find((documentSymbol) => documentSymbol.key == "items_game") - function names(key: string) { - return items_game - ?.children - ?.find((documentSymbol) => documentSymbol.key == key) - ?.children - ?.values() - .map((documentSymbol) => documentSymbol.children?.find((documentSymbol) => documentSymbol.key == "name")?.detail) - .filter((name) => name != undefined) - } + function names(key: string) { + return items_game + ?.children + ?.find((documentSymbol) => documentSymbol.key == key) + ?.children + ?.values() + .map((documentSymbol) => documentSymbol.children?.find((documentSymbol) => documentSymbol.key == "name")?.detail) + .filter((name) => name != undefined) + } - return { - items: names("items") ?? Iterator.from([]), - attributes: names("attributes") ?? Iterator.from([]), - } + return { + items: names("items") ?? Iterator.from([]), + attributes: names("attributes") ?? Iterator.from([]), + } + }), + ), + entities: from(fileSystem.readDirectory("maps", { pattern: "mvm_*.bsp" })).pipe( + map((maps) => { + const basename = this.uri.basename() + return maps + .values() + .filter(([, type]) => type == 1) + .find(([name]) => basename.startsWith(posix.parse(name).name))?.[0] + }), + switchMap((bsp) => { + if (!bsp) { + return of(null) + } + + return fileSystem.resolveFile(`maps/${bsp}`).pipe( + concatMap(async (uri) => { + if (!uri) { + return null + } + + const entities = await getEntities(uri) + if (!entities) { + return null + } + + return Map.groupBy(entities, (item) => item["classname"]) + }), + map((entities) => { + if (!entities) { + return null + } + + // Where + const blueTeamSpawns = new Set( + entities + ?.get("info_player_teamspawn") + ?.values() + ?.filter((entity) => entity["TeamNum"] == "3") + .map((entity) => entity["targetname"]) + .toArray() + ) + + // Target + const logicRelays = new Set( + entities + ?.get("logic_relay") + ?.values() + .map((entity) => entity["targetname"]) + .toArray() + ) + + // StartingPathTrackNode + const pathTracks = new Set( + entities + ?.get("path_track") + ?.values() + .filter((entity) => !entities.get("path_track")!.some((e) => e["target"] == entity["targetname"])) + .map((entity) => entity["targetname"]) + .toArray() + ) + + return { blueTeamSpawns, logicRelays, pathTracks } + }) + ) + }) + ) + }) }), - map(({ items, attributes }) => { + map(({ items: { items, attributes }, entities }) => { const attributesItems = attributes.map((name) => ({ label: name, kind: CompletionItemKind.Field })).toArray() // Drop "default" @@ -223,7 +296,21 @@ export class PopfileTextDocument extends VDFTextDocument { itemname: { kind: CompletionItemKind.Constant, values: itemsItems - } + }, + ...(entities && { + where: { + kind: CompletionItemKind.Enum, + values: [...entities.blueTeamSpawns].toSorted() + }, + target: { + kind: CompletionItemKind.Enum, + values: [...entities.logicRelays].toSorted() + }, + startingpathtracknode: { + kind: CompletionItemKind.Enum, + values: [...entities.pathTracks].toSorted() + } + }), } }, globals: [] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a800ba6..ea3c1bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -178,11 +178,16 @@ importers: specifier: workspace:^ version: link:../../packages/vtf-canvas + packages/bsp: {} + packages/client: devDependencies: '@trpc/server': specifier: ^10.45.2 version: 10.45.2 + bsp: + specifier: workspace:^ + version: link:../bsp common: specifier: workspace:^ version: link:../common/src