Feature: Looping garage animations for legendary camouflages

Legendary camo add-ons now loop their garage animation in the Tankopedia viewer. It's always on, with no settings.

- The garage replay now also works out which motions end up looping, and the export writes only those clips
- Customization parts export skeletons, skin weights and sockets
- Parts render as primitives so their bones can animate, with attachments layered on top
- Base tank models are unchanged
This commit is contained in:
Mark Greene committed 2026-10-31 22:15:57 +13:00
commit 8566b2cc2f
94 files changed
+2037 -1861

No files matched your search

@@ -246,6 +246,27 @@ export abstract class ServerBlitzKitAPI0 extends BlitzKitAPI {
return this;
}
protected getTankFilterIds(
name: string,
definitionKey: string,
filter: "include" | "exclude",
) {
const keys = name.trim().split(/\s+/).filter(Boolean);
return keys.flatMap((key) => {
const id = this.tankStringIdMap[key];
if (id === undefined) {
console.warn(
`Skipping unavailable tank "${key}" in ${filter} vehicle filter for "${definitionKey}"`,
);
return [];
}
return [id];
});
}
protected getString(name: string) {
const collection: Record<string, string> = {
[locales.default]: this.stringsI18n[locales.default][name],
@@ -264,6 +285,8 @@ export abstract class ServerBlitzKitAPI0 extends BlitzKitAPI {
collection[locale] = localizedString;
}
collection[locales.default] = collection[locales.default] ?? name;
return { locales: collection } satisfies I18nString;
}
}
@@ -387,7 +387,7 @@ export abstract class ServerBlitzKitAPI1 extends ServerBlitzKitAPI0 {
: TankType.TANK_TYPE_RESEARCHABLE,
tier: tank.level,
class: tankClass,
testing: tankTags.includes("testTank"),
testing: tankTags.includes("testTank") || tank.level === 11,
deprecated: tankTags.includes("deprecated"),
price: tankPrice,
camouflage_still: tankDefinition.root.invisibility.still,
@@ -595,9 +595,25 @@ export abstract class ServerBlitzKitAPI1 extends ServerBlitzKitAPI0 {
for (const shellKey in gunListEntry.shots) {
const gunShellEntry = gunListEntry.shots[shellKey];
const shell = shellList.root[shellKey];
// Newer client data stores shot properties on the shell rather
// than repeating them under every gun's <shots> entry.
const speed =
typeof gunShellEntry === "string"
? shell.speed
: gunShellEntry.speed;
const maxDistance =
typeof gunShellEntry === "string"
? shell.maxDistance
: gunShellEntry.maxDistance;
const piercingPower =
typeof gunShellEntry === "string"
? shell.piercingPower
: gunShellEntry.piercingPower;
const shellId = toUniqueId(nation, shell.id);
const shellName = this.getString(shell.userString);
const penetrationRaw = gunShellEntry.piercingPower
const penetrationRaw = piercingPower
.split(" ")
.filter((penetrationString) => penetrationString !== "")
.map(Number);
@@ -607,7 +623,7 @@ export abstract class ServerBlitzKitAPI1 extends ServerBlitzKitAPI0 {
].shells.push({
id: shellId,
name: shellName,
velocity: gunShellEntry.speed,
velocity: speed,
armor_damage: shell.damage.armor,
module_damage: shell.damage.devices,
caliber: shell.caliber,
@@ -623,7 +639,7 @@ export abstract class ServerBlitzKitAPI1 extends ServerBlitzKitAPI0 {
near: penetrationRaw[0],
far: penetrationRaw[1],
},
range: gunShellEntry.maxDistance,
range: maxDistance,
});
}
@@ -15,7 +15,9 @@ export abstract class ServerBlitzKitAPI2 extends ServerBlitzKitAPI1 {
);
const sc2 = new Sc2ReadStream(
(
await this.vfs.file(`Data/3d/${parameters.resourcesPath.blitzModelPath}`)
await this.vfs.file(
`Data/3d/${parameters.resourcesPath.blitzModelPath}`,
)
).buffer as ArrayBuffer,
).sc2();
const presets = new Set<string>();
@@ -51,6 +53,8 @@ export abstract class ServerBlitzKitAPI2 extends ServerBlitzKitAPI1 {
for (const camoKey in this.camouflagesXml!.root.camouflages) {
const camo = this.camouflagesXml!.root.camouflages[camoKey];
if (!(camoKey in this.camouflagesYaml!)) continue;
const yamlEntry = this.camouflagesYaml![camoKey];
const fullName = yamlEntry.userString
? this.getString(yamlEntry.userString)
@@ -83,14 +87,19 @@ export abstract class ServerBlitzKitAPI2 extends ServerBlitzKitAPI1 {
),
);
const [nation, tankKey] = vehicle.split(":");
// some unique camos only set the preset for their one tank
const preset =
yamlEntry.preset ??
yamlEntry.override?.[nation]?.override?.[tankKey]?.preset;
let hasPreset = false;
if (camo.group === "legendary" && yamlEntry.preset !== undefined) {
if (preset !== undefined) {
if (!skinPresets.has(vehicle)) {
skinPresets.set(vehicle, this.skinPresets(vehicle));
}
hasPreset = (await skinPresets.get(vehicle)!).has(yamlEntry.preset);
hasPreset = (await skinPresets.get(vehicle)!).has(preset);
}
if (resolvedParts.some(Boolean) || hasPreset) {
@@ -98,12 +107,15 @@ export abstract class ServerBlitzKitAPI2 extends ServerBlitzKitAPI1 {
}
}
const name = this.getString(camo.userString);
camouflageDefinitions.camouflages[camo.id] = {
id: camo.id,
name: this.getString(camo.userString),
name,
tank_name: resolvedTankName,
tank_name_full: resolvedTankNameFull,
tank_id: tankId,
attachment: camo.kind === "customization",
};
}
@@ -44,9 +44,7 @@ export abstract class ServerBlitzKitAPI6 extends ServerBlitzKitAPI5 {
filter_type: {
$case: "ids",
value: {
ids: includeRaw.name.split(/ +/).map((key) => {
return this.tankStringIdMap[key];
}),
ids: this.getTankFilterIds(includeRaw.name, key, "include"),
},
},
});
@@ -72,9 +70,7 @@ export abstract class ServerBlitzKitAPI6 extends ServerBlitzKitAPI5 {
filter_type: {
$case: "ids",
value: {
ids: excludeRaw.name.split(/ +/).map((key) => {
return this.tankStringIdMap[key];
}),
ids: this.getTankFilterIds(excludeRaw.name, key, "exclude"),
},
},
});
@@ -11,7 +11,7 @@ export abstract class ServerBlitzKitAPI7 extends ServerBlitzKitAPI6 {
async provisionDefinitions() {
const provisionDefinitions = ProvisionDefinitions.create();
Object.entries(this.provisionsCommon).forEach(([, provision]) => {
Object.entries(this.provisionsCommon).forEach(([key, provision]) => {
const entry: Provision = {
id: provision.id,
exclude: [],
@@ -42,9 +42,7 @@ export abstract class ServerBlitzKitAPI7 extends ServerBlitzKitAPI6 {
filter_type: {
$case: "ids",
value: {
ids: includeRaw.name.split(/ +/).map((key) => {
return this.tankStringIdMap[key];
}),
ids: this.getTankFilterIds(includeRaw.name, key, "include"),
},
},
});
@@ -70,9 +68,7 @@ export abstract class ServerBlitzKitAPI7 extends ServerBlitzKitAPI6 {
filter_type: {
$case: "ids",
value: {
ids: excludeRaw.name
.split(/ +/)
.map((key) => this.tankStringIdMap[key]),
ids: this.getTankFilterIds(excludeRaw.name, key, "exclude"),
},
},
});
@@ -14,4 +14,5 @@ message Camouflage {
optional I18nString tank_name = 3;
optional I18nString tank_name_full = 4;
optional uint32 tank_id = 5;
optional bool attachment = 6;
}
@@ -25,6 +25,7 @@ export interface Camouflage {
tank_name?: I18nString | undefined;
tank_name_full?: I18nString | undefined;
tank_id?: number | undefined;
attachment?: boolean | undefined;
}
function createBaseCamouflageDefinitions(): CamouflageDefinitions {
@@ -192,7 +193,7 @@ export const CamouflageDefinitions_CamouflagesEntry: MessageFns<CamouflageDefini
};
function createBaseCamouflage(): Camouflage {
return { id: 0, name: undefined, tank_name: undefined, tank_name_full: undefined, tank_id: 0 };
return { id: 0, name: undefined, tank_name: undefined, tank_name_full: undefined, tank_id: 0, attachment: false };
}
export const Camouflage: MessageFns<Camouflage> = {
@@ -212,6 +213,9 @@ export const Camouflage: MessageFns<Camouflage> = {
if (message.tank_id !== undefined && message.tank_id !== 0) {
writer.uint32(40).uint32(message.tank_id);
}
if (message.attachment !== undefined && message.attachment !== false) {
writer.uint32(48).bool(message.attachment);
}
return writer;
},
@@ -262,6 +266,14 @@ export const Camouflage: MessageFns<Camouflage> = {
message.tank_id = reader.uint32();
continue;
}
case 6: {
if (tag !== 48) {
break;
}
message.attachment = reader.bool();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
@@ -278,6 +290,7 @@ export const Camouflage: MessageFns<Camouflage> = {
tank_name: isSet(object.tank_name) ? I18nString.fromJSON(object.tank_name) : undefined,
tank_name_full: isSet(object.tank_name_full) ? I18nString.fromJSON(object.tank_name_full) : undefined,
tank_id: isSet(object.tank_id) ? globalThis.Number(object.tank_id) : 0,
attachment: isSet(object.attachment) ? globalThis.Boolean(object.attachment) : false,
};
},
@@ -298,6 +311,9 @@ export const Camouflage: MessageFns<Camouflage> = {
if (message.tank_id !== undefined && message.tank_id !== 0) {
obj.tank_id = Math.round(message.tank_id);
}
if (message.attachment !== undefined && message.attachment !== false) {
obj.attachment = message.attachment;
}
return obj;
},
@@ -317,6 +333,7 @@ export const Camouflage: MessageFns<Camouflage> = {
? I18nString.fromPartial(object.tank_name_full)
: undefined;
message.tank_id = object.tank_id ?? 0;
message.attachment = object.attachment ?? false;
return message;
},
};
+26 -1
View File
@@ -88,6 +88,7 @@ enum PvrChannelType {
enum ResolvedBitRatePixelFormat {
R4G4B4A4,
R8G8B8A8,
R5G6B5A0,
}
@@ -137,6 +138,27 @@ export class PvrReadStream extends ReadStream {
};
}
case ResolvedBitRatePixelFormat.R8G8B8A8: {
const data = Buffer.alloc(pixelCount * 4);
times(pixelCount, (index) => {
const buffer = this.consumeUint8Array(4);
const bufferIndex = index * 4;
data[bufferIndex] = buffer[0]; // R
data[bufferIndex + 1] = buffer[1]; // G
data[bufferIndex + 2] = buffer[2]; // B
data[bufferIndex + 3] = buffer[3]; // A
});
return {
data,
width: header.width,
height: header.height,
channels: 4 as const,
};
}
case ResolvedBitRatePixelFormat.R5G6B5A0: {
const data = Buffer.alloc(pixelCount * 3);
@@ -208,10 +230,13 @@ export class PvrReadStream extends ReadStream {
if (pixelFormat.order === "rgba") {
if (isEqual(pixelFormat.bitRates, [4, 4, 4, 4])) {
return ResolvedBitRatePixelFormat.R4G4B4A4;
} else
} else if (isEqual(pixelFormat.bitRates, [8, 8, 8, 8])) {
return ResolvedBitRatePixelFormat.R8G8B8A8;
} else {
throw new TypeError(
`Unhandled rgba bit rate ${pixelFormat.bitRates.join(", ")}`,
);
}
} else if (pixelFormat.order === "rgb\0") {
if (isEqual(pixelFormat.bitRates, [5, 6, 5, 0])) {
return ResolvedBitRatePixelFormat.R5G6B5A0;
+7 -1
View File
@@ -40,6 +40,8 @@ export interface ConfigArchive {
interface EnabledPresets {
AlphaTest: boolean;
AlphaBlend?: boolean;
AlphaBlendAdditive?: boolean;
}
interface Flags {
@@ -156,7 +158,11 @@ export type Component =
"ro.notShadowOnly": boolean;
"ro.sOclCull": boolean;
"ro.sOclIndex": number;
} & Record<`rb${number}.${"lodIndex" | "switchIndex"}`, number>;
} & Record<`rb${number}.${"lodIndex" | "switchIndex"}`, number> &
Record<
`skinnedObject.batch${number}.targetsData`,
ArrayBuffer | undefined
>;
}
| {
"comp.typename": "SlotComponent";
@@ -5,5 +5,10 @@ export type CamouflagesYaml = Record<
shortUserString?: string;
preset?: string;
customEntities?: Record<string, { item: string }>;
// nation -> tank
override?: Record<
string,
{ override?: Record<string, { preset?: string }> }
>;
}
>;
@@ -20,11 +20,13 @@ export interface GunDefinitionsList {
burst?: { count: number; rate: number };
clip?: { count: number; rate: number };
shots: {
[key: string]: {
speed: number;
piercingPower: string;
maxDistance: number;
};
[key: string]:
| ""
| {
speed: number;
piercingPower: string;
maxDistance: number;
};
};
};
};
@@ -10,6 +10,9 @@ export type ShellDefinitionsList = Record<
normalizationAngle?: number;
ricochetAngle?: number;
explosionRadius?: number;
speed: number;
maxDistance?: number;
piercingPower: string;
}
> & {
icons: Record<string, string>;