diff --git a/README.md b/README.md index 79494dc..76d7c04 100644 --- a/README.md +++ b/README.md @@ -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--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 ``` diff --git a/api/drizzle/0007_block_models.sql b/api/drizzle/0007_block_models.sql new file mode 100644 index 0000000..c8d7fde --- /dev/null +++ b/api/drizzle/0007_block_models.sql @@ -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") +); diff --git a/api/src/db/schema.ts b/api/src/db/schema.ts index 3f7014c..1644190 100644 --- a/api/src/db/schema.ts +++ b/api/src/db/schema.ts @@ -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. diff --git a/api/src/models.test.ts b/api/src/models.test.ts new file mode 100644 index 0000000..dcc6f2a --- /dev/null +++ b/api/src/models.test.ts @@ -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(); + }); +}); diff --git a/api/src/models.ts b/api/src/models.ts new file mode 100644 index 0000000..bc5b58a --- /dev/null +++ b/api/src/models.ts @@ -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() }, + }); +} diff --git a/api/src/ws-gateway.ts b/api/src/ws-gateway.ts index f6c898b..69224be 100644 --- a/api/src/ws-gateway.ts +++ b/api/src/ws-gateway.ts @@ -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) { diff --git a/worker/src/block_names.rs b/worker/src/block_names.rs index 3f8600c..3e9595c 100644 --- a/worker/src/block_names.rs +++ b/worker/src/block_names.rs @@ -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 + } } diff --git a/worker/src/db.rs b/worker/src/db.rs index c21f1b6..2426570 100644 --- a/worker/src/db.rs +++ b/worker/src/db.rs @@ -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> { + 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> { + 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, diff --git a/worker/src/lib.rs b/worker/src/lib.rs index e65222e..caedc67 100644 --- a/worker/src/lib.rs +++ b/worker/src/lib.rs @@ -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; diff --git a/worker/src/main.rs b/worker/src/main.rs index 05962c6..0482dd2 100644 --- a/worker/src/main.rs +++ b/worker/src/main.rs @@ -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, sections: Vec, + /// 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, } async fn fetch_chunk_data(pool: &sqlx::PgPool, job: &ChunkJob) -> anyhow::Result { @@ -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 = 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(§ion.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; } diff --git a/worker/src/mesh.rs b/worker/src/mesh.rs index a5ec548..75fca40 100644 --- a/worker/src/mesh.rs +++ b/worker/src/mesh.rs @@ -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> = 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 = 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); + } } diff --git a/worker/src/models.rs b/worker/src/models.rs new file mode 100644 index 0000000..5727558 --- /dev/null +++ b/worker/src/models.rs @@ -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, +} + +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; 6], +} + +fn face_index(name: &str) -> Option { + 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, +} + +#[derive(Deserialize, Clone)] +#[serde(untagged)] +enum VariantValue { + Single(VariantEntry), + List(Vec), +} + +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, + #[serde(default)] + textures: HashMap, + elements: Option>, +} + +#[derive(Deserialize, Clone)] +struct RawElement { + from: [f32; 3], + to: [f32; 3], + #[serde(default)] + faces: HashMap, +} + +#[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, + models: HashMap, +} + +// serde types above can't cheaply derive Debug (HashMap 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, model_files: &HashMap) -> ModelRegistry { + let mut blockstates = HashMap::new(); + for (name, json) in blockstate_files { + if let Ok(parsed) = serde_json::from_str::(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::(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 { + 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 = HashMap::new(); + let mut elements: Option> = 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; 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) -> Option { + 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, + models: HashMap, +} + +/// Loads a cached vanilla model registry from `/vanilla--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 { + 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::(&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 { + 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, + block_id: u16, + meta: u8, +) -> Option { + 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, HashMap) { + 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); + } +} diff --git a/worker/src/render/mod.rs b/worker/src/render/mod.rs index 085f55f..2d0156e 100644 --- a/worker/src/render/mod.rs +++ b/worker/src/render/mod.rs @@ -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 = 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, +} + +impl<'a> ModelContext<'a> { + pub fn resolve(&self, block_id: u16, meta: u8) -> Option { + 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, +) -> 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,