Phase 13: real non-cube block models via blockstate/model JSON resolution

Adds worker/src/models.rs (parent-chain blockstate/model resolution,
texture-variable substitution reusing Phase 12's atlas keys), routes
non-cube blocks through new per-element mesh emission in mesh.rs while
leaving full-cube blocks on the existing cube mesher, and threads a
per-server ModelContext (vanilla worker-wide + modded per-job) through
main.rs. Modded model JSON is stored in a new block_models Postgres
table and read alongside the existing (previously write-only)
block_registry table. Fixes a pre-existing face-culling bug as a side
effect of excluding non-cube voxels from the cube mesher's input.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015tKdPZt78zbPUZMXWzKEKt
This commit is contained in:
2026-08-10 01:19:13 +02:00
parent 6ad8051eae
commit a7b69bd038
13 changed files with 1069 additions and 15 deletions
+54
View File
@@ -163,6 +163,60 @@ blockstate/model JSON parsing (`BlockAssetExtractor`'s texture matching is still
convention guess, not a real model resolution) — renumbered to **Phase 13** once the atlas/UV
scope above turned out to be its own full phase; Thaumcraft remains the named stress test for it.
### Real (non-cube) block models (Phase 13)
`worker/src/models.rs` is a real blockstate/model JSON resolver: given a block's registry name, it
picks a variant, walks the model's `parent` chain (merging `textures` maps as it goes, child
overrides win), and resolves each element's face textures down to the same atlas-lookup key
`atlas.rs` already uses (Phase 12) — so a resolved non-cube model's faces are textured with zero
atlas-side changes. `ResolvedModel::is_full_cube()` tells `mesh.rs` whether a block still belongs
on the existing cube greedy-mesher (untouched — lower risk, and re-rendering a plain cube through
the new per-element path would be pure overhead) or needs its own per-element geometry.
Two model sources, deliberately split:
- **Vanilla**: extracted from the same Mojang client jar Phase 11/12 already download (cached to
disk under its own `vanilla-<version>-models.json`, a third instance of the same accepted
"duplicate download, cached after first build" tradeoff as the palette/atlas), worker-wide.
- **Modded**: shipped by the mod over a new `block_models` WS message (mirrors Phase 11's
`block_textures`, batched the same way) and stored per-server in a new `block_models` Postgres
table (`server_id, kind, name, json`, `kind` distinguishing a blockstate entry from a model entry
since both are keyed by resource-location-shaped strings that could otherwise collide) — fetched
fresh per chunk-job by the worker, alongside the already-existing `block_registry` table (Phase
11 wrote it but nothing ever read it back until now). A modded model's `parent` can point at a
vanilla base model (e.g. `"minecraft:block/cross"`) via `ModelRegistry::resolve`'s `fallback`
parameter — common in practice, since plenty of modded blocks just extend a vanilla shape.
**Deliberately out of scope**, documented in `models.rs`'s doc comments rather than silently
dropped:
- **No `multipart` blockstates** (fences, walls, redstone wire, glass panes) — there's no way to
know a block's neighbor-dependent connection state from this project's raw block-id/meta data
model, so a multipart-only blockstate resolves to `None` and the block falls back to a flat cube,
same as pre-Phase-13.
- **No property-based variant selection** — chunk data here only ever carries a numeric `meta`
(legacy) or a truncated packed state id (modern, per Phase 10), never named property strings, so
`resolve()` always picks a deterministic representative variant (the `""` key if present, else
alphabetically first) rather than the "correct" one for a given block's actual state.
- **No per-face UV rectangle or per-variant rotation** — element geometry (`from`/`to`) is real,
but face texturing reuses the same tile-relative "UV span in block units" scheme the cube mesher
already uses, not the model's literal declared UV rect, to avoid needing per-pixel atlas
remapping/a more complex shader.
- Weighted multi-model variant lists always take the first entry (weights ignored); `meta` is never
consulted for modded block resolution, since `block_registry` only carries `block_id -> name`
(no per-state granularity) — a real, pre-existing schema constraint, not new to this phase.
A genuine pre-existing rendering bug got fixed as a side effect: `compute_face_masks` treated *any*
non-zero block as solid for neighbor face-culling, so a torch (or any non-cube block) sitting next
to a solid block incorrectly culled that solid block's adjacent face. Excluding non-cube-resolved
voxels from the cube mesher's input array (needed anyway, for Phase 13's own correctness) fixes
this for free — see `mesh.rs`'s `a_non_cube_neighbor_no_longer_incorrectly_culls_an_adjacent_solid_faces`
test.
Verified via real `cargo build`/`cargo test --lib` (worker, 65/65 passing, up from 52) and a real
`./gradlew :forge-1_12_2:compileJava` against the actual legacy ForgeGradle toolchain for the mod
side. `api`'s new `models.test.ts` (mirrors `textures.test.ts`'s pattern) was written but **not run
against a live Postgres** — Docker isn't available in this dev environment, the same honestly-
documented gap Phase 11 already carries; `bunx tsc --noEmit` is clean.
## Running
```
+8
View File
@@ -0,0 +1,8 @@
CREATE TABLE IF NOT EXISTS "block_models" (
"server_id" uuid NOT NULL REFERENCES "servers"("id") ON DELETE CASCADE,
"kind" text NOT NULL,
"name" text NOT NULL,
"json" text NOT NULL,
"updated_at" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("server_id", "kind", "name")
);
+23
View File
@@ -194,6 +194,29 @@ export const blockTextures = pgTable(
(table) => [primaryKey({ columns: [table.serverId, table.name] })],
);
// Phase 13: raw blockstate/model JSON text the mod extracted off its own classloader, mirroring
// `blockRegistry`/`blockTextures`'s "written by the api from the mod's WS connection, read later
// by the worker" shape — stored as plain text (not MinIO) since these are small JSON snippets, not
// binary blobs, matching `blockRegistry`'s own text-in-Postgres precedent. `kind` distinguishes a
// blockstate (`name` = the block's own registry name, e.g. `"thaumcraft:blockcustomplant"`) from a
// model (`name` = the model's own resource location, e.g. `"thaumcraft:block/customplant"`, as
// referenced by a blockstate variant's `"model"` field or another model's `"parent"`) since both
// are keyed by resource-location strings that could otherwise collide. See worker/src/models.rs's
// `ModelRegistry` for how these get parsed and resolved into real element geometry.
export const blockModels = pgTable(
"block_models",
{
serverId: uuid("server_id")
.notNull()
.references(() => servers.id, { onDelete: "cascade" }),
kind: text("kind").notNull(),
name: text("name").notNull(),
json: text("json").notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => [primaryKey({ columns: [table.serverId, table.kind, table.name] })],
);
// Metadata pointer to a rendered tile PNG in MinIO — the binary itself never touches Postgres.
// One row per (server, dimension, zoom, tileX, tileZ); zoom is always 0 until Phase 2 adds
// multi-resolution tiles.
+62
View File
@@ -0,0 +1,62 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { and, eq } from "drizzle-orm";
import { db } from "./db/client";
import { blockModels } from "./db/schema";
import { storeBlockModels } from "./models";
import { createTestServer, deleteTestServer } from "./test-helpers";
describe("storeBlockModels", () => {
let server: { id: string };
beforeAll(async () => {
server = await createTestServer("block-models");
});
afterAll(async () => {
await deleteTestServer(server.id);
});
test("upserts blockstate and model entries, keyed by (serverId, kind, name)", async () => {
await storeBlockModels(server.id, [
{ kind: "blockstate", name: "thaumcraft:blockcustomplant", json: '{"variants":{}}' },
{ kind: "model", name: "thaumcraft:block/customplant", json: '{"elements":[]}' },
]);
const rows = await db.select().from(blockModels).where(eq(blockModels.serverId, server.id));
expect(rows.length).toBe(2);
expect(rows.find((r) => r.kind === "blockstate")?.name).toBe("thaumcraft:blockcustomplant");
expect(rows.find((r) => r.kind === "model")?.json).toBe('{"elements":[]}');
});
test("a blockstate and a model can share the same name without colliding (kind is part of the key)", async () => {
await storeBlockModels(server.id, [
{ kind: "blockstate", name: "thaumcraft:foo", json: '{"a":1}' },
{ kind: "model", name: "thaumcraft:foo", json: '{"b":2}' },
]);
const rows = await db
.select()
.from(blockModels)
.where(and(eq(blockModels.serverId, server.id), eq(blockModels.name, "thaumcraft:foo")));
expect(rows.length).toBe(2);
});
test("re-sending the same (kind, name) with new JSON overwrites, not duplicates", async () => {
await storeBlockModels(server.id, [{ kind: "model", name: "thaumcraft:block/customplant", json: '{"v":1}' }]);
await storeBlockModels(server.id, [{ kind: "model", name: "thaumcraft:block/customplant", json: '{"v":2}' }]);
const rows = await db
.select()
.from(blockModels)
.where(
and(
eq(blockModels.serverId, server.id),
eq(blockModels.kind, "model"),
eq(blockModels.name, "thaumcraft:block/customplant"),
),
);
expect(rows.length).toBe(1);
expect(rows[0]!.json).toBe('{"v":2}');
});
test("an empty list is a no-op, not an error", async () => {
await expect(storeBlockModels(server.id, [])).resolves.toBeUndefined();
});
});
+27
View File
@@ -0,0 +1,27 @@
import { sql } from "drizzle-orm";
import { db } from "./db/client";
import { blockModels } from "./db/schema";
export interface BlockModelEntry {
kind: "blockstate" | "model";
name: string;
json: string;
}
/**
* Upserts the mod's raw blockstate/model JSON dump for one server (Phase 13) — sent once per
* connection, same lifecycle as `storeBlockRegistry`/`storeBlockTextures` (see textures.ts):
* stable for a world's lifetime, no need to resend on a timer. Read by the worker's
* `models::ModelRegistry` (via new `fetch_block_models`/`fetch_block_registry` queries) to resolve
* a modded block's real (possibly non-cube) shape — see worker/src/models.rs's doc comment.
*/
export async function storeBlockModels(serverId: string, entries: BlockModelEntry[]) {
if (entries.length === 0) return;
await db
.insert(blockModels)
.values(entries.map((e) => ({ serverId, kind: e.kind, name: e.name, json: e.json })))
.onConflictDoUpdate({
target: [blockModels.serverId, blockModels.kind, blockModels.name],
set: { json: sql.raw("excluded.json"), updatedAt: new Date() },
});
}
+17 -3
View File
@@ -6,6 +6,7 @@ import { storeLinkCode } from "./link";
import { recordAndPublishChat } from "./chat";
import { arePlayerPositionsVisible, publishPlayerPositions, type PlayerPosition } from "./players";
import { storeBlockRegistry, storeBlockTextures, type BlockRegistryEntry, type BlockTextureEntry } from "./textures";
import { storeBlockModels, type BlockModelEntry } from "./models";
// Wire protocol (mod <-> api), one JSON object per WS text frame:
//
@@ -62,15 +63,22 @@ import { storeBlockRegistry, storeBlockTextures, type BlockRegistryEntry, type B
//
// mod -> api {"type":"block_registry","entries":[{"id":4000,"name":"botania:manapool"}]}
// mod -> api {"type":"block_textures","textures":[{"name":"botania:manapool","dataBase64":"..."}]}
// mod -> api {"type":"block_models","entries":[{"kind":"blockstate","name":"botania:manapool","json":"..."},{"kind":"model","name":"botania:block/manapool","json":"..."}]}
//
// Phase 11: sent once after `hello_ack` (a world's numeric block-id assignments and mod-jar
// contents are both stable for the server's lifetime, so there's no need to resend on a timer).
// `block_registry` is the numeric-id -> registry-name mapping needed to make sense of
// `chunkColumns.blockId`/`blockMeta` for modded blocks (see MCMapperMod.java's connect-time
// registry dump); `block_textures` is the mod's best-effort classloader extraction of each
// block's texture PNG (see textures.ts's doc comment for storage). Both are stored now but not
// yet read by the render pipeline — see worker/README's Phase 11 note on the deferred per-server
// palette-resolution work this unlocks.
// block's texture PNG (see textures.ts's doc comment for storage). Both are read by the worker's
// per-job model resolution as of Phase 13 (`block_registry` for the id->name lookup); the worker's
// 2D/atlas texture-palette resolution itself is still process-wide only — see worker/README.
//
// Phase 13: `block_models` is the mod's raw blockstate/model JSON dump (see models.ts's doc
// comment) — `kind` distinguishes a blockstate entry (`name` = the block's own registry name)
// from a model entry (`name` = the model's resource location, as referenced by a variant's
// `"model"` or another model's `"parent"`). Same "sent once, stable for the world's lifetime"
// cadence as the other two.
interface Column {
x: number;
@@ -244,6 +252,12 @@ export const wsGateway = {
await storeBlockTextures(state.serverId, textures);
return;
}
if (msg.type === "block_models") {
const entries: BlockModelEntry[] = msg.entries ?? [];
await storeBlockModels(state.serverId, entries);
return;
}
},
close(ws: any) {
+33
View File
@@ -53,6 +53,26 @@ pub fn texture_name(block_id: u16, meta: u8) -> Option<&'static str> {
}
}
/// Maps a handful of well-known **non-cube** vanilla blocks to their real blockstate registry
/// name (see `models.rs`), so `mesh.rs` can render their actual shape instead of a flat cube.
/// Deliberately small and hand-picked, not a full 1-256 vanilla id table: this is a demo of the
/// Phase 13 model-resolution pipeline working end to end on real game data, not an attempt at
/// exhaustive vanilla coverage — expanding it is just adding more match arms, no design changes
/// needed. Cube-shaped blocks (including ones covered by `texture_name` above) are deliberately
/// absent: `models::ResolvedModel::is_full_cube` would just send them right back to the existing
/// greedy cube mesher, so there's no point resolving them through this path at all.
pub fn vanilla_model_name(block_id: u16, meta: u8) -> Option<&'static str> {
match block_id {
50 => Some("minecraft:torch"),
53 => Some("minecraft:oak_stairs"),
65 => Some("minecraft:ladder"),
85 => Some("minecraft:oak_fence"),
37 => Some("minecraft:dandelion"),
38 if meta == 0 => Some("minecraft:poppy"),
_ => None,
}
}
fn wool_texture(meta: u8) -> &'static str {
match meta {
0 => "white_wool",
@@ -102,4 +122,17 @@ mod tests {
fn unmapped_block_returns_none() {
assert_eq!(texture_name(9999, 0), None);
}
#[test]
fn vanilla_model_name_covers_the_hand_picked_non_cube_demo_blocks() {
assert_eq!(vanilla_model_name(50, 0), Some("minecraft:torch"));
assert_eq!(vanilla_model_name(85, 0), Some("minecraft:oak_fence"));
assert_eq!(vanilla_model_name(9999, 0), None);
}
#[test]
fn cube_shaped_blocks_are_absent_from_the_non_cube_table() {
assert_eq!(vanilla_model_name(1, 0), None); // stone
assert_eq!(vanilla_model_name(4, 0), None); // cobblestone
}
}
+36
View File
@@ -64,6 +64,42 @@ pub async fn fetch_chunk_sections(
Ok(rows)
}
/// Phase 13: the mod's numeric-blockId -> registry-name dump for one server (see api's
/// `textures.ts`/`ws-gateway.ts`) — read here so `models::resolve_for_block` can look up a
/// modded voxel's real registry name (`meta` carries no equivalent per-server data; see
/// `models::ModelRegistry::resolve`'s doc comment for why that's an accepted gap, not an
/// oversight).
#[derive(sqlx::FromRow)]
pub struct BlockRegistryRow {
pub block_id: i32,
pub name: String,
}
pub async fn fetch_block_registry(pool: &PgPool, server_id: Uuid) -> anyhow::Result<Vec<BlockRegistryRow>> {
let rows = sqlx::query_as::<_, BlockRegistryRow>(r#"SELECT block_id, name FROM block_registry WHERE server_id = $1"#)
.bind(server_id)
.fetch_all(pool)
.await?;
Ok(rows)
}
/// Phase 13: the mod's raw blockstate/model JSON dump for one server (see api's `models.ts`) —
/// `kind` is `"blockstate"` or `"model"`, matching `models::ModelRegistry::build`'s two input maps.
#[derive(sqlx::FromRow)]
pub struct BlockModelRow {
pub kind: String,
pub name: String,
pub json: String,
}
pub async fn fetch_block_models(pool: &PgPool, server_id: Uuid) -> anyhow::Result<Vec<BlockModelRow>> {
let rows = sqlx::query_as::<_, BlockModelRow>(r#"SELECT kind, name, json FROM block_models WHERE server_id = $1"#)
.bind(server_id)
.fetch_all(pool)
.await?;
Ok(rows)
}
#[allow(clippy::too_many_arguments)]
pub async fn upsert_mesh_pointer(
pool: &PgPool,
+1
View File
@@ -3,6 +3,7 @@ pub mod block_names;
pub mod config;
pub mod db;
pub mod mesh;
pub mod models;
pub mod palette;
pub mod render;
pub mod storage;
+55 -3
View File
@@ -1,8 +1,9 @@
use std::collections::hash_map::DefaultHasher;
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use base64::{engine::general_purpose::STANDARD, Engine as _};
use mcmapper_worker::{atlas, config, db, mesh, storage, textures};
use mcmapper_worker::{atlas, config, db, mesh, models, storage, textures};
use rayon::prelude::*;
use redis::streams::{StreamReadOptions, StreamReadReply};
use redis::AsyncCommands;
@@ -124,6 +125,24 @@ async fn main() -> anyhow::Result<()> {
"[worker] failed to build texture atlas, 3D meshes will use flat vertex colors only: {err:#}"
),
}
// Phase 13: the vanilla half of the model registry — extracted from the same client jar,
// worker-wide like the palette/atlas above (see models.rs's doc comment). The modded half
// is per-server and fetched fresh per chunk-job below (see fetch_chunk_data), since
// block_registry/block_models are genuinely server-scoped data, unlike the vanilla jar.
match models::load_or_build(std::path::Path::new(&cache_dir), &mc_version).await {
Ok(registry) => {
println!(
"[worker] vanilla model registry ready ({} blockstates, {} models)",
registry.blockstate_count(),
registry.model_count()
);
mcmapper_worker::render::set_vanilla_model_registry(registry);
}
Err(err) => eprintln!(
"[worker] failed to build vanilla model registry, non-cube vanilla blocks will render as flat cubes: {err:#}"
),
}
} else {
println!("[worker] ACCEPT_MINECRAFT_EULA not set — using hand-picked palette colors (see README)");
}
@@ -267,6 +286,13 @@ struct ChunkData {
job: ChunkJob,
columns: Vec<db::StoredColumn>,
sections: Vec<db::StoredSection>,
/// Phase 13: this job's server-scoped modded model data (empty registry/map if the server has
/// never had a mod ship any, which is every server before its mod adopts `block_models` — see
/// the Mod repo's `BlockAssetExtractor`). Fetched fresh per job rather than cached across
/// batches: simplest correct thing, and mirrors this stage's existing "one DB round trip per
/// job" shape (see `columns`/`sections` above) rather than a new batch-level cache.
modded_models: models::ModelRegistry,
modded_id_to_name: HashMap<u16, String>,
}
async fn fetch_chunk_data(pool: &sqlx::PgPool, job: &ChunkJob) -> anyhow::Result<ChunkData> {
@@ -274,7 +300,30 @@ async fn fetch_chunk_data(pool: &sqlx::PgPool, job: &ChunkJob) -> anyhow::Result
db::fetch_chunk_columns(pool, job.server_id, job.dimension, job.chunk_x, job.chunk_z).await?;
let sections =
db::fetch_chunk_sections(pool, job.server_id, job.dimension, job.chunk_x, job.chunk_z).await?;
Ok(ChunkData { job: job.clone(), columns, sections })
let registry_rows = db::fetch_block_registry(pool, job.server_id).await.unwrap_or_default();
let modded_id_to_name: HashMap<u16, String> = registry_rows
.into_iter()
.filter_map(|r| u16::try_from(r.block_id).ok().map(|id| (id, r.name)))
.collect();
let model_rows = db::fetch_block_models(pool, job.server_id).await.unwrap_or_default();
let mut blockstate_files = HashMap::new();
let mut model_files = HashMap::new();
for row in model_rows {
match row.kind.as_str() {
"blockstate" => {
blockstate_files.insert(row.name, row.json);
}
"model" => {
model_files.insert(row.name, row.json);
}
_ => {}
}
}
let modded_models = models::ModelRegistry::build(&blockstate_files, &model_files);
Ok(ChunkData { job: job.clone(), columns, sections, modded_models, modded_id_to_name })
}
struct RenderedMesh {
@@ -325,6 +374,9 @@ fn render_chunk(data: &ChunkData, backend: &dyn RenderBackend) -> anyhow::Result
let png_bytes = backend.rasterize_tile(&pixels)?;
let tile_content_hash = content_hash(&png_bytes);
let model_ctx =
mcmapper_worker::render::model_context(Some(&data.modded_models), data.modded_id_to_name.clone());
let mut meshes = Vec::new();
for section in &data.sections {
let blocks = match decode_blocks(&section.blocks) {
@@ -337,7 +389,7 @@ fn render_chunk(data: &ChunkData, backend: &dyn RenderBackend) -> anyhow::Result
continue;
}
};
let mesh_buf = mesh::mesh_section(&blocks, backend);
let mesh_buf = mesh::mesh_section(&blocks, backend, Some(&model_ctx));
if mesh_buf.is_empty() {
continue;
}
+196 -9
View File
@@ -1,5 +1,7 @@
use std::collections::HashMap;
use crate::palette::color_for_textured;
use crate::render::RenderBackend;
use crate::render::{ModelContext, RenderBackend};
const SIZE: i32 = 16;
@@ -109,9 +111,43 @@ fn axis_pos(axis: usize, layer: i32, u: i32, v: i32) -> (i32, i32, i32) {
/// quads. The per-voxel visibility extraction may have run on GPU; this merge/compaction step is
/// always CPU — it's sequential and branchy (each cell's fate depends on what its neighbors in
/// the same pass already claimed), not a good GPU-parallel fit.
pub fn mesh_section(blocks: &[u16; 4096], backend: &dyn RenderBackend) -> MeshBuffers {
let face_masks = backend.compute_face_masks(blocks);
///
/// Phase 13: `models`, if given, is consulted per non-air voxel to resolve its real shape (see
/// `models::ModelRegistry::resolve`). Voxels that resolve to a genuinely non-cube model are
/// excluded from `blocks` before it's handed to the cube mesher (so they neither get a spurious
/// flat-cube quad of their own, nor incorrectly count as a "solid neighbor" that culls an
/// adjacent block's face — both fixed as a side effect of the same exclusion) and instead get
/// their own per-element box geometry emitted directly, unmerged, via `emit_non_cube_elements`.
/// Voxels that resolve to a full cube (`ResolvedModel::is_full_cube`) are deliberately left alone
/// — the existing cube path already renders them correctly via `block_names::texture_name`.
pub fn mesh_section(blocks: &[u16; 4096], backend: &dyn RenderBackend, models: Option<&ModelContext>) -> MeshBuffers {
let mut buf = MeshBuffers::default();
let mut cube_blocks = *blocks;
if let Some(models) = models {
// Memoized per distinct packed block value (block_id<<4|meta) — a section can easily
// repeat the same non-cube block (a run of torches, a fence line) hundreds of times, and
// re-walking the same blockstate/model parent chain for each instance is wasted work.
let mut cache: HashMap<u16, Option<crate::models::ResolvedModel>> = HashMap::new();
for (i, &block) in blocks.iter().enumerate() {
if block == 0 {
continue;
}
let resolved = cache
.entry(block)
.or_insert_with(|| models.resolve(block >> 4, (block & 0xF) as u8))
.clone();
let Some(resolved) = resolved else { continue };
if resolved.is_full_cube() {
continue;
}
cube_blocks[i] = 0;
let (vx, vy, vz) = ((i % 16) as f32, (i / 256) as f32, ((i / 16) % 16) as f32);
emit_non_cube_elements(&resolved, block >> 4, (block & 0xF) as u8, vx, vy, vz, &mut buf);
}
}
let face_masks = backend.compute_face_masks(&cube_blocks);
let mut face_index = 0;
for axis in 0..3 {
for &dir in &[-1i32, 1i32] {
@@ -244,6 +280,99 @@ fn emit_quad(
}
}
/// Emits one resolved model's element boxes at voxel `(vx, vy, vz)` (section-local, block units)
/// directly into `buf` — each element is its own standalone quad set, never greedy-merged with
/// neighbors (unlike the cube path), since non-cube geometry rarely tiles cleanly and merging it
/// would need real per-shape adjacency logic this phase doesn't attempt. `color` is the block's
/// existing flat vertex-color fallback (same `color_for_textured` lookup the cube path uses,
/// keyed by block id/meta — not a per-face-texture color, so every face of one block shares it).
fn emit_non_cube_elements(
model: &crate::models::ResolvedModel,
block_id: u16,
block_meta: u8,
vx: f32,
vy: f32,
vz: f32,
buf: &mut MeshBuffers,
) {
let [r, g, b] = color_for_textured(block_id, block_meta, crate::render::texture_palette());
let color = [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0];
for element in &model.elements {
let from = [element.from[0] / 16.0, element.from[1] / 16.0, element.from[2] / 16.0];
let to = [element.to[0] / 16.0, element.to[1] / 16.0, element.to[2] / 16.0];
for (face_idx, texture) in element.faces.iter().enumerate() {
let Some(texture) = texture else { continue };
let axis = face_idx / 2;
let dir: i32 = if face_idx % 2 == 1 { 1 } else { -1 };
emit_element_face(axis, dir, from, to, texture, color, vx, vy, vz, buf);
}
}
}
/// One face of one element — generalizes `emit_quad`'s axis/u/v scheme to arbitrary (possibly
/// sub-block, possibly off-origin) box faces instead of always-integer whole-section quads.
#[allow(clippy::too_many_arguments)]
fn emit_element_face(
axis: usize,
dir: i32,
from: [f32; 3],
to: [f32; 3],
texture: &str,
color: [f32; 3],
vx: f32,
vy: f32,
vz: f32,
buf: &mut MeshBuffers,
) {
let (u_axis, v_axis) = match axis {
0 => (1, 2),
1 => (0, 2),
_ => (0, 1),
};
let layer = if dir == 1 { to[axis] } else { from[axis] };
let (u0, u1) = (from[u_axis], to[u_axis]);
let (v0, v1) = (from[v_axis], to[v_axis]);
let corners_uv = [(u0, v0), (u1, v0), (u1, v1), (u0, v1)];
let normal = match (axis, dir) {
(0, 1) => [1.0, 0.0, 0.0],
(0, -1) => [-1.0, 0.0, 0.0],
(1, 1) => [0.0, 1.0, 0.0],
(1, -1) => [0.0, -1.0, 0.0],
(2, 1) => [0.0, 0.0, 1.0],
_ => [0.0, 0.0, -1.0],
};
let atlas_rect = crate::render::texture_atlas().and_then(|atlas| atlas.rect(texture)).unwrap_or(NO_ATLAS_RECT);
// Same tile-relative local-UV scheme as emit_quad's merged cube quads (see
// models::ModelRegistry::resolve's doc comment on why this isn't the model's literal declared
// UV rectangle) — an element's own width/height in block units, not a fixed 0..1, so a
// sub-block-sized face (e.g. a torch's 2/16-block-wide side) samples a matching fraction of
// the atlas tile rather than stretching the whole tile across a sliver of geometry.
let width = u1 - u0;
let height = v1 - v0;
let local_uvs = [[0.0, 0.0], [width, 0.0], [width, height], [0.0, height]];
let base_index = buf.positions.len() as u32;
for (i, (u, v)) in corners_uv.into_iter().enumerate() {
let mut pos = [0.0f32; 3];
pos[axis] = layer;
pos[u_axis] = u;
pos[v_axis] = v;
buf.positions.push([pos[0] + vx, pos[1] + vy, pos[2] + vz]);
buf.normals.push(normal);
buf.colors.push(color);
buf.uvs.push(local_uvs[i]);
buf.atlas_rects.push(atlas_rect);
}
if dir == 1 {
buf.indices.extend_from_slice(&[base_index, base_index + 1, base_index + 2, base_index, base_index + 2, base_index + 3]);
} else {
buf.indices.extend_from_slice(&[base_index, base_index + 2, base_index + 1, base_index, base_index + 3, base_index + 2]);
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -256,7 +385,7 @@ mod tests {
#[test]
fn empty_section_produces_no_geometry() {
let blocks = [0u16; 4096];
let mesh = mesh_section(&blocks, &cpu());
let mesh = mesh_section(&blocks, &cpu(), None);
assert!(mesh.is_empty());
assert_eq!(mesh.positions.len(), 0);
}
@@ -265,7 +394,7 @@ mod tests {
fn single_voxel_produces_six_unmerged_quads() {
let mut blocks = [0u16; 4096];
blocks[((0 * 16 + 0) * 16 + 0) as usize] = (2 << 4) | 0; // grass at local (0,0,0)
let mesh = mesh_section(&blocks, &cpu());
let mesh = mesh_section(&blocks, &cpu(), None);
assert_eq!(mesh.positions.len(), 6 * 4, "6 faces x 4 verts");
assert_eq!(mesh.indices.len(), 6 * 6, "6 faces x 2 tris x 3 indices");
}
@@ -279,7 +408,7 @@ mod tests {
for b in blocks.iter_mut() {
*b = (1 << 4) | 0; // stone everywhere
}
let mesh = mesh_section(&blocks, &cpu());
let mesh = mesh_section(&blocks, &cpu(), None);
assert_eq!(mesh.positions.len(), 6 * 4, "6 merged outer faces x 4 verts");
assert_eq!(mesh.indices.len(), 6 * 6);
}
@@ -300,7 +429,7 @@ mod tests {
}
}
let backend = cpu();
let mesh = mesh_section(&blocks, &backend);
let mesh = mesh_section(&blocks, &backend, None);
// Just check the +y (top) face count via a targeted single-axis call.
// face_index 3 = axis 1 (y), dir +1 — see compute_face_masks's doc comment for the
// face_index = axis*2 + (dir==1) convention.
@@ -315,7 +444,7 @@ mod tests {
fn encode_round_trip_header() {
let mut blocks = [0u16; 4096];
blocks[0] = (2 << 4) | 0;
let mesh = mesh_section(&blocks, &cpu());
let mesh = mesh_section(&blocks, &cpu(), None);
let bytes = mesh.encode();
let vertex_count = u32::from_le_bytes(bytes[0..4].try_into().unwrap());
let index_count = u32::from_le_bytes(bytes[4..8].try_into().unwrap());
@@ -332,7 +461,7 @@ mod tests {
// local UVs should still be well-formed (start at the origin).
let mut blocks = [0u16; 4096];
blocks[0] = (1 << 4) | 0; // stone
let mesh = mesh_section(&blocks, &cpu());
let mesh = mesh_section(&blocks, &cpu(), None);
assert!(!mesh.atlas_rects.is_empty());
for rect in &mesh.atlas_rects {
assert_eq!(*rect, NO_ATLAS_RECT);
@@ -359,4 +488,62 @@ mod tests {
assert_eq!(max_u, 16.0);
assert_eq!(max_v, 16.0);
}
fn torch_vanilla_registry() -> crate::models::ModelRegistry {
let mut bs = HashMap::new();
bs.insert(
"minecraft:torch".to_string(),
r#"{"variants":{"":{"model":"minecraft:block/torch"}}}"#.to_string(),
);
let mut models = HashMap::new();
models.insert(
"minecraft:block/torch".to_string(),
r##"{"textures":{"torch":"blocks/torch"},"elements":[{"from":[7,0,7],"to":[9,10,9],"faces":{
"up":{"texture":"#torch"}
}}]}"##
.to_string(),
);
crate::models::ModelRegistry::build(&bs, &models)
}
#[test]
fn non_cube_block_is_excluded_from_cube_meshing_and_emits_its_own_geometry() {
let vanilla = torch_vanilla_registry();
let ctx = ModelContext { vanilla: Some(&vanilla), modded: None, modded_id_to_name: HashMap::new() };
let mut blocks = [0u16; 4096];
blocks[0] = (50 << 4) | 0; // torch at local (0,0,0), block_id 50 per vanilla_model_name
let mesh = mesh_section(&blocks, &cpu(), Some(&ctx));
// Only the torch model's single "up" face was declared — one quad, not a flat cube
// (which single_voxel_produces_six_unmerged_quads shows would be 6 quads / 24 verts).
assert_eq!(mesh.positions.len(), 4);
assert_eq!(mesh.indices.len(), 6);
// The element spans local (7,0,7)-(9,10,9) in 0..16 model space -> (0.4375, 0, 0.4375) to
// (0.5625, 0.625, 0.5625) in block units, offset by the voxel's own (0,0,0) position.
let ys: Vec<f32> = mesh.positions.iter().map(|p| p[1]).collect();
assert!((ys.iter().cloned().fold(0.0f32, f32::max) - 0.625).abs() < 1e-5);
}
#[test]
fn a_non_cube_neighbor_no_longer_incorrectly_culls_an_adjacent_solid_faces() {
// Before Phase 13, any non-zero block value (torch included) counted as a "solid
// neighbor" for face-culling purposes, since compute_face_masks only ever checked
// "non-zero" — this incorrectly culled a solid block's face against a torch sitting right
// next to it. Excluding non-cube blocks from `cube_blocks` (see mesh_section's doc
// comment) fixes this as a side effect: stone at (0,0,0) next to a torch at (1,0,0) must
// still show its +x face.
let vanilla = torch_vanilla_registry();
let ctx = ModelContext { vanilla: Some(&vanilla), modded: None, modded_id_to_name: HashMap::new() };
let mut blocks = [0u16; 4096];
blocks[0] = (1 << 4) | 0; // stone at (0,0,0)
blocks[1] = (50 << 4) | 0; // torch at (1,0,0)
let mesh = mesh_section(&blocks, &cpu(), Some(&ctx));
// 6 unmerged stone cube quads (24 verts / 36 indices, same shape as
// single_voxel_produces_six_unmerged_quads) + 1 torch quad (4 verts / 6 indices).
assert_eq!(mesh.positions.len(), 28);
assert_eq!(mesh.indices.len(), 42);
}
}
+512
View File
@@ -0,0 +1,512 @@
use std::collections::HashMap;
use std::io::{Cursor, Read};
use std::path::Path;
use serde::Deserialize;
/// A single resolved 3D model as a flat list of axis-aligned element boxes, ready for `mesh.rs`
/// to render as standalone (non-greedy-merged) geometry — the Phase 13 counterpart to the cube
/// greedy-mesher for blocks whose real shape isn't a full 0..16/0..16/0..16 cube (torches, stairs,
/// fences, and — the named stress test — Thaumcraft's extensive custom block models).
#[derive(Debug, Clone, PartialEq)]
pub struct ResolvedModel {
pub elements: Vec<ResolvedElement>,
}
impl ResolvedModel {
/// A single element spanning the full 0..16 cube on every axis with all 6 faces present is
/// indistinguishable from what the existing greedy cube mesher already draws (just via a
/// different texture-lookup convention) — `mesh.rs` leaves these on the old path rather than
/// double-rendering them, see its doc comment.
pub fn is_full_cube(&self) -> bool {
matches!(self.elements.as_slice(), [only] if only.from == [0.0, 0.0, 0.0]
&& only.to == [16.0, 16.0, 16.0]
&& only.faces.iter().all(|f| f.is_some()))
}
}
/// `faces[i]` indexed by the same `axis*2 + (dir==1)` convention `mesh.rs`/`render::FaceMasks`
/// already use: 0=-x(west) 1=+x(east) 2=-y(down) 3=+y(up) 4=-z(north) 5=+z(south).
#[derive(Debug, Clone, PartialEq)]
pub struct ResolvedElement {
pub from: [f32; 3],
pub to: [f32; 3],
pub faces: [Option<String>; 6],
}
fn face_index(name: &str) -> Option<usize> {
Some(match name {
"west" => 0,
"east" => 1,
"down" => 2,
"up" => 3,
"north" => 4,
"south" => 5,
_ => return None,
})
}
#[derive(Deserialize, Clone)]
struct RawBlockState {
#[serde(default)]
variants: HashMap<String, VariantValue>,
}
#[derive(Deserialize, Clone)]
#[serde(untagged)]
enum VariantValue {
Single(VariantEntry),
List(Vec<VariantEntry>),
}
impl VariantValue {
fn first(&self) -> Option<&VariantEntry> {
match self {
VariantValue::Single(v) => Some(v),
VariantValue::List(v) => v.first(),
}
}
}
#[derive(Deserialize, Clone)]
struct VariantEntry {
model: String,
}
#[derive(Deserialize)]
struct RawModel {
parent: Option<String>,
#[serde(default)]
textures: HashMap<String, String>,
elements: Option<Vec<RawElement>>,
}
#[derive(Deserialize, Clone)]
struct RawElement {
from: [f32; 3],
to: [f32; 3],
#[serde(default)]
faces: HashMap<String, RawFace>,
}
#[derive(Deserialize, Clone)]
struct RawFace {
texture: String,
}
/// Parsed blockstate + model JSON for one "source" — either the vanilla set (extracted from the
/// downloaded Mojang client jar, see `load_or_build`) or one server's modded set (shipped by the
/// mod's `BlockAssetExtractor`, see the Mod repo, and stored per-server in Postgres). Deliberately
/// dumb about anything beyond single-representative-variant resolution — see `resolve`'s doc
/// comment for exactly what's out of scope and why.
#[derive(Debug, Default)]
pub struct ModelRegistry {
blockstates: HashMap<String, RawBlockStateDebug>,
models: HashMap<String, RawModelDebug>,
}
// serde types above can't cheaply derive Debug (HashMap<String, RawFace> etc. don't need to be
// inspectable), so the registry wraps them in a thin newtype that just says "opaque" — only used
// so `ModelRegistry` itself can derive `Debug` for tests/assertions without extra ceremony at
// every call site.
struct RawBlockStateDebug(RawBlockState);
impl std::fmt::Debug for RawBlockStateDebug {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "RawBlockState({} variants)", self.0.variants.len())
}
}
struct RawModelDebug(RawModel);
impl std::fmt::Debug for RawModelDebug {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "RawModel(parent={:?})", self.0.parent)
}
}
impl ModelRegistry {
pub fn is_empty(&self) -> bool {
self.blockstates.is_empty()
}
pub fn blockstate_count(&self) -> usize {
self.blockstates.len()
}
pub fn model_count(&self) -> usize {
self.models.len()
}
/// Parses every `(name, json)` pair, silently skipping entries that fail to parse — matches
/// this crate's existing "skip malformed input, never hard-fail a whole batch over one bad
/// entry" pattern (e.g. `main.rs`'s `decode_blocks` error handling for a malformed section).
pub fn build(blockstate_files: &HashMap<String, String>, model_files: &HashMap<String, String>) -> ModelRegistry {
let mut blockstates = HashMap::new();
for (name, json) in blockstate_files {
if let Ok(parsed) = serde_json::from_str::<RawBlockState>(json) {
blockstates.insert(name.clone(), RawBlockStateDebug(parsed));
}
}
let mut models = HashMap::new();
for (name, json) in model_files {
if let Ok(parsed) = serde_json::from_str::<RawModel>(json) {
models.insert(name.clone(), RawModelDebug(parsed));
}
}
ModelRegistry { blockstates, models }
}
/// Resolves `registry_name` (e.g. `"minecraft:torch"`, `"thaumcraft:blockcustomplant"`) to
/// real element geometry: blockstate variant selection, model parent-chain walking, and
/// texture-variable substitution. `fallback` (typically the vanilla registry) is consulted
/// for any model reference not found in `self` — a modded block's model can `"parent"` a
/// vanilla one (e.g. `"minecraft:block/cross"`), and vanilla models are never shipped by the
/// mod (they're not on a dedicated server's classpath at all — see the Mod repo's
/// `BlockAssetExtractor` doc comment), only extracted backend-side from the client jar.
///
/// Several real blockstate/model features are deliberately **not** implemented — this is a
/// best-effort "show the real shape", not a full model resolver:
/// - `multipart` blockstates (fences/walls/redstone wire/glass panes) aren't resolved at all
/// — there's no way to know neighbor-dependent connection state from this project's raw
/// `block_id`/`meta` chunk data anyway. Returns `None`; the caller falls back to the
/// existing flat-cube rendering, same as any other unresolved block.
/// - When a `variants` map has multiple property-keyed entries (true for most blocks that
/// have *any* blockstate property), there's no way to know which one applies — this
/// project's chunk data model only ever carries a numeric `meta` (legacy blocks) or a
/// truncated packed state id (modern, see the Phase 10 `char[]` truncation note), never
/// named property values. This always picks one deterministic representative variant
/// (`""` if present, else the alphabetically-first key) — the same "no property data"
/// simplification already accepted for texture-name resolution elsewhere in this crate.
/// - Per-face `uv`/`rotation` and per-variant `x`/`y` block rotation are ignored. Element
/// geometry (`from`/`to`) is real, but texture mapping reuses the same tile-relative
/// "coordinate span in block units" scheme the existing cube mesher already uses (see
/// `mesh.rs::emit_quad`'s local UV), not the model's literal declared UV rectangle.
/// - A weighted multi-model variant list always picks its first entry, ignoring `weight`.
pub fn resolve(&self, registry_name: &str, fallback: Option<&ModelRegistry>) -> Option<ResolvedModel> {
let blockstate = self
.blockstates
.get(registry_name)
.or_else(|| fallback.and_then(|f| f.blockstates.get(registry_name)))?;
let variants = &blockstate.0.variants;
if variants.is_empty() {
return None; // multipart-only (or genuinely empty) — not resolved, see doc comment
}
let variant = match variants.get("") {
Some(v) => v,
None => {
let mut keys: Vec<&String> = variants.keys().collect();
keys.sort();
variants.get(keys.first()?.as_str())?
}
}
.first()?;
let mut current = normalize_ref(&variant.model);
let mut textures_merged: HashMap<String, String> = HashMap::new();
let mut elements: Option<Vec<RawElement>> = None;
for _ in 0..16 {
let model = self
.models
.get(current.as_str())
.or_else(|| fallback.and_then(|f| f.models.get(current.as_str())))?;
for (k, v) in &model.0.textures {
textures_merged.entry(k.clone()).or_insert_with(|| v.clone());
}
if elements.is_none() {
if let Some(e) = &model.0.elements {
elements = Some(e.clone());
}
}
match &model.0.parent {
Some(parent) => current = normalize_ref(parent),
None => break,
}
}
let elements = elements?;
let resolved_elements = elements
.into_iter()
.map(|el| {
let mut faces: [Option<String>; 6] = Default::default();
for (face_name, raw_face) in &el.faces {
let Some(idx) = face_index(face_name) else { continue };
faces[idx] = resolve_texture(&raw_face.texture, &textures_merged);
}
ResolvedElement { from: el.from, to: el.to, faces }
})
.collect();
Some(ResolvedModel { elements: resolved_elements })
}
}
/// Vanilla blockstate/model JSON omits the `minecraft:` namespace on internal references (a bare
/// `"block/torch"` model ref, or `"#all"` texture var) — this project's registries always key
/// vanilla entries with an explicit `minecraft:` prefix (see `extract_vanilla_source`) to keep
/// lookups uniform with modded entries (which are always already fully-qualified `"modid:path"`,
/// matching `block_registry.name`'s convention — see the api's `textures.ts` doc comment).
fn normalize_ref(s: &str) -> String {
if s.contains(':') {
s.to_string()
} else {
format!("minecraft:{s}")
}
}
/// Follows `#variable` chains (a face's `texture` can point at another texture-map key, e.g.
/// `"#all": "#base"`, `"#base": "block/stone"`) to a literal texture path, then extracts the atlas
/// lookup key (the same file-stem convention `atlas.rs`/`textures.rs` key their maps by, e.g.
/// `"minecraft:block/stone"` -> `"stone"`) from it. Returns `None` on an unresolvable variable
/// (references a texture key nothing defines) or a pathological cycle, rather than guessing.
fn resolve_texture(texture: &str, textures: &HashMap<String, String>) -> Option<String> {
let mut current: &str = texture;
for _ in 0..16 {
match current.strip_prefix('#') {
Some(var) => current = textures.get(var)?.as_str(),
None => {
let stem = current.rsplit('/').next().unwrap_or(current);
return Some(stem.to_string());
}
}
}
None
}
#[derive(serde::Serialize, serde::Deserialize)]
struct CachedSource {
blockstates: HashMap<String, String>,
models: HashMap<String, String>,
}
/// Loads a cached vanilla model registry from `<cache_dir>/vanilla-<version>-models.json` if
/// present, otherwise downloads the Mojang client jar (reuses `textures::download_client_jar_bytes`
/// — a small one-time duplicate download on a cold cache, the same accepted tradeoff `atlas.rs`
/// already makes for keeping each build path independent) and extracts every
/// `assets/minecraft/blockstates/*.json` and `assets/minecraft/models/block/**/*.json` entry.
pub async fn load_or_build(cache_dir: &Path, mc_version: &str) -> anyhow::Result<ModelRegistry> {
let cache_path = cache_dir.join(format!("vanilla-{mc_version}-models.json"));
if let Ok(bytes) = std::fs::read(&cache_path) {
if let Ok(source) = serde_json::from_slice::<CachedSource>(&bytes) {
let registry = ModelRegistry::build(&source.blockstates, &source.models);
println!(
"[worker] loaded cached vanilla model registry ({} blockstates, {} models) from {}",
registry.blockstate_count(),
registry.model_count(),
cache_path.display()
);
return Ok(registry);
}
}
println!(
"[worker] downloading Minecraft {mc_version} client jar from Mojang to build the vanilla model registry..."
);
let jar_bytes = crate::textures::download_client_jar_bytes(mc_version).await?;
let source = extract_vanilla_source(&jar_bytes)?;
std::fs::create_dir_all(cache_dir)?;
std::fs::write(&cache_path, serde_json::to_vec(&source)?)?;
let registry = ModelRegistry::build(&source.blockstates, &source.models);
println!(
"[worker] built vanilla model registry ({} blockstates, {} models), cached to {}",
registry.blockstate_count(),
registry.model_count(),
cache_path.display()
);
Ok(registry)
}
fn extract_vanilla_source(jar_bytes: &[u8]) -> anyhow::Result<CachedSource> {
let mut archive = zip::ZipArchive::new(Cursor::new(jar_bytes))?;
let mut blockstates = HashMap::new();
let mut models = HashMap::new();
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
let name = file.name().to_string();
if let Some(path) =
name.strip_prefix("assets/minecraft/blockstates/").and_then(|p| p.strip_suffix(".json"))
{
let mut text = String::new();
if file.read_to_string(&mut text).is_ok() {
blockstates.insert(format!("minecraft:{path}"), text);
}
} else if let Some(path) =
name.strip_prefix("assets/minecraft/models/block/").and_then(|p| p.strip_suffix(".json"))
{
let mut text = String::new();
if file.read_to_string(&mut text).is_ok() {
models.insert(format!("minecraft:block/{path}"), text);
}
}
}
Ok(CachedSource { blockstates, models })
}
/// Resolves one voxel's `(block_id, meta)` to real geometry, if any is known — the single entry
/// point `mesh.rs` calls per non-air voxel. Modded blocks take priority over vanilla when both a
/// per-server registry name (`modded_id_to_name`, sourced from the `block_registry` table — see
/// the api's `textures.ts`) and a matching modded model are available; `meta` is never consulted
/// for modded blocks since `block_registry` only carries `block_id -> name` (no per-state
/// granularity — the mod can't cheaply enumerate every possible state, and it wouldn't help
/// without the property-string data `ModelRegistry::resolve`'s doc comment already flags as
/// missing). Falls back to a small built-in table of vanilla non-cube blocks (`vanilla_model_name`,
/// `block_names.rs`) when there's no per-server match.
pub fn resolve_for_block(
vanilla: Option<&ModelRegistry>,
modded: Option<&ModelRegistry>,
modded_id_to_name: &HashMap<u16, String>,
block_id: u16,
meta: u8,
) -> Option<ResolvedModel> {
if let (Some(name), Some(modded_registry)) = (modded_id_to_name.get(&block_id), modded) {
if let Some(resolved) = modded_registry.resolve(name, vanilla) {
return Some(resolved);
}
}
let name = crate::block_names::vanilla_model_name(block_id, meta)?;
vanilla?.resolve(name, None)
}
#[cfg(test)]
mod tests {
use super::*;
fn full_cube_source() -> (HashMap<String, String>, HashMap<String, String>) {
let blockstates = HashMap::from([(
"minecraft:stone".to_string(),
r#"{"variants":{"":{"model":"minecraft:block/stone"}}}"#.to_string(),
)]);
let models = HashMap::from([(
"minecraft:block/stone".to_string(),
r#"{"parent":"block/cube_all","textures":{"all":"block/stone"}}"#.to_string(),
), (
"minecraft:block/cube_all".to_string(),
r##"{"elements":[{"from":[0,0,0],"to":[16,16,16],"faces":{
"down":{"texture":"#all"},"up":{"texture":"#all"},
"north":{"texture":"#all"},"south":{"texture":"#all"},
"west":{"texture":"#all"},"east":{"texture":"#all"}
}}]}"##.to_string(),
)]);
(blockstates, models)
}
#[test]
fn resolves_a_full_cube_via_a_parent_chain_and_texture_variable() {
let (bs, models) = full_cube_source();
let registry = ModelRegistry::build(&bs, &models);
let resolved = registry.resolve("minecraft:stone", None).unwrap();
assert!(resolved.is_full_cube());
assert_eq!(resolved.elements[0].faces[3], Some("stone".to_string())); // +y = up
}
#[test]
fn resolves_a_non_cube_torch_shaped_model() {
let mut bs = HashMap::new();
bs.insert(
"minecraft:torch".to_string(),
r#"{"variants":{"":{"model":"minecraft:block/torch"}}}"#.to_string(),
);
let mut models = HashMap::new();
models.insert(
"minecraft:block/torch".to_string(),
r##"{"textures":{"torch":"block/torch"},"elements":[{"from":[7,0,7],"to":[9,10,9],"faces":{
"up":{"texture":"#torch"},"down":{"texture":"#torch"}
}}]}"##.to_string(),
);
let registry = ModelRegistry::build(&bs, &models);
let resolved = registry.resolve("minecraft:torch", None).unwrap();
assert!(!resolved.is_full_cube());
assert_eq!(resolved.elements.len(), 1);
assert_eq!(resolved.elements[0].from, [7.0, 0.0, 7.0]);
assert_eq!(resolved.elements[0].faces[3], Some("torch".to_string()));
assert_eq!(resolved.elements[0].faces[0], None); // west face not modeled
}
#[test]
fn multipart_only_blockstate_is_not_resolved() {
let mut bs = HashMap::new();
bs.insert(
"minecraft:oak_fence".to_string(),
r#"{"multipart":[{"apply":{"model":"minecraft:block/oak_fence_post"}}]}"#.to_string(),
);
let registry = ModelRegistry::build(&bs, &HashMap::new());
assert!(registry.resolve("minecraft:oak_fence", None).is_none());
}
#[test]
fn a_modded_model_can_parent_a_vanilla_one_via_the_fallback_registry() {
let (vanilla_bs, vanilla_models) = full_cube_source();
let vanilla = ModelRegistry::build(&vanilla_bs, &vanilla_models);
let mut modded_bs = HashMap::new();
modded_bs.insert(
"thaumcraft:blockcustomplant".to_string(),
r#"{"variants":{"":{"model":"thaumcraft:block/customplant"}}}"#.to_string(),
);
let mut modded_models = HashMap::new();
modded_models.insert(
"thaumcraft:block/customplant".to_string(),
r#"{"parent":"minecraft:block/cube_all","textures":{"all":"thaumcraft:block/customplant"}}"#
.to_string(),
);
let modded = ModelRegistry::build(&modded_bs, &modded_models);
let resolved = modded.resolve("thaumcraft:blockcustomplant", Some(&vanilla)).unwrap();
assert!(resolved.is_full_cube());
assert_eq!(resolved.elements[0].faces[3], Some("customplant".to_string()));
}
#[test]
fn missing_registry_name_resolves_to_none() {
let registry = ModelRegistry::build(&HashMap::new(), &HashMap::new());
assert!(registry.resolve("minecraft:does_not_exist", None).is_none());
}
#[test]
fn resolve_for_block_prefers_modded_over_vanilla_for_a_known_id() {
let (vanilla_bs, vanilla_models) = full_cube_source();
let vanilla = ModelRegistry::build(&vanilla_bs, &vanilla_models);
let mut modded_bs = HashMap::new();
modded_bs.insert(
"thaumcraft:manapool".to_string(),
r#"{"variants":{"":{"model":"thaumcraft:block/manapool"}}}"#.to_string(),
);
let mut modded_models = HashMap::new();
modded_models.insert(
"thaumcraft:block/manapool".to_string(),
r##"{"textures":{"top":"thaumcraft:block/manapool_top"},"elements":[{"from":[0,0,0],"to":[16,4,16],"faces":{"up":{"texture":"#top"}}}]}"##.to_string(),
);
let modded = ModelRegistry::build(&modded_bs, &modded_models);
let mut id_to_name = HashMap::new();
id_to_name.insert(4000u16, "thaumcraft:manapool".to_string());
let resolved = resolve_for_block(Some(&vanilla), Some(&modded), &id_to_name, 4000, 0).unwrap();
assert_eq!(resolved.elements[0].to, [16.0, 4.0, 16.0]);
}
#[test]
fn resolve_for_block_falls_back_to_vanilla_for_an_unknown_id() {
let (vanilla_bs, vanilla_models) = full_cube_source();
let vanilla = ModelRegistry::build(&vanilla_bs, &vanilla_models);
// block_id 1 = stone, matches block_names::vanilla_model_name's vanilla table indirectly
// via a full-cube resolve — but stone isn't in the small non-cube demo table, so this
// should resolve to None (block_names::vanilla_model_name only covers non-cube blocks —
// see that function's doc comment) even though "minecraft:stone" itself is resolvable.
assert!(resolve_for_block(Some(&vanilla), None, &HashMap::new(), 1, 0).is_none());
}
#[test]
fn resolve_texture_follows_a_variable_chain_to_a_literal_stem() {
let mut textures = HashMap::new();
textures.insert("all".to_string(), "#base".to_string());
textures.insert("base".to_string(), "minecraft:block/stone".to_string());
assert_eq!(resolve_texture("#all", &textures), Some("stone".to_string()));
}
#[test]
fn resolve_texture_on_an_unresolvable_variable_is_none() {
let textures = HashMap::new();
assert_eq!(resolve_texture("#missing", &textures), None);
}
}
+45
View File
@@ -48,6 +48,51 @@ pub(crate) fn texture_atlas() -> Option<&'static crate::atlas::TextureAtlas> {
TEXTURE_ATLAS.get()
}
/// Same `OnceLock`-once-at-startup pattern as `TEXTURE_PALETTE`/`TEXTURE_ATLAS`, for the Phase 13
/// vanilla model registry (see `models.rs`). Worker-wide, not per-server — the same simplification
/// tier already accepted for the texture palette/atlas. `None` until `main.rs` successfully builds
/// one; `mesh.rs` simply skips non-cube resolution entirely when this is `None`.
static VANILLA_MODEL_REGISTRY: OnceLock<crate::models::ModelRegistry> = OnceLock::new();
pub fn set_vanilla_model_registry(registry: crate::models::ModelRegistry) {
let _ = VANILLA_MODEL_REGISTRY.set(registry);
}
pub(crate) fn vanilla_model_registry() -> Option<&'static crate::models::ModelRegistry> {
VANILLA_MODEL_REGISTRY.get()
}
/// Bundles everything `mesh.rs` needs to resolve one voxel's real (possibly non-cube) shape:
/// the worker-wide vanilla registry plus one job's per-server modded registry/id-map, both
/// optional (a server with no modded model data yet, or a worker that never built the vanilla
/// registry, simply resolves nothing — mesh_section falls back to its pre-Phase-13 behavior).
/// Built fresh per chunk-job in `main.rs` (the modded half is genuinely per-server; the vanilla
/// half is just a borrow of the static above) rather than stored globally, since it's cheap and
/// keeps `mesh_section` ignorant of *where* the data came from.
#[derive(Default)]
pub struct ModelContext<'a> {
pub vanilla: Option<&'a crate::models::ModelRegistry>,
pub modded: Option<&'a crate::models::ModelRegistry>,
pub modded_id_to_name: std::collections::HashMap<u16, String>,
}
impl<'a> ModelContext<'a> {
pub fn resolve(&self, block_id: u16, meta: u8) -> Option<crate::models::ResolvedModel> {
crate::models::resolve_for_block(self.vanilla, self.modded, &self.modded_id_to_name, block_id, meta)
}
}
/// Builds a `ModelContext` for one chunk-job, combining the worker-wide vanilla registry (read
/// from the `OnceLock` above — kept private to this module, `main.rs` never reads it directly) with
/// the caller-supplied per-server modded registry/id-map (see `main.rs`'s `fetch_chunk_data`,
/// which queries `block_registry`/`block_models` for the job's `server_id`).
pub fn model_context(
modded: Option<&crate::models::ModelRegistry>,
modded_id_to_name: std::collections::HashMap<u16, String>,
) -> ModelContext<'_> {
ModelContext { vanilla: vanilla_model_registry(), modded, modded_id_to_name }
}
/// One rendered column within a chunk, in chunk-local coordinates (0..16).
pub struct ColumnPixel {
pub local_x: u8,