infrastructure for inline vfs and clients

This commit is contained in:
tresabhi committed 2026-07-31 20:48:33 -05:00
1 parent d81d885767
commit 39745dd475
19 files changed
+133 -64

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
import { GameDefinitions } from "../../protos";
export abstract class BlitzKitAPI {
abstract game(): Promise<GameDefinitions>;
}
+9
View File
@@ -0,0 +1,9 @@
import { fetchPB } from "../../protobuf";
import { GameDefinitions } from "../../protos";
import { BlitzKitAPI } from "./base";
export class ClientBlitzKitAPI extends BlitzKitAPI {
game() {
return fetchPB("/api/definitions/game.pb", GameDefinitions);
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./base";
export * from "./client";
export * from "./server";
+69
View File
@@ -0,0 +1,69 @@
import {
AbstractVFS,
AvailableNationsYaml,
SquadBattleTypeStylesYaml,
} from "@blitzkit/core";
import { GameDefinitions } from "../../protos";
import { BlitzKitAPI } from "./base";
export class ServerBlitzKitAPI extends BlitzKitAPI {
vfs: AbstractVFS;
constructor(vfs: AbstractVFS) {
super();
this.vfs = vfs;
}
async game() {
const versionFile = await this.vfs.text("Data/version.txt");
const version = versionFile.split(" ")[0];
const availableNations = await this.vfs.yaml<AvailableNationsYaml>(
"Data/available_nations.yaml",
);
const nations = availableNations.available_nations;
const game: GameDefinitions = {
version,
nations,
gameModes: {},
roles: {},
};
const squadBattleTypeStyles =
await this.vfs.yaml<SquadBattleTypeStylesYaml>(
`Data/UI/Screens3/Lobby/Hangar/Squad/SquadBattleType.yaml`,
);
for (const match of squadBattleTypeStyles.Prototypes[0].components.UIDataLocalBindingsComponent.data[1][2].matchAll(
/"(\d+)" -> "(battleType\/([a-zA-Z]+))"/g,
)) {
const id = Number(match[1]);
const name = getString(match[2]);
gameModeNativeNames[match[3]] = id;
game.gameModes[id] = {
name,
};
}
Object.entries(combatRoles).forEach(([, value]) => {
gameDefinitions.roles[value.id] = { provisions: [], consumables: [] };
value.default_abilities.forEach((ability) => {
if (ability in consumableNativeNames) {
gameDefinitions.roles[value.id].consumables.push(
consumableNativeNames[ability],
);
} else if (ability in provisionNativeNames) {
gameDefinitions.roles[value.id].provisions.push(
provisionNativeNames[ability],
);
} else throw new Error(`Unknown ability ${ability}`);
});
});
return game;
}
}
@@ -1,7 +0,0 @@
import { fetchPB } from '../protobuf';
import { GameDefinitions } from '../protos';
import { asset } from './asset';
export function fetchGameDefinitions() {
return fetchPB(asset('definitions/game.pb'), GameDefinitions);
}
+2 -1
View File
@@ -7,15 +7,16 @@ export * from "./averageDefinitions";
export * from "./coefficient";
export * from "./consumableDefinitions";
// export * from './context';
export * from "./api";
export * from "./createDefaultSkills";
export * from "./degressiveStat";
export * from "./discoveredIdDefinitions";
export * from "./emblemURL";
export * from "./equipmentDefinitions";
export * from "./eventManager";
export * from "./gameDefinitions";
export * from "./getArchivedLatestSeasonNumber";
export * from "./sluggify";
export * from "./vfs";
// export * from './getArchivedRatingInfo';
export * from "./getArchivedRatingLeaderboard";
// export * from './getArchivedRatingMidnightLeaderboard';
@@ -0,0 +1,79 @@
import { readDVPL } from "@blitzkit/core";
import { Options, parse as parseCSV } from "csv-parse/sync";
import { XMLParser } from "fast-xml-parser";
import { normalize } from "path/posix";
import { parse as parseYaml } from "yaml";
export abstract class AbstractVFS {
textDecoder = new TextDecoder();
private xmlParser = new XMLParser();
abstract init(): Promise<typeof this>;
abstract dispose(): void;
abstract has(path: string): Promise<boolean>;
abstract raw(path: string): Promise<Uint8Array>;
async resolve(path: string) {
const normalized = normalize(path);
if (await this.has(normalized)) return normalized;
const dvplPath = `${normalized}.dvpl`;
if (await this.has(dvplPath)) return dvplPath;
return null;
}
async assert(path: string) {
const resolved = await this.resolve(path);
if (resolved === null) throw new Error(`File not found: ${path}`);
return resolved;
}
async file(file: string) {
const resolved = await this.assert(file);
const raw = await this.raw(resolved);
let buffer = raw;
if (resolved.endsWith(".dvpl")) {
buffer = new Uint8Array(readDVPL(Buffer.from(raw)));
}
return buffer;
}
abstract dir(path: string): Promise<string[]>;
async text(path: string) {
const file = await this.file(path);
return this.textDecoder.decode(file);
}
async yaml<Type>(path: string) {
const file = await this.text(path);
return parseYaml(file) as Type;
}
async xml<Type>(path: string) {
const file = await this.text(path);
return this.xmlParser.parse(file) as Type;
}
async _csv(text: string, options: Options = {}) {
const parsed = parseCSV(text, options);
return parsed;
}
async csv(path: string, options: Options = {}) {
const text = await this.text(path);
return await this._csv(text, options);
}
[Symbol.dispose]() {
this.dispose();
}
}
+5
View File
@@ -0,0 +1,5 @@
export * from "./abstract";
export * from "./local";
export * from "./mixed";
export * from "./steam";
export * from "./zip";
+35
View File
@@ -0,0 +1,35 @@
import { readdir } from "node:fs/promises";
import { AbstractVFS } from "./abstract";
export class LocalVFS extends AbstractVFS {
constructor(private base: string) {
super();
}
async init() {
return this;
}
async has(path: string) {
const file = Bun.file(`${this.base}/${path}`);
return await file.exists();
}
async raw(path: string) {
const file = Bun.file(`${this.base}/${path}`);
const buffer = await file.arrayBuffer();
const array = new Uint8Array(buffer);
return array;
}
async dir(path: string) {
try {
return await readdir(`${this.base}/${path}`);
} catch (_) {
return [];
}
}
dispose() {}
}
+49
View File
@@ -0,0 +1,49 @@
import { AbstractVFS } from "./abstract";
export class MixedVFS extends AbstractVFS {
constructor(private vfses: AbstractVFS[]) {
super();
}
async init() {
for (const vfs of this.vfses) {
await vfs.init();
}
return this;
}
dispose() {
for (const vfs of this.vfses) {
vfs.dispose();
}
}
async has(path: string) {
for (const vfs of this.vfses) {
if (await vfs.has(path)) return true;
}
return false;
}
async raw(path: string) {
for (const vfs of this.vfses) {
if (await vfs.has(path)) return await vfs.raw(path);
}
throw new Error(`File not found: ${path}`);
}
async dir(path: string) {
const files = new Set<string>();
for (const vfs of this.vfses) {
for (const file of await vfs.dir(path)) {
files.add(file);
}
}
return Array.from(files);
}
}
+111
View File
@@ -0,0 +1,111 @@
import SteamUser, { EConnectionProtocol } from "steam-user";
import { AbstractVFS } from "./abstract";
interface SteamManifestFile {
chunks: {
sha: string;
crc: number;
offset: string;
cb_original: number;
cb_compressed: number;
}[];
filename: string;
size: string;
flags: number;
sha_filename: string;
sha_content: string;
linktarget: unknown;
}
interface SteamManifest {
files: SteamManifestFile[];
depot_id: number;
gid_manifest: string;
creation_time: number;
filenames_encrypted: boolean;
cb_disk_original: string;
cb_disk_compressed: string;
unique_chunks: number;
crc_encrypted: number;
crc_clear: number;
}
export class SteamVFS extends AbstractVFS {
private steam = new SteamUser({ protocol: EConnectionProtocol.TCP });
private manifest: Map<string, SteamManifestFile> = new Map();
constructor(
private username: string,
private password: string,
private app: number,
private depot: number,
) {
super();
}
async init() {
this.steam.logOn({
accountName: this.username,
password: this.password,
});
await new Promise((resolve) => this.steam.once("loggedOn", resolve));
const productInfo = await this.steam.getProductInfo([this.app], []);
const manifest: SteamManifest = await new Promise((resolve) => {
// @ts-expect-error
this.steam.getManifest(
this.app,
this.depot,
// @ts-expect-error
productInfo.apps[this.app].appinfo.depots[this.depot].manifests.public
.gid,
"public",
// @ts-expect-error
(_, response) => {
resolve(response);
},
);
});
for (const file of manifest.files) {
const path = file.filename.replaceAll("\\", "/");
this.manifest.set(path, file);
}
return this;
}
async has(path: string) {
return this.manifest.has(path);
}
async raw(path: string) {
const fileManifest = this.manifest.get(path)!;
const downloaded: { type: "complete"; file: Buffer } =
// @ts-expect-error
await this.steam.downloadFile(this.app, this.depot, fileManifest);
return new Uint8Array(downloaded.file);
}
async dir(path: string) {
const parentSegments = path.split("/").length;
const children = new Set<string>();
for (const child of this.manifest.keys()) {
if (!child.startsWith(path) || child === path) continue;
const childSegments = child.split("/");
const nextSegment = childSegments[parentSegments];
children.add(nextSegment);
}
return Array.from(children);
}
dispose() {
this.steam.logOff();
}
}
+53
View File
@@ -0,0 +1,53 @@
import { normalize } from "path/posix";
import { HTTPRangeReader, unzip, ZipEntry } from "unzipit";
import { AbstractVFS } from "./abstract";
export class ZipVFS extends AbstractVFS {
entries = new Map<string, ZipEntry>();
constructor(
private url: string,
private root = "app/",
) {
super();
}
async init() {
const reader = new HTTPRangeReader(this.url);
const { entries } = await unzip(reader);
for (const entry of Object.values(entries)) {
const normalized = normalize(entry.name.slice(this.root.length));
this.entries.set(normalized, entry);
}
return this;
}
async has(path: string) {
return this.entries.has(path);
}
async raw(path: string) {
const arrayBuffer = await this.entries.get(path)!.arrayBuffer();
return new Uint8Array(arrayBuffer);
}
async dir(path: string) {
const parentSegments = path.split("/").length;
const children = new Set<string>();
for (const child of this.entries.keys()) {
if (!child.startsWith(path) || child === path) continue;
const childSegments = child.split("/");
const nextSegment = childSegments[parentSegments];
children.add(nextSegment);
}
return Array.from(children);
}
dispose() {}
}
@@ -0,0 +1,3 @@
export interface AvailableNationsYaml {
available_nations: string[];
}
+4 -2
View File
@@ -1,2 +1,4 @@
export * from './glossary';
export * from './tanksStats';
export * from "./availableNationsYaml";
export * from "./glossary";
export * from "./squadBattleTypeStylesYaml";
export * from "./tanksStats";
@@ -0,0 +1,9 @@
export interface SquadBattleTypeStylesYaml {
Prototypes: {
components: {
UIDataLocalBindingsComponent: {
data: [string, string, string][];
};
};
}[];
}