Merge branch 'main' into ue

This commit is contained in:
tresabhi committed 2026-06-10 13:11:59 -05:00
commit 173c476fb4
12 files changed
+240 -110

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
# BlitzKit 1.30.3
Thank you to [GonnaHetzMe](https://github.com/karelpak1) for your contributions this update.
## Features
- [GonnaHetzMe] Added tooltips to the module selector in Tankopedia
## Fixes
- Added aggressive re-rendering for the Guess mini-game's score readout to make inspect-element harder
- Removed tank visual and collision model caching
- Guesser once again breaks streaks if not all tiers are selected
- BlitzKit now properly unloads tank models from memory when visually hidden in Guesser
- [GonnaHetzMe] Tanks with complex transparency like the Herdbreaker once again handle opacity correctly
- [GonnaHetzMe] Pieces of tank models that have transparency now handle culling properly (they're visible from both sides)
- [GonnaHetzMe] Tanks with "cardboard" textures have been fixed, thanks to better PVR file parsing
- [GonnaHetzMe] Tank icons are now scaled correctly so small tanks are now properly zoomed out in contrast to bigger ones
+12 -12
View File
@@ -1,5 +1,5 @@
import { clamp, isEqual, times } from 'lodash-es'; import { clamp, isEqual, times } from "lodash-es";
import { ReadStream } from './buffer'; import { ReadStream } from "./buffer";
export enum PvrFlags { export enum PvrFlags {
NoFlag = 0, NoFlag = 0,
@@ -96,7 +96,7 @@ export class PvrReadStream extends ReadStream {
const header = this.header(); const header = this.header();
this.metadata(); this.metadata();
if (typeof header.pixelFormat === 'number') { if (typeof header.pixelFormat === "number") {
switch (header.pixelFormat) { switch (header.pixelFormat) {
default: default:
throw new TypeError( throw new TypeError(
@@ -142,12 +142,12 @@ export class PvrReadStream extends ReadStream {
times(pixelCount, (index) => { times(pixelCount, (index) => {
const buffer = this.consumeUint8Array(2); const buffer = this.consumeUint8Array(2);
const bufferIndex = index * 4; const bufferIndex = index * 3;
const r = ((buffer[1] & 0b11111000) >>> 3) / 31; const r = ((buffer[1] & 0b11111000) >>> 3) / 31;
const g = const g =
((buffer[1] & 0b111) << 3) | (((buffer[1] & 0b111) << 3) | ((buffer[0] & 0b11100000) >>> 5)) /
(((buffer[0] & 0b11100000) >>> 5) / 31); 63;
const b = buffer[0] & 0b11111; const b = (buffer[0] & 0b11111) / 31;
data[bufferIndex] = Math.round(clamp(r * 255, 0, 255)); data[bufferIndex] = Math.round(clamp(r * 255, 0, 255));
data[bufferIndex + 1] = Math.round(clamp(g * 255, 0, 255)); data[bufferIndex + 1] = Math.round(clamp(g * 255, 0, 255));
@@ -186,7 +186,7 @@ export class PvrReadStream extends ReadStream {
metadataSize: this.uint32(), metadataSize: this.uint32(),
}; };
if (header.version !== 'PVR\x03') throw new TypeError('Endian mismatch'); if (header.version !== "PVR\x03") throw new TypeError("Endian mismatch");
return header; return header;
} }
@@ -205,19 +205,19 @@ export class PvrReadStream extends ReadStream {
resolveBitRatePixelFormat( resolveBitRatePixelFormat(
pixelFormat: Exclude<ReturnType<typeof this.pixelFormat>, number>, pixelFormat: Exclude<ReturnType<typeof this.pixelFormat>, number>,
) { ) {
if (pixelFormat.order === 'rgba') { if (pixelFormat.order === "rgba") {
if (isEqual(pixelFormat.bitRates, [4, 4, 4, 4])) { if (isEqual(pixelFormat.bitRates, [4, 4, 4, 4])) {
return ResolvedBitRatePixelFormat.R4G4B4A4; return ResolvedBitRatePixelFormat.R4G4B4A4;
} else } else
throw new TypeError( throw new TypeError(
`Unhandled rgba bit rate ${pixelFormat.bitRates.join(', ')}`, `Unhandled rgba bit rate ${pixelFormat.bitRates.join(", ")}`,
); );
} else if (pixelFormat.order === 'rgb\0') { } else if (pixelFormat.order === "rgb\0") {
if (isEqual(pixelFormat.bitRates, [5, 6, 5, 0])) { if (isEqual(pixelFormat.bitRates, [5, 6, 5, 0])) {
return ResolvedBitRatePixelFormat.R5G6B5A0; return ResolvedBitRatePixelFormat.R5G6B5A0;
} else } else
throw new TypeError( throw new TypeError(
`Unhandled rgb bit rate ${pixelFormat.bitRates.join(', ')}`, `Unhandled rgb bit rate ${pixelFormat.bitRates.join(", ")}`,
); );
} else { } else {
throw new TypeError(`Unhandled pixel order ${pixelFormat.order}`); throw new TypeError(`Unhandled pixel order ${pixelFormat.order}`);
+4 -1
View File
@@ -15,6 +15,9 @@ type DataNode = {
qualityGroup?: string; qualityGroup?: string;
fxName?: string; fxName?: string;
textures?: Textures; textures?: Textures;
customCullMode?: number;
enabledPresets?: EnabledPresets;
properties?: Properties;
} & ( } & (
| ({ | ({
configCount: number; configCount: number;
@@ -24,7 +27,7 @@ type DataNode = {
} }
); );
interface ConfigArchive { export interface ConfigArchive {
configName: string; configName: string;
fxName: string; fxName: string;
properties: Properties; properties: Properties;
+2 -2
View File
@@ -1018,8 +1018,8 @@
"title": "Характеристики", "title": "Характеристики",
"compare": { "compare": {
"label": "Сравнивать с", "label": "Сравнивать с",
"class": "Уровень Тип", "class": "{tier} {class}",
"tier": "Все уровни", "tier": "Все {tier} уровня",
"all": "Все танки" "all": "Все танки"
}, },
"values": { "values": {
+48 -6
View File
@@ -1,6 +1,7 @@
import { NATION_IDS } from "@blitzkit/core"; import { NATION_IDS } from "@blitzkit/core";
import sharp from "sharp"; import sharp from "sharp";
import type { Vector3Tuple } from "three"; import type { Vector3Tuple } from "three";
import { parsePackedSpriteRect } from "../core/blitz/parsePackedSpriteRect";
import { AssetUploader } from "../core/github/assetUploader"; import { AssetUploader } from "../core/github/assetUploader";
import { vfs } from "./constants"; import { vfs } from "./constants";
import type { VehicleDefinitionList } from "./definitions"; import type { VehicleDefinitionList } from "./definitions";
@@ -37,6 +38,45 @@ export interface TankParameters {
}; };
} }
async function extractPackedTankIcon(packedBuffer: Buffer, context: string) {
const texture = sharp(packedBuffer);
const spriteRect = parsePackedSpriteRect(packedBuffer);
if (!spriteRect) {
throw new Error(`Missing RIFF sprite bounds for ${context}`);
}
const [left, top, width, height] = spriteRect;
const bounds = [left, top, width, height];
if (!bounds.every(Number.isInteger)) {
throw new Error(
`Invalid RIFF sprite bounds for ${context}: ${bounds.join(" ")}`,
);
}
const metadata = await texture.metadata();
if (metadata.width === undefined || metadata.height === undefined) {
throw new Error(`Failed to read image dimensions for ${context}`);
}
if (
left < 0 ||
top < 0 ||
width <= 0 ||
height <= 0 ||
left + width > metadata.width ||
top + height > metadata.height
) {
throw new Error(
`Out-of-bounds RIFF sprite for ${context}: ${bounds.join(" ")} in ${metadata.width}x${metadata.height}`,
);
}
return await texture.extract({ left, top, width, height }).toBuffer();
}
export async function tankIcons() { export async function tankIcons() {
console.log("Building tank icons..."); console.log("Building tank icons...");
@@ -67,12 +107,14 @@ export async function tankIcons() {
const bigPath = `Data/${parameters.resourcesPath.bigIconPath const bigPath = `Data/${parameters.resourcesPath.bigIconPath
.replace(/~res:\//, "") .replace(/~res:\//, "")
.replace(/\..+/, "")}.packed.webp`; .replace(/\..+/, "")}.packed.webp`;
const big = await sharp(await vfs.file(bigPath)) const big = await extractPackedTankIcon(
.trim() await vfs.file(bigPath),
.toBuffer(); `tank ${tankKey} big`,
const small = await sharp(await vfs.file(smallPath)) );
.trim() const small = await extractPackedTankIcon(
.toBuffer(); await vfs.file(smallPath),
`tank ${tankKey} small`,
);
if (big) { if (big) {
await uploader.add({ await uploader.add({
@@ -1,5 +1,6 @@
import { import {
bufferToBigInt, bufferToBigInt,
ConfigArchive,
Hierarchy, Hierarchy,
Sc2ReadStream, Sc2ReadStream,
ScgReadStream, ScgReadStream,
@@ -75,6 +76,42 @@ export async function extractModel(vfs: AbstractVFS, path: string) {
.setImage(baseColor), .setImage(baseColor),
); );
let defaultConfigArchive: ConfigArchive | undefined = undefined;
if (node.configCount) {
const names = times(
node.configCount,
(index) => node[`configArchive_${index}`],
);
defaultConfigArchive = names.find(
(archive) => archive.configName === "Default",
);
defaultConfigArchive ??= node.configArchive_0;
}
const customCullMode =
node.customCullMode ?? defaultConfigArchive?.customCullMode;
if (customCullMode === 0) {
material.setDoubleSided(true);
} else if (customCullMode !== undefined) {
material.setDoubleSided(false);
}
if (
node.enabledPresets?.AlphaTest &&
node.properties?.alphatestThreshold
) {
const view = new DataView(node.properties.alphatestThreshold);
const alphaCutoff =
view.byteLength < 4
? 0.5
: view.getFloat32(view.byteLength - 4, true);
material.setAlphaMode("MASK").setAlphaCutoff(alphaCutoff);
}
if (node.configCount) { if (node.configCount) {
for ( for (
let configIndex = 0; let configIndex = 0;
@@ -85,14 +122,17 @@ export async function extractModel(vfs: AbstractVFS, path: string) {
if ( if (
archive.configName !== "Default" || archive.configName !== "Default" ||
!archive.enabledPresets?.AlphaTest !archive.enabledPresets?.AlphaTest ||
!archive.properties?.alphatestThreshold
) { ) {
continue; continue;
} }
const view = new DataView(archive.properties.alphatestThreshold); const view = new DataView(archive.properties.alphatestThreshold);
const _ = view.getUint8(0); // only god knows what these flags are const alphaCutoff =
const alphaCutoff = view.getFloat32(1, true); view.byteLength < 4
? 0.5
: view.getFloat32(view.byteLength - 4, true);
material.setAlphaMode("MASK").setAlphaCutoff(alphaCutoff); material.setAlphaMode("MASK").setAlphaCutoff(alphaCutoff);
} }
@@ -171,8 +211,8 @@ export async function extractModel(vfs: AbstractVFS, path: string) {
case "TransformComponent": { case "TransformComponent": {
const localTranslation = component["tc.localTranslation"]; const localTranslation = component["tc.localTranslation"];
// The game resets top-level node translation to [0, 0, 0] on load. // The game resets top-level node translation to [0, 0, 0] on load.
// Child nodes must keep their authored local offsets. // Child nodes must keep their authored local offsets.
node.setTranslation( node.setTranslation(
parent instanceof Scene ? [0, 0, 0] : localTranslation, parent instanceof Scene ? [0, 0, 0] : localTranslation,
); );
@@ -284,4 +324,4 @@ export async function extractModel(vfs: AbstractVFS, path: string) {
await document.transform(prune({ keepAttributes: true }), dedup()); await document.transform(prune({ keepAttributes: true }), dedup());
return document; return document;
} }
+18 -8
View File
@@ -58,12 +58,14 @@ export function Guesser() {
const [results, setResults] = useState<TankDefinition[] | null>(null); const [results, setResults] = useState<TankDefinition[] | null>(null);
const [selected, setSelected] = useState<TankDefinition | null>(null); const [selected, setSelected] = useState<TankDefinition | null>(null);
const tiers = Guess.use((state) => state.tiers); const tiers = Guess.use((state) => state.tiers);
const status = useRef<HTMLSpanElement>(null);
const requestSearch = useCallback(() => { const requestSearch = useCallback(() => {
setSelected(null); setSelected(null);
setSearching(true); setSearching(true);
search(); search();
}, []); }, []);
const search = useCallback( const search = useCallback(
debounce(() => { debounce(() => {
if (!input.current) return; if (!input.current) return;
@@ -92,6 +94,20 @@ export function Guesser() {
if (guessState !== GuessState.NotGuessed) setSelected(null); if (guessState !== GuessState.NotGuessed) setSelected(null);
}, [guessState]); }, [guessState]);
useEffect(() => {
const interval = setInterval(() => {
if (!status.current) return;
status.current.innerText = literals(strings.website.tools.guess.stats, {
correct: correctGuesses,
total: totalGuesses,
streak: streak,
});
}, 1000 / 200);
return () => clearInterval(interval);
}, [correctGuesses, totalGuesses, streak]);
return ( return (
<Flex <Flex
direction="column" direction="column"
@@ -158,13 +174,7 @@ export function Guesser() {
</Box> </Box>
<Flex justify="center" style={{ userSelect: "none" }}> <Flex justify="center" style={{ userSelect: "none" }}>
<Text> <Text ref={status} />
{literals(strings.website.tools.guess.stats, {
correct: correctGuesses,
total: totalGuesses,
streak: streak,
})}
</Text>
</Flex> </Flex>
<Flex gap={{ initial: "2", sm: "3" }}> <Flex gap={{ initial: "2", sm: "3" }}>
@@ -312,7 +322,7 @@ export function Guesser() {
? GuessState.Correct ? GuessState.Correct
: GuessState.Incorrect; : GuessState.Incorrect;
draft.totalGuesses++; draft.totalGuesses++;
draft.correctGuesses += correct ? 1 : 0; draft.correctGuesses += correct && draft.tiers.length === 10 ? 1 : 0;
draft.streak = correct ? draft.streak + 1 : 0; draft.streak = correct ? draft.streak + 1 : 0;
}); });
} else { } else {
@@ -70,37 +70,6 @@ export function Equalizer() {
<Switch checked={equalize} /> <Switch checked={equalize} />
</Flex> </Flex>
</Box> </Box>
<Popover.Root>
<Popover.Trigger>
<Text style={{ cursor: "pointer" }} size="2" color="gray">
<Flex align="center" gap="2">
Brought to you by{" "}
<img
src="/assets/images/third-party/wotb-news.png"
style={{
width: "1rem",
height: "1rem",
objectFit: "cover",
borderRadius: "50%",
}}
/>{" "}
WoTB News <InfoCircledIcon />
</Flex>
</Text>
</Popover.Trigger>
<Popover.Content>
<Text>
This feature was brought to you early, ahead of the release of the
game mode, thanks to{" "}
<Link href="https://discord.gg/WHdER7ZPAD" target="_blank">
WoTB News
</Link>
.
</Text>
</Popover.Content>
</Popover.Root>
</ConfigurationChildWrapper> </ConfigurationChildWrapper>
); );
} }
@@ -18,6 +18,7 @@ import {
Heading, Heading,
IconButton, IconButton,
Text, Text,
Tooltip,
} from "@radix-ui/themes"; } from "@radix-ui/themes";
import { awaitableTankDefinitions } from "../../../../core/awaitables/tankDefinitions"; import { awaitableTankDefinitions } from "../../../../core/awaitables/tankDefinitions";
import { useLocale } from "../../../../hooks/useLocale"; import { useLocale } from "../../../../hooks/useLocale";
@@ -55,12 +56,14 @@ function ModuleButton({
selected, selected,
unlock, unlock,
top, top,
tooltip,
onClick, onClick,
}: { }: {
unlock: Unlock; unlock: Unlock;
tier: number; tier: number;
selected: boolean; selected: boolean;
top: boolean; top: boolean;
tooltip?: string;
onClick: () => void; onClick: () => void;
}) { }) {
const isTank = unlock.type === ModuleType.MODULE_TYPE_VEHICLE; const isTank = unlock.type === ModuleType.MODULE_TYPE_VEHICLE;
@@ -134,7 +137,7 @@ function ModuleButton({
</IconButton> </IconButton>
); );
return isTank ? ( const node = isTank ? (
<LinkI18n <LinkI18n
locale={locale} locale={locale}
href={`/tanks/${tankDefinitions.tanks[unlock.id].slug}`} href={`/tanks/${tankDefinitions.tanks[unlock.id].slug}`}
@@ -144,6 +147,10 @@ function ModuleButton({
) : ( ) : (
button button
); );
if (!tooltip) return node;
return <Tooltip content={tooltip}>{node}</Tooltip>;
} }
const tankDefinitions = await awaitableTankDefinitions; const tankDefinitions = await awaitableTankDefinitions;
@@ -167,7 +174,7 @@ export function Modules() {
const topGun = topTurret.guns.at(-1)!; const topGun = topTurret.guns.at(-1)!;
const topEngine = tank.engines.at(-1)!; const topEngine = tank.engines.at(-1)!;
const topTrack = tank.tracks.at(-1)!; const topTrack = tank.tracks.at(-1)!;
const { strings } = useLocale(); const { strings, unwrap } = useLocale();
function setByUnlock(unlock: Unlock) { function setByUnlock(unlock: Unlock) {
Duel.mutate((draft) => { Duel.mutate((draft) => {
@@ -249,6 +256,8 @@ export function Modules() {
if (module === undefined) return null; if (module === undefined) return null;
const tooltip = unwrap(module.name!);
return ( return (
<Flex <Flex
key={unlock.id} key={unlock.id}
@@ -301,6 +310,7 @@ export function Modules() {
<ModuleButton <ModuleButton
unlock={unlock} unlock={unlock}
tier={extractModule(module).tier} tier={extractModule(module).tier}
tooltip={tooltip}
selected={ selected={
(unlock.type === ModuleType.MODULE_TYPE_TURRET (unlock.type === ModuleType.MODULE_TYPE_TURRET
? turret.id ? turret.id
+15 -19
View File
@@ -1,23 +1,19 @@
import { asset } from '@blitzkit/core'; import { asset } from "@blitzkit/core";
import { type ObjectMap, useLoader } from '@react-three/fiber'; import { useLoader } from "@react-three/fiber";
import { type GLTF, GLTFLoader } from 'three-stdlib'; import { GLTFLoader } from "three-stdlib";
import { useDispose } from "./useDispose";
const cache: Record<
number,
{ gltf: GLTF & ObjectMap; hasDynamicArmor: boolean }
> = {};
export function useArmor(id: number) { export function useArmor(id: number) {
const gltf = useLoader(GLTFLoader, asset(`3d/tanks/armor/${id}.glb`)); const path = asset(`3d/tanks/armor/${id}.glb`);
const gltf = useLoader(GLTFLoader, path);
if (!cache[id]) { useDispose(gltf, path);
cache[id] = {
gltf, return {
hasDynamicArmor: Object.values(gltf.nodes).some( gltf,
(node) => hasDynamicArmor: Object.values(gltf.nodes).some(
node.name.includes('state_00') || node.name.includes('state_01'), (node) =>
), node.name.includes("state_00") || node.name.includes("state_01"),
}; ),
} };
return cache[id];
} }
+50
View File
@@ -0,0 +1,50 @@
import { useLoader, type ObjectMap } from "@react-three/fiber";
import { useEffect } from "react";
import { Cache, Group, Mesh, MeshStandardMaterial, Object3D } from "three";
import { GLTFLoader, type GLTF } from "three-stdlib";
export function useDispose(gltf: GLTF & ObjectMap, path: string) {
useEffect(() => {
return () => {
gltf.scene.traverse((object) => {
if (object instanceof Mesh) {
if (object.material instanceof MeshStandardMaterial) {
const maps = [
object.material.map,
object.material.aoMap,
object.material.envMap,
object.material.bumpMap,
object.material.alphaMap,
object.material.lightMap,
object.material.normalMap,
object.material.emissiveMap,
object.material.roughnessMap,
object.material.metalnessMap,
object.material.displacementMap,
];
for (const map of maps) {
if (!map) continue;
map.source.data.close();
map.dispose();
}
object.material.dispose();
} else {
throw new TypeError(
`Unexpected material type: ${object.material.type}`,
);
}
} else if (object instanceof Group || object instanceof Object3D) {
// traverse will get children
} else {
throw new TypeError(`Unexpected object type: ${object}`);
}
});
useLoader.clear(GLTFLoader, path);
Cache.remove(path);
};
}, [gltf, path]);
}
+15 -23
View File
@@ -1,30 +1,22 @@
import { asset } from "@blitzkit/core"; import { asset } from "@blitzkit/core";
import { type ObjectMap, useLoader } from "@react-three/fiber"; import { useLoader } from "@react-three/fiber";
import { Mesh, MeshStandardMaterial } from "three"; import { Mesh, MeshStandardMaterial } from "three";
import { type GLTF, GLTFLoader } from "three-stdlib"; import { GLTFLoader } from "three-stdlib";
import { useDispose } from "./useDispose";
const cache: Record<
number,
{
gltf: GLTF & ObjectMap;
hasPbr: boolean;
}
> = {};
export function useModel(id: number) { export function useModel(id: number) {
const gltf = useLoader(GLTFLoader, asset(`3d/tanks/models/${id}.glb`)); const path = asset(`3d/tanks/models/${id}.glb`);
const gltf = useLoader(GLTFLoader, path);
if (!cache[id]) { useDispose(gltf, path);
cache[id] = {
gltf,
hasPbr: Object.values(gltf.nodes).some(
(node) =>
node instanceof Mesh &&
node.material instanceof MeshStandardMaterial &&
node.material.roughnessMap !== null
),
};
}
return cache[id]; return {
gltf,
hasPbr: Object.values(gltf.nodes).some(
(node) =>
node instanceof Mesh &&
node.material instanceof MeshStandardMaterial &&
node.material.roughnessMap !== null,
),
};
} }