Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions src/worker/services/hytale/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,20 +23,21 @@ let loadingPromise: Promise<HytaleAssets> | null = null;
* Assets are loaded from R2 or disk asynchronously.
* Returns cached assets after first load.
*/
export async function loadHytaleAssets(): Promise<HytaleAssets> {
export async function loadHytaleAssets(skinPath: string = 'Common/Characters/Player_Textures/Player_Greyscale.png', ctx: ExecutionContext): Promise<HytaleAssets> {
if (cacheState === 'loaded') {
return cache!;
}

if (cacheState === 'loading') {
return loadingPromise!;
}
skinPath = skinPath.startsWith('Common/') ? skinPath : `Common/${skinPath}`;

cacheState = 'loading';
loadingPromise = (async () => {
const playerTextureData = await readAssetFile('Common/Characters/Player_Textures/Player_Greyscale.png', env);
const playerModelJson = await readAssetFile('Common/Characters/Player.blockymodel', env);
const idleAnimationJson = await readAssetFile('Common/Characters/Animations/Default/Idle.blockyanim', env);
const playerTextureData = await readAssetFile(skinPath, env, ctx);
const playerModelJson = await readAssetFile('Common/Characters/Player.blockymodel', env, ctx);
const idleAnimationJson = await readAssetFile('Common/Characters/Animations/Default/Idle.blockyanim', env, ctx);

const textureBytes = new Uint8Array(playerTextureData);

Expand Down
68 changes: 66 additions & 2 deletions src/worker/services/hytale/cosmetic-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import facesJson from '../../../../assets/hytale/Cosmetics/CharacterCreator/Face
import facialHairJson from '../../../../assets/hytale/Cosmetics/CharacterCreator/FacialHair.json';
import glovesJson from '../../../../assets/hytale/Cosmetics/CharacterCreator/Gloves.json';
import gradientSetsJson from '../../../../assets/hytale/Cosmetics/CharacterCreator/GradientSets.json';
import haircutFallbacksJson from '../../../../assets/hytale/Cosmetics/CharacterCreator/HaircutFallbacks.json';
import haircutsJson from '../../../../assets/hytale/Cosmetics/CharacterCreator/Haircuts.json';
import headAccessoryJson from '../../../../assets/hytale/Cosmetics/CharacterCreator/HeadAccessory.json';
import mouthsJson from '../../../../assets/hytale/Cosmetics/CharacterCreator/Mouths.json';
Expand Down Expand Up @@ -79,6 +80,39 @@ const DEFINITION_FILES: SlotDefinitions = {
Underwear: parseJson<CosmeticDefinition[]>(underwearJson),
};

const COSMETIC_FILES = [
{ path: 'Cosmetics/CharacterCreator/Faces.json', content: facesJson },
{ path: 'Cosmetics/CharacterCreator/Eyes.json', content: eyesJson },
{ path: 'Cosmetics/CharacterCreator/Eyebrows.json', content: eyebrowsJson },
{ path: 'Cosmetics/CharacterCreator/Mouths.json', content: mouthsJson },
{ path: 'Cosmetics/CharacterCreator/Ears.json', content: earsJson },
{ path: 'Cosmetics/CharacterCreator/Haircuts.json', content: haircutsJson },
{ path: 'Cosmetics/CharacterCreator/FacialHair.json', content: facialHairJson },
{ path: 'Cosmetics/CharacterCreator/Underwear.json', content: underwearJson },
{ path: 'Cosmetics/CharacterCreator/FaceAccessory.json', content: faceAccessoryJson },
{ path: 'Cosmetics/CharacterCreator/Capes.json', content: capesJson },
{ path: 'Cosmetics/CharacterCreator/EarAccessory.json', content: earAccessoryJson },
{ path: 'Cosmetics/CharacterCreator/Gloves.json', content: glovesJson },
{ path: 'Cosmetics/CharacterCreator/HeadAccessory.json', content: headAccessoryJson },
{ path: 'Cosmetics/CharacterCreator/GradientSets.json', content: gradientSetsJson },
{ path: 'Cosmetics/CharacterCreator/Overpants.json', content: overpantsJson },
{ path: 'Cosmetics/CharacterCreator/Overtops.json', content: overtopsJson },
{ path: 'Cosmetics/CharacterCreator/Pants.json', content: pantsJson },
{ path: 'Cosmetics/CharacterCreator/Shoes.json', content: shoesJson },
{ path: 'Cosmetics/CharacterCreator/Undertops.json', content: undertopsJson },
{ path: 'Cosmetics/CharacterCreator/HaircutFallbacks.json', content: haircutFallbacksJson },
{ path: 'Cosmetics/CharacterCreator/BodyCharacteristics.json', content: bodyCharacteristicsJson },
];

export interface SkinDefinition {
Id: string;
Model: string;
GradientSet: string;
GreyscaleTexture: string;
Name: string;
IsDefaultAsset?: boolean;
}

type CacheState = 'uninitialized' | 'loading' | 'loaded';

let cacheState: CacheState = 'uninitialized';
Expand Down Expand Up @@ -236,11 +270,21 @@ export async function resolveCosmetic(
}
}

const fullModelPath = modelPath ? `Common/${modelPath}` : null;
const fullTexturePath = texturePath ? `Common/${texturePath}` : null;

const basePlayerAssets = new Set([
'Common/Characters/Player.blockymodel',
'Common/Characters/Animations/Default/Idle.blockyanim',
'Common/Characters/Player_Textures/Player_Greyscale.png',
'Common/Characters/Player_Textures/Player_Muscular_Greyscale.png',
]);

return {
slot,
id: parsed.id,
modelPath: modelPath ? `Common/${modelPath}` : null,
texturePath: texturePath ? `Common/${texturePath}` : null,
modelPath: fullModelPath && !basePlayerAssets.has(fullModelPath) ? fullModelPath : null,
texturePath: fullTexturePath && !basePlayerAssets.has(fullTexturePath) ? fullTexturePath : null,
gradientSetId,
colorId,
gradientTexturePath: gradientTexturePath ? `Common/${gradientTexturePath}` : null,
Expand Down Expand Up @@ -361,3 +405,23 @@ export function getRequiredAssetPaths(resolvedSkin: ResolvedSkin): {
gradients: [...gradients],
};
}

export function getCosmeticJsonBytes(): { path: string; bytes: Uint8Array; }[] {
const reusableTextEncoder = new TextEncoder();
return COSMETIC_FILES.map(({ path, content }) => {
return {
path,
bytes: reusableTextEncoder.encode(typeof content === 'string' ? content : JSON.stringify(content)),
};
});
}

export function getCosmeticJson<T>(path: string): T {
// Return this as as an object
const content = COSMETIC_FILES.find(file => file.path === path)?.content;
if (!content) {
console.warn(`Cosmetic JSON not found: ${path}`);
return {} as T;
}
return JSON.parse(typeof content === 'string' ? content : JSON.stringify(content));
}
71 changes: 34 additions & 37 deletions src/worker/services/hytale/service.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import { createHash } from 'node:crypto';

import pLimit from 'p-limit';

import * as hytaleApi from './api';
import { loadHytaleAssets } from './assets';
import { getRequiredAssetPaths, resolveSkin } from './cosmetic-registry';
import {
type SkinDefinition,
getCosmeticJson,
getCosmeticJsonBytes,
getRequiredAssetPaths,
resolveSkin,
} from './cosmetic-registry';
import { render_hytale_3d, render_text_avatar } from '../../../../pkg/mcavatar';
import { EMPTY } from '../../data';
import { IdentityKind, RequestedKind } from '../../request';
Expand All @@ -14,14 +22,16 @@ import {
uuidVersion,
} from '../../util/uuid';

import type { HytaleProfile } from './api';
import type { HytaleProfile, HytaleSkin } from './api';
import type { CraftheadRequest } from '../../request';
import type { CacheComputeResult } from '../../util/cache-helper';

const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours in milliseconds

function getRenderCacheKey(request: CraftheadRequest): string {
return `renders/${request.requested}/${request.armored}/${request.model || 'regular'}/${request.identity.toLowerCase()}/${request.size}`;
function getRenderCacheKey(skin: HytaleSkin): string {
// Hash the skin to generate a unique key per unique skin combo (so we can share renders with multiple users)
const hash = createHash('sha256').update(JSON.stringify(skin)).digest('hex');
return `renders/${hash}`;
}

async function getCachedRender(cacheKey: string, env: Cloudflare.Env): Promise<Response | null> {
Expand Down Expand Up @@ -160,67 +170,54 @@ function generateAndReturnTextAvatar(username: string, request: CraftheadRequest
* Uses R2 caching for 24 hours to reduce computational cost.
*/
export async function renderAvatar(incomingRequest: Request, request: CraftheadRequest, env: Cloudflare.Env, ctx: ExecutionContext): Promise<Response> {
const cacheKey = getRenderCacheKey(request);

const cachedRender = await getCachedRender(cacheKey, env);
if (cachedRender) {
return cachedRender;
}

const { profile } = await normalizeRequest(incomingRequest, request);
const username = profile?.name ?? request.identity;
if (!profile?.skin) {
// TODO: Replace with a deterministic skin generator
return generateAndReturnTextAvatar(username, request);
}
const cacheKey = getRenderCacheKey(profile.skin);

const cachedRender = await getCachedRender(cacheKey, env);
if (cachedRender) {
return cachedRender;
}

try {
// Load bundled Hytale assets (base model and animation)
const assets = await loadHytaleAssets();
const skinDefinitionJson = getCosmeticJson<SkinDefinition[]>('Cosmetics/CharacterCreator/BodyCharacteristics.json');
// Look for their skin type
const skinType = profile.skin.bodyCharacteristic.split('.')[0] ?? 'Default';
const skinPath = skinDefinitionJson.find(skin => skin.Id === skinType)?.GreyscaleTexture ?? 'Common/Characters/Player_Textures/Player_Greyscale.png';
const assets = await loadHytaleAssets(skinPath, ctx);

const resolvedSkin = await resolveSkin(profile.skin);
const assetPaths: string[] = [];
const assetBytes: Uint8Array[] = [];

const requiredAssets = getRequiredAssetPaths(resolvedSkin);

const assetSet = new Set<string>([
const skinSpecificAssetSet = new Set<string>([
...requiredAssets.models,
...requiredAssets.textures,
...requiredAssets.gradients,
'Cosmetics/CharacterCreator/HaircutFallbacks.json',
'Cosmetics/CharacterCreator/Faces.json',
'Cosmetics/CharacterCreator/Eyes.json',
'Cosmetics/CharacterCreator/Eyebrows.json',
'Cosmetics/CharacterCreator/Mouths.json',
'Cosmetics/CharacterCreator/Ears.json',
'Cosmetics/CharacterCreator/Haircuts.json',
'Cosmetics/CharacterCreator/FacialHair.json',
'Cosmetics/CharacterCreator/Underwear.json',
'Cosmetics/CharacterCreator/FaceAccessory.json',
'Cosmetics/CharacterCreator/Capes.json',
'Cosmetics/CharacterCreator/EarAccessory.json',
'Cosmetics/CharacterCreator/Gloves.json',
'Cosmetics/CharacterCreator/HeadAccessory.json',
'Cosmetics/CharacterCreator/GradientSets.json',
'Cosmetics/CharacterCreator/Overpants.json',
'Cosmetics/CharacterCreator/Overtops.json',
'Cosmetics/CharacterCreator/Pants.json',
'Cosmetics/CharacterCreator/Shoes.json',
'Cosmetics/CharacterCreator/SkinFeatures.json',
'Cosmetics/CharacterCreator/Undertops.json',
]);

for (const { path, bytes } of getCosmeticJsonBytes()) {
assetPaths.push(`assets/Common/${path}`);
assetBytes.push(bytes);
}

const limit = pLimit(5);
const assetPromises = [...assetSet].map(async (assetPath) => {
const data = await limit(() => readAssetFile(assetPath, env));
const skinAssetPromises = [...skinSpecificAssetSet].map(async (assetPath) => {
const data = await limit(() => readAssetFile(assetPath, env, ctx));
const providerPath = assetPath.startsWith('Common/')
? `assets/${assetPath}`
: `assets/Common/${assetPath}`;
return { providerPath, bytes: new Uint8Array(data) };
});

const results = await Promise.all(assetPromises);
const results = await Promise.all(skinAssetPromises);
for (const result of results) {
assetPaths.push(result.providerPath);
assetBytes.push(result.bytes);
Expand Down
24 changes: 21 additions & 3 deletions src/worker/util/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
* in Cloudflare Workers while still supporting local development.
*/

export async function generateCacheKey(filePath: string): Promise<string> {
return `https://crafthead.net/_hytale_assets/${filePath}`;
}

/**
* Reads an asset file from R2 (production) or disk (local development)
*
Expand All @@ -17,19 +21,33 @@
export async function readAssetFile(
filePath: string,
env: Cloudflare.Env,
ctx: ExecutionContext,
): Promise<ArrayBuffer> {
// There is intentionally no fallback to disk here. You should just upload the files to b2 locally.
// There's a script: ./scripts/upload-assets-to-r2.ts, re-enable the debug endpoint and run it to upload the assets.
const cacheKey = await generateCacheKey(filePath);
// Check to see if it's already cached and if so, return the cached response
const cachedResponse = await caches.default.match(new Request(cacheKey));
if (cachedResponse) {
return cachedResponse.arrayBuffer();
}
// Check if R2 binding is available (production)
if (env.HYTALE_ASSETS) {
const object = await env.HYTALE_ASSETS.get(filePath);
if (!object) {
console.log('No object for R2', filePath);
throw new Error(`Asset file not found in R2: ${filePath}`);
}
console.log('serving asset from R2', filePath);
return object.arrayBuffer();
const arrayBuffer = await object.arrayBuffer();
const cachedResponse = new Response(arrayBuffer, {
headers: {
'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream',
'Cache-Control': 'max-age=604800', // 7 days
},
});
ctx.waitUntil(caches.default.put(new Request(cacheKey), cachedResponse));
return arrayBuffer;
}

throw new Error(`Asset file not found in R2: ${filePath}`);
throw new Error(`Asset file not found: ${filePath}`);
}
Loading