From 04377ea06aefa05e9fa5b0c72a7ef05626c9eca7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Haylee=20Sch=C3=A4fer?= Date: Sat, 17 Oct 2026 15:52:42 +0200 Subject: [PATCH 1/2] Load entity models from the nearest published version The entity-models dataset is published for a few versions only (1.16.5, 1.17.1, 1.20.1, 1.21.11, 26.1.2). For any other version, block entities such as the bell body, chests and signs fell back to their block models, and special items could not find their geometry. Entities.getDatasetRoot now probes /entity-models/version.json and, for an unpublished release, selects the nearest published one from entity-models/versions.json with the same rule as MineRenderData.resolve. Model files, entity lists, animations and the block-entity index load from that root; textures keep the requested version. Snapshots keep their own version. The shared selection code moves to DatasetVersions.ts. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- packages/minerender/reference/concepts.md | 2 + .../minerender/src/assets/BlockEntities.ts | 2 + .../minerender/src/assets/DatasetVersions.ts | 24 +++++++ packages/minerender/src/assets/Entities.ts | 34 ++++++++- .../minerender/src/assets/MineRenderData.ts | 30 +++----- packages/minerender/src/cache/Caching.ts | 2 + .../test/AssetCacheRecovery.test.ts | 4 +- .../minerender/test/BlockEntities.test.ts | 11 +-- .../minerender/test/EntityAnimation.test.ts | 1 + .../minerender/test/EntityLoading.test.ts | 70 +++++++++++++++++-- .../minerender/test/SavedEntities.test.ts | 2 + 12 files changed, 147 insertions(+), 37 deletions(-) create mode 100644 packages/minerender/src/assets/DatasetVersions.ts diff --git a/AGENTS.md b/AGENTS.md index 9a207615..87b51641 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,7 +88,7 @@ The private Yarn workspace root contains the public library and its consumers. U **Skins** (`src/skin/`): `SkinObject` uses vanilla part dimensions, UVs, and joint pivots. The player faces +Z as in V1, with its own right side on -X, so a negative X rotation raises a limb forward. PNG decoding preserves hidden RGB before base-layer opacity is enforced; overlay alpha is retained. Await `setSkinTexture` to load and detect the layout/model; legacy skins are normalized to a square atlas. `setSlim` updates arms in place, preserving named parts, poses, materials, and visibility. PNG model detection is a heuristic; explicit `slim`/`legacy` options or setters override detection; pass `undefined` to a setter to restore it. Await `setCapeTexture(src, layout)` for a `minecraft` (default), `optifine`, or `labymod` cape; pass `undefined` to remove it. `Skins.capeFromCapesDev` resolves a name or UUID to a full static texture URL. The named `cape` group follows the torso; the group and mesh support posing and visibility. Geometry and prepared skin/cape materials are shared cache entries. -**Entities** (`src/entity/`): `Entities.getEntity` loads a versioned `entity-models` file. By default it selects `main` plus the dataset `passes` that have no `when` label or whose label is in `options.when` (`getPassList` lists them); `layer`/`layers` select exactly the named layers in order, without passes. `textures` overrides are keyed like `EntityModel.layers`, where a further draw of the same geometry is `#` from 2. `getLayerList` lists available names. Each draw uses the material for its dataset `render` mode (`Materials.createEntityCanvasMaterial`), cached per texture, mode, and tint; modes that vanilla culls get no inward faces. `EntityObject` `tints` maps pass tint labels to colours. `energy_swirl` and `breeze_wind` materials are owned by their object: `Ticker` advances `EntityObject.age` and the texture offset while the object has a parent; removal pauses it and reattachment resumes it. `EntityObject.getLayerGroup` and layer-scoped `getGroupByName`/`getMeshByName` expose each layer’s visibility and poses. Parts form a nested `Object3D` hierarchy with radian poses, inherited texture dimensions, cube growth, and mirrored box UVs. The entity root applies the dataset's `transform` (vanilla's renderer operations for the default state: living entities face +Z with their feet at the origin); models without one get scale `(-1, -1, 1)`. Nested child nodes hold this transform, one per operation, so every operation order is exact and caller position, rotation, and scale apply on top of it. `flip: true` forces that plain flip and `flip: false` keeps raw model space. Without an explicit texture key, use dataset `textureLocation` first, then the resolver (model path, a same-named texture in its directory, then variant data); `getBlock` and `getBlockList` are deprecated aliases. `src/bedrock/` contains type declarations only. +**Entities** (`src/entity/`): `Entities.getEntity` loads a versioned `entity-models` file. By default it selects `main` plus the dataset `passes` that have no `when` label or whose label is in `options.when` (`getPassList` lists them); `layer`/`layers` select exactly the named layers in order, without passes. `textures` overrides are keyed like `EntityModel.layers`, where a further draw of the same geometry is `#` from 2. `getLayerList` lists available names. Each draw uses the material for its dataset `render` mode (`Materials.createEntityCanvasMaterial`), cached per texture, mode, and tint; modes that vanilla culls get no inward faces. `EntityObject` `tints` maps pass tint labels to colours. `energy_swirl` and `breeze_wind` materials are owned by their object: `Ticker` advances `EntityObject.age` and the texture offset while the object has a parent; removal pauses it and reattachment resumes it. `EntityObject.getLayerGroup` and layer-scoped `getGroupByName`/`getMeshByName` expose each layer’s visibility and poses. Parts form a nested `Object3D` hierarchy with radian poses, inherited texture dimensions, cube growth, and mirrored box UVs. The entity root applies the dataset's `transform` (vanilla's renderer operations for the default state: living entities face +Z with their feet at the origin); models without one get scale `(-1, -1, 1)`. Nested child nodes hold this transform, one per operation, so every operation order is exact and caller position, rotation, and scale apply on top of it. `flip: true` forces that plain flip and `flip: false` keeps raw model space. Without an explicit texture key, use dataset `textureLocation` first, then the resolver (model path, a same-named texture in its directory, then variant data); `getBlock` and `getBlockList` are deprecated aliases. `Entities.getDatasetRoot` loads geometry, lists, animations, and the block index from the nearest published release when the exact dataset version is missing; textures keep the requested root. `src/bedrock/` contains type declarations only. **World** (`src/world/`): `MineRenderWorld` is an early prototype with sparse chunks keyed by signed coordinates. Cubic 16³ `Chunk`s store `ChunkData` palettes with immutable states and recycled unused entries, 4,096 `Uint16` state indices, and per-cell NBT. Render handles and stable `BlockInfo` views remain separate; `.block` returns detached snapshots. Block coordinates are integers; chunk-local coordinates are 0–15. World and owned-chunk setters refresh `cullface` masks after edits, including across chunk boundaries; only opaque full-cube models occlude neighbors. The versioned `blockCulling` dataset lists vanilla's further face rules, applied in both rendering modes: glass, ice, slime and similar blocks hide faces toward the same block, mangrove roots toward the same block above or below, and connected panes or bars toward each other. Leaves keep their faces, as vanilla's cutout leaves do; data without `blockCulling` applies no such rule. `new MineRenderWorld(scene, { sectionMeshing: true })` merges models of any shape, including cutout, translucent, multipart, and animated models, plus fluid surfaces into per-section meshes with separate solid, translucent, and fluid atlas pages (at most 2048×2048 pixels by default; `maxAtlasSize`), with one mesh per page. Page selection uses texture alpha (`hasTranslucency`), so plain glass and panes use the solid/cutout page where vanilla uses the translucent layer. Translucent quads within a section are not depth-sorted. Per-position flower offsets remain unsupported. Only block entities keep individual objects; only opaque full cubes occlude neighbors. Animated atlases occupy their own pages and redraw on frame changes. Fluid surfaces use an 18×18×18 cell neighborhood and are built in the section worker when available. The section worker entry must stay free of three, DOM, and Node imports, so pure geometry code lives in `SectionGeometry.ts` and `FluidQuads.ts`. Merged `BlockInfo.object` values are `undefined`; edit through world/chunk setters. Replacing, removing, or clearing blocks releases their instance slots. `await world.unloadChunkColumn(x, z)` removes every section at absolute chunk-column coordinates and refreshes neighboring faces and fluids. `await world.unloadChunkColumns([{ x, z }, ...])` validates, copies, and deduplicates a group before removal, then refreshes surviving faces, fluids, lighting, AO, and biome colors once. `await world.setBlockVisibleAt(position, visible)` preserves block data and reveals neighboring faces in both rendering modes; replacement or removal resets visibility. Without an executor, parsed columns copy each section's palette into the chunk, resolve each palette entry once, and fill cells in one synchronous pass; `placeMultiBlock` groups blocks per chunk and resolves each distinct state once per group, with weighted variants chosen per cell and yields after about 8 ms of work. Passing a `BatchedExecutor` keeps the previous per-position batches, order at repeated positions, and wait-for-active-batch failure semantics. Bulk placement applies saved properties before model creation and flushes neighbor culling once, including after partial failure; single-block edits update neighbors immediately. Parsed column placement and unloading mark whole sections and neighboring sections' shared boundary cells dirty; structure placement and single edits queue chunk-local indices, and the culling drain recomputes masks in a flat pass per section and rebuilds each changed section once through `SectionMesh.buildAsync`, which runs the pure `buildSectionGeometry` (`SectionGeometry.ts`, typed arrays in and out) in the browser section worker when `Env.provider.createWorker` returns one and synchronously otherwise; a rebuild superseded by a newer rebuild or `clear()` is disposed. `BatchedExecutor.submit` returns a task promise; `stop()` rejects queued and future tasks while active tasks finish. Idle executors hold no timer; caller-provided executors remain caller-owned. Saved biomes supply grass, foliage, dry-foliage, and per-cell water colors in both rendering modes. Saved lighting, corner ambient occlusion, and biome blending are opt-in; light propagation, exact partial-shape ambient occlusion, and LOD remain unsupported. The demos in `apps/web/` exercise `MineRenderWorld`. diff --git a/packages/minerender/reference/concepts.md b/packages/minerender/reference/concepts.md index a315d505..6cc130df 100644 --- a/packages/minerender/reference/concepts.md +++ b/packages/minerender/reference/concepts.md @@ -53,6 +53,8 @@ Pass an explicit asset root as the second argument to `MineRenderData.get(datase The former synchronous data lookups now return promises: `ItemDefaults.get`, `ItemTints.getPotionList`, `getBlockFluidState`, `getFluidKind`, and `resolveLegacyBlock`. For repeated synchronous calculations, load `fluids` or `legacyBlocks` once with `MineRenderData.get` and pass the table to `resolveBlockFluidState` or `resolveLegacyBlockState`. Rendering and parser entry points await their data automatically. `BannerPatterns.getColors(root?)` provides versioned texture dyes; `DYE_COLORS` and `BannerPatterns.getColor` retain the fixed 1.21.11 palette for compatibility and are deprecated. +Entity and block-entity geometry from `entity-models/` use the same version selection when the exact dataset version is missing. This also applies to entity lists, animations, and the block-entity index. An unpublished snapshot keeps its own version and draws block entities with their block models. Textures keep the requested asset version. + ## Redrawing after changes [Renderer](/api/index/classes/Renderer) draws only when the scene is dirty, `render.renderAlways` is enabled, an `onFrame` subscription is active, or a video is recording. Calling `start()` runs this loop; it does not force an unchanged scene to redraw continuously. diff --git a/packages/minerender/src/assets/BlockEntities.ts b/packages/minerender/src/assets/BlockEntities.ts index d1931e30..e976fa90 100644 --- a/packages/minerender/src/assets/BlockEntities.ts +++ b/packages/minerender/src/assets/BlockEntities.ts @@ -1,5 +1,6 @@ import { Matrix4 } from "three"; import { AssetKey } from "./AssetKey"; +import { Entities } from "./Entities"; import { AssetLoader } from "./AssetLoader"; import { AssetParser } from "./source"; import { Caching } from "../cache/Caching"; @@ -58,6 +59,7 @@ export class BlockEntities { key.rootType = undefined!; const index = await Caching.blockEntityIndexCache.get(key.serialize(), async () => { try { + key.root = await Entities.getDatasetRoot(root); return await AssetLoader.get(key, AssetParser.JSON) ?? {}; } catch (error) { log.warn("Could not load the block entity index; block entities are drawn as plain block models", error); diff --git a/packages/minerender/src/assets/DatasetVersions.ts b/packages/minerender/src/assets/DatasetVersions.ts new file mode 100644 index 00000000..d0658c4b --- /dev/null +++ b/packages/minerender/src/assets/DatasetVersions.ts @@ -0,0 +1,24 @@ +/** Parses release versions; snapshots and other names yield undefined. */ +export const parseRelease = (value: string): number[] | undefined => /^\d+\.\d+(?:\.\d+)?$/.test(value) ? value.split(".").map(Number) : undefined; +const compare = (left: number[], right: number[]): number => { + for (let i = 0; i < 3; i++) { + const difference = (left[i] ?? 0) - (right[i] ?? 0); + if (difference) return difference; + } + return 0; +}; + +/** Selects the nearest patch (older on ties), then the latest earlier release or oldest published release. */ +export const nearestRelease = (requestedVersion: string, published: readonly string[]): string | undefined => { + const requested = parseRelease(requestedVersion); + if (!requested) return undefined; + const versions = published.map(name => ({ name, number: parseRelease(name) })) + .filter((entry): entry is { name: string; number: number[] } => !!entry.number) + .sort((left, right) => compare(left.number, right.number)); + const sameLine = versions.filter(entry => entry.number[0] === requested[0] && entry.number[1] === requested[1]); + const selected = sameLine.sort((left, right) => Math.abs((left.number[2] ?? 0) - (requested[2] ?? 0)) + - Math.abs((right.number[2] ?? 0) - (requested[2] ?? 0)) || compare(left.number, right.number))[0] + ?? versions.filter(entry => compare(entry.number, requested) <= 0).pop() + ?? versions[0]; + return selected?.name; +}; diff --git a/packages/minerender/src/assets/Entities.ts b/packages/minerender/src/assets/Entities.ts index b2b0851e..1bebeaf7 100644 --- a/packages/minerender/src/assets/Entities.ts +++ b/packages/minerender/src/assets/Entities.ts @@ -1,6 +1,7 @@ import { AssetKey, BasicAssetKey, isAssetKey } from "./AssetKey"; import type { EntityLayer, EntityModel, EntityModelFile, EntityModelPass } from "../entity/EntityModel"; import type { EntityAnimation, EntityAnimationFile } from "../entity/EntityAnimation"; +import { parseRelease, nearestRelease } from "./DatasetVersions"; import { AssetLoader } from "./AssetLoader"; import { AssetParser } from "./source"; import { Maybe } from "../util"; @@ -14,11 +15,38 @@ import type { MinecraftAsset } from "../MinecraftAsset"; /** Loads entity and block-entity geometry, texture selections, and animation clips from the dataset. */ export class Entities { + /** + * Resolves `root` (default: the active version). Uses the exact dataset when published or when the + * version is not a release; otherwise selects the nearest published release, like {@link MineRenderData.resolve}. + * Textures keep the requested root. + */ + public static async getDatasetRoot(root?: string): Promise> { + const requestRoot = (root ?? AssetLoader.ROOT).replace(/\/+$/, ""); + const requestedVersion = requestRoot.slice(requestRoot.lastIndexOf("/") + 1); + const base = requestRoot.slice(0, requestRoot.lastIndexOf("/")); + const selected = await Caching.entityDatasetVersionCache.get(root ?? `sources:${AssetLoader.ROOT}`, async () => { + const key = new AssetKey("entity-models", "version", undefined, undefined, undefined, ".json", root); + key.rootType = undefined!; + if (await AssetLoader.get(key, AssetParser.JSON) !== undefined || !parseRelease(requestedVersion)) return requestedVersion; + const indexKey = new AssetKey("entity-models", "versions", undefined, undefined, undefined, ".json", base); + indexKey.rootType = undefined!; + const index = await AssetLoader.get(indexKey, AssetParser.JSON); + if (index === undefined) return requestedVersion; + if (!index || typeof index !== "object" || Array.isArray(index) || !Array.isArray(index.versions) + || !index.versions.every(entry => entry && typeof entry === "object" && !Array.isArray(entry) && typeof entry.name === "string")) { + throw new MineRenderError(`Invalid entity model version index at ${base}/entity-models/versions.json`); + } + return nearestRelease(requestedVersion, index.versions.map(entry => entry.name)) ?? requestedVersion; + }); + return selected === requestedVersion ? root : `${base}/${selected}`; + } + /** Lists model paths in the `minecraft` namespace, including subdirectories and excluding `.json`. */ public static async getEntityList(): Promise { + const root = await this.getDatasetRoot(); const collect = async (path: string): Promise => { const prefix = path ? `${path}/` : ""; - const key = new AssetKey(DEFAULT_NAMESPACE, `${prefix}_list`, undefined, undefined, "entity-models", ".json"); + const key = new AssetKey(DEFAULT_NAMESPACE, `${prefix}_list`, undefined, undefined, "entity-models", ".json", root); const list = await Caching.listAssetCache.get(key.serialize(), () => AssetLoader.get(key, AssetParser.LIST)); if (!list) return []; const files = list.files.filter(file => file.endsWith(".json") && file !== "_list.json") @@ -31,7 +59,7 @@ export class Entities { private static async getModelFile(modelKey: BasicAssetKey): Promise> { const path = isAssetKey(modelKey) ? modelKey.getFullPath() : modelKey.path; - const key = new AssetKey(modelKey.namespace, path, undefined, undefined, "entity-models", ".json", isAssetKey(modelKey) ? modelKey.root : undefined); + const key = new AssetKey(modelKey.namespace, path, undefined, undefined, "entity-models", ".json", await this.getDatasetRoot(isAssetKey(modelKey) ? modelKey.root : undefined)); return Caching.entityModelCache.get(key.serialize(), () => AssetLoader.get(key, AssetParser.JSON)); } @@ -101,7 +129,7 @@ export class Entities { */ public static async getAnimations(modelKey: BasicAssetKey): Promise>> { const path = isAssetKey(modelKey) ? modelKey.getFullPath() : modelKey.path; - const key = new AssetKey(modelKey.namespace, path, undefined, undefined, "entity-models/animations", ".json", isAssetKey(modelKey) ? modelKey.root : undefined); + const key = new AssetKey(modelKey.namespace, path, undefined, undefined, "entity-models/animations", ".json", await this.getDatasetRoot(isAssetKey(modelKey) ? modelKey.root : undefined)); const file = await Caching.entityAnimationCache.get(key.serialize(), () => AssetLoader.get(key, AssetParser.JSON)); return file?.animations; } diff --git a/packages/minerender/src/assets/MineRenderData.ts b/packages/minerender/src/assets/MineRenderData.ts index b74f51af..fd3de344 100644 --- a/packages/minerender/src/assets/MineRenderData.ts +++ b/packages/minerender/src/assets/MineRenderData.ts @@ -1,3 +1,4 @@ +import { parseRelease, nearestRelease } from "./DatasetVersions"; import { AssetKey } from "./AssetKey"; import { AssetLoader } from "./AssetLoader"; import { AssetParser } from "./source/parser/AssetParsers"; @@ -48,14 +49,6 @@ export interface ResolvedMineRenderData { } const object = (value: unknown): value is Record => value !== null && typeof value === "object" && !Array.isArray(value); -const release = (value: string): number[] | undefined => /^\d+\.\d+(?:\.\d+)?$/.test(value) ? value.split(".").map(Number) : undefined; -const compare = (left: number[], right: number[]): number => { - for (let i = 0; i < 3; i++) { - const difference = (left[i] ?? 0) - (right[i] ?? 0); - if (difference) return difference; - } - return 0; -}; /** Loads extracted Minecraft registries from the active asset sources and version. */ export class MineRenderData { @@ -88,9 +81,9 @@ export class MineRenderData { const scope = AssetLoader.persistentScope; return this.cached(`resolve:${requestRoot === undefined ? "sources" : "explicit"}:${root}`, scope, defaultRoot, async () => { const requestedVersion = root.slice(root.lastIndexOf("/") + 1); - let manifest = await this.manifest(root, requestRoot !== undefined && release(requestedVersion) ? requestedVersion : undefined, requestRoot); + let manifest = await this.manifest(root, requestRoot !== undefined && parseRelease(requestedVersion) ? requestedVersion : undefined, requestRoot); if (manifest) return { requestedVersion, version: manifest.minecraftVersion, root, manifest }; - const requested = release(requestedVersion); + const requested = parseRelease(requestedVersion); if (!requested) { throw new Error(`No MineRender data is published for ${requestedVersion}; automatic fallback requires a release`); } @@ -103,19 +96,12 @@ export class MineRenderData { } return value.versions as { name: string }[]; }); - const versions = index.map(entry => ({ name: entry.name, number: release(entry.name) })) - .filter((entry): entry is { name: string; number: number[] } => !!entry.number) - .sort((left, right) => compare(left.number, right.number)); - const sameLine = versions.filter(entry => entry.number[0] === requested[0] && entry.number[1] === requested[1]); - const selected = sameLine.sort((left, right) => Math.abs((left.number[2] ?? 0) - (requested[2] ?? 0)) - - Math.abs((right.number[2] ?? 0) - (requested[2] ?? 0)) || compare(left.number, right.number))[0] - ?? versions.filter(entry => compare(entry.number, requested) <= 0).pop() - ?? versions[0]; + const selected = nearestRelease(requestedVersion, index.map(entry => entry.name)); if (!selected) throw new Error(`No compatible MineRender data is published for ${requestedVersion}`); - const resolvedRoot = `${base}/${selected.name}`; - manifest = await this.manifest(resolvedRoot, selected.name, resolvedRoot); - if (!manifest) throw new Error(`Missing MineRender manifest for published version ${selected.name}`); - return { requestedVersion, version: selected.name, root: resolvedRoot, manifest }; + const resolvedRoot = `${base}/${selected}`; + manifest = await this.manifest(resolvedRoot, selected, resolvedRoot); + if (!manifest) throw new Error(`Missing MineRender manifest for published version ${selected}`); + return { requestedVersion, version: selected, root: resolvedRoot, manifest }; }); } diff --git a/packages/minerender/src/cache/Caching.ts b/packages/minerender/src/cache/Caching.ts index 20cbbe0b..62d2a4b8 100644 --- a/packages/minerender/src/cache/Caching.ts +++ b/packages/minerender/src/cache/Caching.ts @@ -117,6 +117,7 @@ export class Caching { static readonly entityAnimationCache: AsyncLoadingCache = Caching.createAssetCache(); static readonly blockEntityIndexCache: AsyncLoadingCache = Caching.createAssetCache(); + static readonly entityDatasetVersionCache: AsyncLoadingCache = Caching.createAssetCache(); static readonly unihexCache: AsyncLoadingCache = Caching.createAssetCache(); static readonly fontCache: AsyncLoadingCache = Caching.createAssetCache(); static readonly bannerPatternCache: AsyncLoadingCache = Caching.createAssetCache(); @@ -159,6 +160,7 @@ export class Caching { this.entityTextureCache, this.entityAnimationCache, this.blockEntityIndexCache, + this.entityDatasetVersionCache, this.fontCache, this.unihexCache, this.bannerPatternCache, diff --git a/packages/minerender/test/AssetCacheRecovery.test.ts b/packages/minerender/test/AssetCacheRecovery.test.ts index ff090478..d83588e6 100644 --- a/packages/minerender/test/AssetCacheRecovery.test.ts +++ b/packages/minerender/test/AssetCacheRecovery.test.ts @@ -26,7 +26,8 @@ for (const fixture of cases) { let reject!: (error: Error) => void; const pending = new Promise((_, rejectPromise) => { reject = rejectPromise; }); let calls = 0; - AssetLoader.get = (async () => { + AssetLoader.get = (async (key: AssetKey) => { + if (key.namespace === "entity-models" && key.path === "version") return { id: AssetLoader.version }; calls++; if (calls === 1) return pending; return calls === 2 ? undefined : fixture.asset; @@ -34,6 +35,7 @@ for (const fixture of cases) { const first = fixture.load(); const second = fixture.load(); + await new Promise(resolve => setImmediate(resolve)); t.is(calls, 1); const failures = [t.throwsAsync(first, { is: failure }), t.throwsAsync(second, { is: failure })]; reject(failure); diff --git a/packages/minerender/test/BlockEntities.test.ts b/packages/minerender/test/BlockEntities.test.ts index 8d9f84e8..9fba95ad 100644 --- a/packages/minerender/test/BlockEntities.test.ts +++ b/packages/minerender/test/BlockEntities.test.ts @@ -60,24 +60,25 @@ test.serial("the block index is cached per root and read from next to the namesp AssetLoader.addSource("test", new StubSource((key, parser) => { t.is(parser, AssetParser.JSON); keys.push(key); + if (key.path === "version") return { id: "1.21.11" }; return key.root === "https://example.test/old" ? undefined : index; })); AssetLoader.ROOT = "https://example.test/1.21.11"; t.is(await BlockEntities.getIndex(), index); t.is(await BlockEntities.getIndex(), index); - t.is(keys.length, 1); + t.is(keys.length, 2); const hosted = new HostedAssetSource(AssetLoader.ROOT); - t.is(hosted.assetBasePath(keys[0]) + keys[0].path + keys[0].extension, "https://example.test/1.21.11/entity-models/blocks.json"); + t.is(hosted.assetBasePath(keys[1]) + keys[1].path + keys[1].extension, "https://example.test/1.21.11/entity-models/blocks.json"); // Versions without an index have no block entities, and are asked only once. t.deepEqual(await BlockEntities.getIndex("https://example.test/old"), {}); t.deepEqual(await BlockEntities.getIndex("https://example.test/old"), {}); - t.is(keys.length, 2); - t.is(hosted.assetBasePath(keys[1]), "https://example.test/old/entity-models/"); + t.is(keys.length, 4); + t.is(hosted.assetBasePath(keys[3]), "https://example.test/old/entity-models/"); AssetLoader.ROOT = "https://example.test/1.21.10"; await BlockEntities.getIndex(); - t.is(keys.length, 3); + t.is(keys.length, 6); }); test.serial("a failed index load yields no block entities instead of an error", async t => { diff --git a/packages/minerender/test/EntityAnimation.test.ts b/packages/minerender/test/EntityAnimation.test.ts index 71a847c1..cf84ccd5 100644 --- a/packages/minerender/test/EntityAnimation.test.ts +++ b/packages/minerender/test/EntityAnimation.test.ts @@ -94,6 +94,7 @@ test.serial("animations load from the dataset's animation tree per version and a AssetLoader.addSource("test", new StubSource((key, parser) => { t.is(parser, AssetParser.JSON); t.is(key.assetType, undefined); + if (key.path === "version") return { id: "published" }; const root = key.root ?? AssetLoader.ROOT; requests.push(`${ root }/${ key.rootType }/${ key.namespace }/${ key.path }${ key.extension }`); return root !== "https://example.test/1.18.2" && key.path === "warden" ? file : undefined; diff --git a/packages/minerender/test/EntityLoading.test.ts b/packages/minerender/test/EntityLoading.test.ts index db11cecd..e6e6a929 100644 --- a/packages/minerender/test/EntityLoading.test.ts +++ b/packages/minerender/test/EntityLoading.test.ts @@ -1,6 +1,7 @@ import test from "ava"; import { AssetKey, BasicAssetKey } from "../src/assets/AssetKey"; import { AssetLoader } from "../src/assets/AssetLoader"; +import { BlockEntities } from "../src/assets/BlockEntities"; import { Entities } from "../src/assets/Entities"; import { ModelTextures } from "../src/assets/ModelTextures"; import { Env, EnvProvider } from "../src/Env"; @@ -76,7 +77,7 @@ test.serial("entity files preserve nested IDs and share layers while explicit te t.is(saddle!.key, texture); t.is(saddle!.layer, file.layers.saddle); t.deepEqual(await Entities.getBlock(key, texture, { layer: "saddle" }), saddle); - t.deepEqual(keys, [new AssetKey("custom", "boat/oak", undefined, undefined, "entity-models", ".json")]); + t.deepEqual(keys.slice(1), [new AssetKey("custom", "boat/oak", undefined, undefined, "entity-models", ".json")]); t.is(lowerCalls, 0); t.deepEqual(file, original); }); @@ -87,13 +88,14 @@ test.serial("parsed entity and texture keys retain their complete paths", async const keys: AssetKey[] = []; AssetLoader.addSource("test", new StubSource(key => { keys.push(key); + if (key.path === "version") return { id: "1.21.11" }; return key.namespace === "custom" && key.path === "boat/oak" ? file : undefined; })); const key = AssetKey.parse("entities", "custom:boat/oak"); const entity = await Entities.getEntity(key, key); const primary = { key, texture: new AssetKey("custom", "boat/oak", "textures", "entity", "assets", ".png"), layer: file.layers.main }; t.deepEqual(entity, { ...primary, id: file.id, layers: { main: primary } }); - t.deepEqual(keys, [new AssetKey("custom", "boat/oak", undefined, undefined, "entity-models", ".json")]); + t.deepEqual(keys.slice(1), [new AssetKey("custom", "boat/oak", undefined, undefined, "entity-models", ".json")]); const textured = await Entities.getEntity(key, AssetKey.parse("textures", "painted:boat/checkered")); t.deepEqual(textured!.texture, new AssetKey("painted", "boat/checkered", "textures", "entity", "assets", ".png")); const rootedTexture = new AssetKey("painted", "boat/checkered", "textures", "entity", "assets", ".png", "https://pack.example/custom"); @@ -103,7 +105,7 @@ test.serial("parsed entity and texture keys retain their complete paths", async test.serial("the dataset's root transform is passed on with the entity", async t => { const file: EntityModelFile = { ...model(), id: "minecraft:chest", transform: [{ scale: [-1, -1, 1] }] }; - AssetLoader.addSource("test", new StubSource(key => key.path === "chest" ? file : undefined)); + AssetLoader.addSource("test", new StubSource(key => key.path === "version" ? { id: "1.21.11" } : key.path === "chest" ? file : undefined)); const entity = await Entities.getEntity(new BasicAssetKey("minecraft", "chest"), new BasicAssetKey("minecraft", "chest")); t.deepEqual(entity!.transform, [{ scale: [-1, -1, 1] }]); }); @@ -128,7 +130,7 @@ test.serial("selected layer texture locations skip probing and explicit textures const texture = new AssetKey("custom", "cow/checkered", "textures", "entity", "assets", ".png"); const explicit = await Entities.getEntity(key, texture, { layer: "saddle" }); t.is(explicit!.texture, texture); - t.deepEqual(keys, [key]); + t.deepEqual(keys.slice(1), [key]); t.is(decodedImages, 0); }); @@ -139,6 +141,7 @@ test.serial("selected layers retain their order and dataset textures while named const original = structuredClone(file); let requests = 0; AssetLoader.addSource("test", new StubSource((key, parser) => { + if (key.path === "version") return file; t.is(key.rootType, "entity-models"); t.is(parser, AssetParser.JSON); requests++; @@ -174,6 +177,7 @@ test.serial("layer discovery shares the entity file cache without loading textur const file = model(); const requests: string[] = []; AssetLoader.addSource("test", new StubSource((key, parser) => { + if (key.path === "version") return { id: "1.21.11" }; t.is(key.rootType, "entity-models"); t.is(parser, AssetParser.JSON); requests.push(key.path); @@ -200,7 +204,7 @@ test.serial("layer discovery shares the entity file cache without loading textur test.serial("missing layers identify the model and available layers without selecting a substitute", async t => { const file = model(); delete file.layers.main; - AssetLoader.addSource("test", new StubSource(key => key.path === "cow" ? file : undefined)); + AssetLoader.addSource("test", new StubSource(key => key.path === "version" ? { id: "1.21.11" } : key.path === "cow" ? file : undefined)); const key = new BasicAssetKey("minecraft", "cow"); await t.throwsAsync(Entities.getEntity(key), { message: 'Entity minecraft:cow has no layer "main". Available layers: saddle' }); await t.throwsAsync(Entities.getEntity(key, undefined, { layer: "armor" }), { @@ -218,6 +222,7 @@ test.serial("entity lists recurse through dataset directories and return bare ID "boat/cargo/_list": { files: ["bamboo.json"], directories: [] } }; AssetLoader.addSource("test", new StubSource((key, parser) => { + if (key.path === "version") return { id: "1.21.11" }; t.is(key.namespace, "minecraft"); t.is(key.rootType, "entity-models"); t.is(key.assetType, undefined); @@ -254,10 +259,13 @@ test.serial("version changes fetch entity files and resolved textures from the s t.is(custom!.texture!.root, explicitRoot.root); t.is(decodedImages, 3); t.deepEqual(urls, [ + "https://assets.mcasset.cloud/1.21.11/entity-models/version.json", "https://assets.mcasset.cloud/1.21.11/entity-models/minecraft/cow.json", "https://assets.mcasset.cloud/1.21.11/assets/minecraft/textures/entity/cow.png", + "https://assets.mcasset.cloud/1.20.1/entity-models/version.json", "https://assets.mcasset.cloud/1.20.1/entity-models/minecraft/cow.json", "https://assets.mcasset.cloud/1.20.1/assets/minecraft/textures/entity/cow.png", + "https://pack.example/custom/entity-models/version.json", "https://pack.example/custom/entity-models/minecraft/cow.json", "https://pack.example/custom/assets/minecraft/textures/entity/cow.png" ]); @@ -275,6 +283,7 @@ test.serial("layers without texture locations use direct or nested textures and const texture = new AssetKey("minecraft", path, "textures", "entity", "assets", ".png", "https://pack.example/custom"); AssetLoader.addSource("test", new StubSource((key, parser) => { t.is(key.root, texture.root); + if (key.path === "version") return file; if (key.rootType === "entity-models") return file; t.is(parser, AssetParser.IMAGE); calls.push(key.toNamespacedString()); @@ -303,6 +312,7 @@ test.serial("variant textures prefer temperate, otherwise the first file, and su const root = "https://pack.example/custom"; AssetLoader.addSource("test", new StubSource((key, parser) => { t.is(key.root, root); + if (key.path === "version") return model(); if (key.rootType === "entity-models") return model(); if (parser === AssetParser.IMAGE) { calls.push(key.toNamespacedString()); @@ -322,3 +332,53 @@ test.serial("variant textures prefer temperate, otherwise the first file, and su t.is(calls.at(-1), entry.texture); } }); + +test.serial("unpublished releases use published entity geometry while textures keep the requested root", async t => { + AssetLoader.ROOT = "https://example.test/26.3"; + const keys: AssetKey[] = []; + const file = model(); + file.layers.main.textureLocation = "minecraft:textures/entity/bell/bell_body.png"; + const index = { "minecraft:bell": { parts: [{ model: "minecraft:bell" }] } }; + AssetLoader.addSource("test", new StubSource(key => { + keys.push(key); + if (key.path === "versions") return { versions: [{ name: "26.1.2" }, { name: "1.21.11" }] }; + if (key.root !== "https://example.test/26.1.2") return undefined; + return key.path === "blocks" ? index : file; + })); + const entity = (await Entities.getEntity(new AssetKey("minecraft", "bell", undefined, undefined, "entity-models", ".json", "https://example.test/26.3")))!; + t.is(keys.find(key => key.path === "bell")!.root, "https://example.test/26.1.2"); + t.is(entity.layers!.main.texture!.root, "https://example.test/26.3"); + t.is(await BlockEntities.getIndex("https://example.test/26.3"), index); + t.is(keys.filter(key => key.path === "version").length, 1); + t.is(keys.filter(key => key.path === "versions").length, 1); +}); + +test.serial("published versions and snapshots never request the published version list", async t => { + AssetLoader.ROOT = "https://example.test/26.3"; + const keys: AssetKey[] = []; + AssetLoader.addSource("test", new StubSource(key => { + keys.push(key); + if (key.path === "version") return key.root?.endsWith("26w14a") ? undefined : { id: "26.3" }; + return model(); + })); + await Entities.getLayerList(new BasicAssetKey("minecraft", "bell")); + Caching.clear(); + await Entities.getLayerList(new AssetKey("minecraft", "bell", undefined, undefined, "entity-models", ".json", AssetLoader.ROOT)); + t.deepEqual(keys.filter(key => key.path === "bell").map(key => key.root), [undefined, AssetLoader.ROOT]); + t.is(await Entities.getDatasetRoot("https://example.test/26w14a"), "https://example.test/26w14a"); + t.false(keys.some(key => key.path === "versions")); +}); + +test.serial("missing version indexes keep the root and malformed indexes reject with an optional block index", async t => { + AssetLoader.ROOT = "https://example.test/26.3"; + const warn = console.warn; + console.warn = () => undefined; + t.teardown(() => console.warn = warn); + let versions: unknown; + AssetLoader.addSource("test", new StubSource(key => key.path === "versions" ? versions : undefined)); + t.is(await Entities.getDatasetRoot(AssetLoader.ROOT), AssetLoader.ROOT); + versions = { versions: [{ name: 42 }] }; + Caching.clear(); + await t.throwsAsync(Entities.getDatasetRoot(AssetLoader.ROOT), { message: /Invalid entity model version index/ }); + t.deepEqual(await BlockEntities.getIndex(), {}); +}); diff --git a/packages/minerender/test/SavedEntities.test.ts b/packages/minerender/test/SavedEntities.test.ts index 3c0b8ebb..f1fae2e8 100644 --- a/packages/minerender/test/SavedEntities.test.ts +++ b/packages/minerender/test/SavedEntities.test.ts @@ -3,6 +3,7 @@ import { installMineRenderDataFixtures } from "./helpers/minerender-data"; import test, { ExecutionContext } from "ava"; import { BoxGeometry, Mesh, MeshBasicMaterial, Object3D, Raycaster, Vector3 } from "three"; import type { Compound } from "prismarine-nbt"; +import { AssetLoader } from "../src/assets/AssetLoader"; import { AssetKey } from "../src/assets/AssetKey"; import { BlockStates } from "../src/assets/BlockStates"; import { MineRenderData } from "../src/assets/MineRenderData"; @@ -205,6 +206,7 @@ async function appearanceFixture(t: ExecutionContext) { Entities.getEntity = getEntity; Caching.clear(); t.teardown(() => Caching.clear()); + await Caching.entityDatasetVersionCache.get(`sources:${AssetLoader.ROOT}`, async () => AssetLoader.version); for (const model of ["sheep", "fox", "axolotl", "parrot"]) { const layers = model === "sheep" ? ["main", "wool_undercoat", "wool"] : ["main"]; const file: EntityModelFile = { From 35543beac4a50e5d8ba745c4a55113800daca8b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Haylee=20Sch=C3=A4fer?= Date: Sat, 17 Oct 2026 16:57:29 +0200 Subject: [PATCH 2/2] Keep dataset version helpers internal and skip the probe for other roots DatasetVersions moves into an underscore directory, so the generated barrels never export parseRelease and nearestRelease. Roots whose last segment isn't a release, such as snapshots or custom hosts, skip the version.json request, since its answer can't change the result. Co-Authored-By: Claude Opus 5.5 --- packages/minerender/reference/concepts.md | 2 +- packages/minerender/src/assets/Entities.ts | 4 ++-- packages/minerender/src/assets/MineRenderData.ts | 2 +- .../src/assets/{ => _versions}/DatasetVersions.ts | 0 packages/minerender/test/BlockEntities.test.ts | 6 +++--- packages/minerender/test/EntityLoading.test.ts | 3 +-- 6 files changed, 8 insertions(+), 9 deletions(-) rename packages/minerender/src/assets/{ => _versions}/DatasetVersions.ts (100%) diff --git a/packages/minerender/reference/concepts.md b/packages/minerender/reference/concepts.md index 6cc130df..de7aec72 100644 --- a/packages/minerender/reference/concepts.md +++ b/packages/minerender/reference/concepts.md @@ -53,7 +53,7 @@ Pass an explicit asset root as the second argument to `MineRenderData.get(datase The former synchronous data lookups now return promises: `ItemDefaults.get`, `ItemTints.getPotionList`, `getBlockFluidState`, `getFluidKind`, and `resolveLegacyBlock`. For repeated synchronous calculations, load `fluids` or `legacyBlocks` once with `MineRenderData.get` and pass the table to `resolveBlockFluidState` or `resolveLegacyBlockState`. Rendering and parser entry points await their data automatically. `BannerPatterns.getColors(root?)` provides versioned texture dyes; `DYE_COLORS` and `BannerPatterns.getColor` retain the fixed 1.21.11 palette for compatibility and are deprecated. -Entity and block-entity geometry from `entity-models/` use the same version selection when the exact dataset version is missing. This also applies to entity lists, animations, and the block-entity index. An unpublished snapshot keeps its own version and draws block entities with their block models. Textures keep the requested asset version. +Entity and block-entity geometry from `entity-models/` uses the same version selection when the exact dataset version is missing. This also applies to entity lists, animations, and the block-entity index. An unpublished snapshot keeps its own version and draws block entities with their block models. Textures keep the requested asset version. ## Redrawing after changes diff --git a/packages/minerender/src/assets/Entities.ts b/packages/minerender/src/assets/Entities.ts index 1bebeaf7..4105a07b 100644 --- a/packages/minerender/src/assets/Entities.ts +++ b/packages/minerender/src/assets/Entities.ts @@ -1,7 +1,7 @@ import { AssetKey, BasicAssetKey, isAssetKey } from "./AssetKey"; import type { EntityLayer, EntityModel, EntityModelFile, EntityModelPass } from "../entity/EntityModel"; import type { EntityAnimation, EntityAnimationFile } from "../entity/EntityAnimation"; -import { parseRelease, nearestRelease } from "./DatasetVersions"; +import { parseRelease, nearestRelease } from "./_versions/DatasetVersions"; import { AssetLoader } from "./AssetLoader"; import { AssetParser } from "./source"; import { Maybe } from "../util"; @@ -27,7 +27,7 @@ export class Entities { const selected = await Caching.entityDatasetVersionCache.get(root ?? `sources:${AssetLoader.ROOT}`, async () => { const key = new AssetKey("entity-models", "version", undefined, undefined, undefined, ".json", root); key.rootType = undefined!; - if (await AssetLoader.get(key, AssetParser.JSON) !== undefined || !parseRelease(requestedVersion)) return requestedVersion; + if (!parseRelease(requestedVersion) || await AssetLoader.get(key, AssetParser.JSON) !== undefined) return requestedVersion; const indexKey = new AssetKey("entity-models", "versions", undefined, undefined, undefined, ".json", base); indexKey.rootType = undefined!; const index = await AssetLoader.get(indexKey, AssetParser.JSON); diff --git a/packages/minerender/src/assets/MineRenderData.ts b/packages/minerender/src/assets/MineRenderData.ts index fd3de344..6621c4cc 100644 --- a/packages/minerender/src/assets/MineRenderData.ts +++ b/packages/minerender/src/assets/MineRenderData.ts @@ -1,4 +1,4 @@ -import { parseRelease, nearestRelease } from "./DatasetVersions"; +import { parseRelease, nearestRelease } from "./_versions/DatasetVersions"; import { AssetKey } from "./AssetKey"; import { AssetLoader } from "./AssetLoader"; import { AssetParser } from "./source/parser/AssetParsers"; diff --git a/packages/minerender/src/assets/DatasetVersions.ts b/packages/minerender/src/assets/_versions/DatasetVersions.ts similarity index 100% rename from packages/minerender/src/assets/DatasetVersions.ts rename to packages/minerender/src/assets/_versions/DatasetVersions.ts diff --git a/packages/minerender/test/BlockEntities.test.ts b/packages/minerender/test/BlockEntities.test.ts index 9fba95ad..148c9162 100644 --- a/packages/minerender/test/BlockEntities.test.ts +++ b/packages/minerender/test/BlockEntities.test.ts @@ -73,12 +73,12 @@ test.serial("the block index is cached per root and read from next to the namesp // Versions without an index have no block entities, and are asked only once. t.deepEqual(await BlockEntities.getIndex("https://example.test/old"), {}); t.deepEqual(await BlockEntities.getIndex("https://example.test/old"), {}); - t.is(keys.length, 4); - t.is(hosted.assetBasePath(keys[3]), "https://example.test/old/entity-models/"); + t.is(keys.length, 3); + t.is(hosted.assetBasePath(keys[2]), "https://example.test/old/entity-models/"); AssetLoader.ROOT = "https://example.test/1.21.10"; await BlockEntities.getIndex(); - t.is(keys.length, 6); + t.is(keys.length, 5); }); test.serial("a failed index load yields no block entities instead of an error", async t => { diff --git a/packages/minerender/test/EntityLoading.test.ts b/packages/minerender/test/EntityLoading.test.ts index e6e6a929..489575b6 100644 --- a/packages/minerender/test/EntityLoading.test.ts +++ b/packages/minerender/test/EntityLoading.test.ts @@ -130,7 +130,7 @@ test.serial("selected layer texture locations skip probing and explicit textures const texture = new AssetKey("custom", "cow/checkered", "textures", "entity", "assets", ".png"); const explicit = await Entities.getEntity(key, texture, { layer: "saddle" }); t.is(explicit!.texture, texture); - t.deepEqual(keys.slice(1), [key]); + t.deepEqual(keys, [key]); t.is(decodedImages, 0); }); @@ -265,7 +265,6 @@ test.serial("version changes fetch entity files and resolved textures from the s "https://assets.mcasset.cloud/1.20.1/entity-models/version.json", "https://assets.mcasset.cloud/1.20.1/entity-models/minecraft/cow.json", "https://assets.mcasset.cloud/1.20.1/assets/minecraft/textures/entity/cow.png", - "https://pack.example/custom/entity-models/version.json", "https://pack.example/custom/entity-models/minecraft/cow.json", "https://pack.example/custom/assets/minecraft/textures/entity/cow.png" ]);