From 0b9e83ff8a20c15ac9daef3fb990d83e0f8ab12a Mon Sep 17 00:00:00 2001 From: Jake Ryan <38368197+JuanDiegoMontoya@users.noreply.github.com> Date: Wed, 30 Apr 2025 08:55:53 -0700 Subject: [PATCH] patterns: Add support for MagicaVoxel .vox files (#390) * patterns: Added support for MagicaVoxel .vox files * Fixed incorrect IMAP field size and added basic chunk size mismatch detection and recovery. * Fixed pattern for "_r" values and added RotationToMat3. * Added test vox file. --------- Co-authored-by: paxcut <53811119+paxcut@users.noreply.github.com> --- README.md | 1 + patterns/vox.hexpat | 270 ++++++++++++++++++++++++ tests/patterns/test_data/vox.hexpat.vox | Bin 0 -> 26847 bytes 3 files changed, 271 insertions(+) create mode 100644 patterns/vox.hexpat create mode 100644 tests/patterns/test_data/vox.hexpat.vox diff --git a/README.md b/README.md index 0eaeaa9..502946b 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,7 @@ Everything will immediately show up in ImHex's Content Store and gets bundled wi | VEADO | | [`patterns/veado.hexpat`](patterns/veado.hexpat) | veadotube mini avatar file | | VGM | | [`patterns/vgm.hexpat`](patterns/vgm.hexpat) | VGM (Video Game Music) sound log | | VHDX | | [`patterns/vhdx.hexpat`](patterns/vhdx.hexpat) | Microsoft Hyper-V Virtual Hard Disk format | +| VOX | | [`patterns/vox.hexpat`](patterns/vox.hexpat) | MagicaVoxel scene description format | | WAV | `audio/x-wav` | [`patterns/wav.hexpat`](patterns/wav.hexpat) | RIFF header, WAVE header, PCM header | | WAS | | [`patterns\was_oskasoftware.hexpat`](patterns\was_oskasoftware.hexpat) | Oska Software DeskMates WAS/WA3 (WAVE/MP3 Set) file | WAD | | [`patterns/wad.hexpat`](patterns/wad.hexpat) | DOOM WAD Archive | diff --git a/patterns/vox.hexpat b/patterns/vox.hexpat new file mode 100644 index 0000000..3e6e1f8 --- /dev/null +++ b/patterns/vox.hexpat @@ -0,0 +1,270 @@ +#pragma author Jake Ryan +#pragma description MagicaVoxel scene description format +#pragma magic [ 56 4F 58 20 ] @ 0 +#pragma array_limit 0x100000 + +import std.io; +import std.core; + +// Input and output variables + +// If true, color data will be formatted as normalized floats. +bool formatColorsAsFloat in; + +// If true, chunks will be treated as though they were in a single big array instead of a hierarchy. +bool readChunksAsLinear in; + +bool attemptRecoveryOnChunkSizeMismatch in; + +// Pattern definitions +// https://github.com/ephtracy/voxel-model/blob/master/MagicaVoxel-file-format-vox.txt +// https://github.com/ephtracy/voxel-model/blob/master/MagicaVoxel-file-format-vox-extension.txt +struct VoxHeader +{ + char magic[4]; + u32 version; +}; + +struct Voxel +{ + u8 x; + u8 y; + u8 z; + u8 colorIndex; +} [[static, format("FormatVoxel")]]; + +fn FormatVoxel(ref Voxel v) +{ + return std::format("{}, {}, {}", v.x, v.y, v.z); +}; + +struct RGBA8 +{ + u8 r [[color("FF0000")]]; + u8 g [[color("00FF00")]]; + u8 b [[color("0000FF")]]; + u8 a; +} [[static, color(std::format("{:02X}{:02X}{:02X}", r, g, b)), format("FormatRGBA8")]]; + +fn FormatRGBA8(ref RGBA8 c) +{ + if (formatColorsAsFloat) + { + return std::format("{:4.2f}, {:4.2f}, {:4.2f}, {:4.2f}", c.r / 255.0, c.g / 255.0, c.b / 255.0, c.a / 255.0); + } + else + { + return std::format("{:3}, {:3}, {:3}, {:3}", c.r, c.g, c.b, c.a); + } +}; + +struct String +{ + u32 sizeBytes; + char data[sizeBytes]; +} [[format("FormatString")]]; + +fn FormatString(ref String s) +{ + return std::format("\"{}\"", s.data); +}; + +struct Vec3 { float v[3]; }; +struct Mat3 { Vec3 r0, r1, r2; } [[format("FormatMat3")]]; + +fn FormatMat3(ref Mat3 m) +{ + return std::format("({}, {}, {}) ({}, {}, {}) ({}, {}, {})", + m.r0.v[0], m.r0.v[1], m.r0.v[2], + m.r1.v[0], m.r1.v[1], m.r1.v[2], + m.r2.v[0], m.r2.v[1], m.r2.v[2]); +}; + +bitfield Rotation +{ + firstRowIndex : 2; + secondRowIndex : 2; + firstRowSign : 1; + secondRowSign : 1; + thirdRowSign : 1; + padding : 1; +}; + +// Unused, but demonstrates how a 3x3 matrix could be constructed from Rotation. +fn RotationToMat3(Rotation r) +{ + Mat3 mat; + mat.r0.v[r.firstRowIndex] = -(r.firstRowSign * 2 - 1); + mat.r1.v[r.secondRowIndex] = -(r.secondRowSign * 2 - 1); + mat.r2.v[0] = -(r.thirdRowSign * 2.0 - 1) * !(r.firstRowIndex == 0 || r.secondRowIndex == 0); + mat.r2.v[1] = -(r.thirdRowSign * 2.0 - 1) * !(r.firstRowIndex == 1 || r.secondRowIndex == 1); + mat.r2.v[2] = -(r.thirdRowSign * 2.0 - 1) * !(r.firstRowIndex == 2 || r.secondRowIndex == 2); + return mat; +}; + +struct KeyValue +{ + String key; + // Depending on the key, the value must be parsed as one or more floats or integers. + String value; +} [[format("FormatKeyValue")]]; + +fn FormatKeyValue(ref KeyValue kv) +{ + return std::format("{}: {}", kv.key, kv.value); +}; + +struct Dict +{ + u32 count; + KeyValue pairs[count] [[inline]]; +} [[format("FormatDict")]]; + +fn FormatDict(ref Dict d) +{ + return std::format("count: {}", d.count); +}; + +struct Model +{ + s32 modelId; + Dict attributes; +}; + +struct Chunk +{ + char id[4]; + u32 sizeBytes; + u32 childrenBytes; + + // Contents of the chunk + s32 cursorPosBeforeContent = $; + match (id) + { + ("MAIN"): {} + ("PACK"): + { + u32 numModels; + } + ("SIZE"): + { + u32 sizeX, sizeY, sizeZ; + } + ("XYZI"): + { + u32 numVoxels; + Voxel voxels[numVoxels]; + } + ("RGBA"): + { + RGBA8 colors[256]; + } + ("nTRN"): + { + s32 nodeId; + Dict attributes; + s32 childNodeId; + s32 reservedMustBeNegative1; + s32 layerId; + u32 numFrames; + Dict frames[numFrames]; + } + ("nGRP"): + { + s32 nodeId; + Dict attributes; + u32 numChildren; + s32 childrenIds[numChildren]; + } + ("nSHP"): + { + s32 nodeId; + Dict attributes; + u32 numModels; + Model models[numModels]; + } + ("MATL"): // Material properties + { + s32 materialId; + Dict attributes; + } + ("LAYR"): + { + s32 layerId; + Dict attributes; + s32 reservedMustBeNegative1; + } + ("rOBJ"): // Rendering attributes + { + Dict attributes; + } + ("rCAM"): // Camera attributes + { + s32 cameraId; + Dict attributes; + } + ("NOTE"): // Names for colors + { + u32 numNames; + String names[numNames]; + } + ("IMAP"): // Index map + { + // The documentation says this field is i32x256, but it's u8x256 in actual models. + u8 paletteIndices[256]; + } + (_): + { + u8 unknownData[sizeBytes]; + std::warning(std::format("Unknown chunk ID at 0x{:X}: \"{}\"", $, id)); + } + } + + s32 actualContentSize = ($ - cursorPosBeforeContent); + if (actualContentSize != sizeBytes) + { + str warningTextSecondHalf = std::format("Content size mismatch! Expected: {}. Actual: {}", sizeBytes, actualContentSize); + str warningText = std::format("Chunk at 0x{:X} with id {}: {}", addressof(this), id, warningTextSecondHalf); + if (attemptRecoveryOnChunkSizeMismatch) + { + std::warning(warningText); + } + else + { + std::error(warningText); + } + // Limited recovery- reading past EoF is still possible if chunk is nested. + // Setting readChunksAsLinear to true can make recovery more robust as the outermost array will be broken out of. + break; + } + + if (childrenBytes > 0 && !readChunksAsLinear) + { + s32 cursorPosBeforeChildren = $; + Chunk children[while(true)]; + } + + // The node with id MAIN is also the root node. + if (id != "MAIN" && !readChunksAsLinear) + { + // We are done parsing the children for this parent if the cursor has advanced parent.childrenBytes bytes since then. + if ($ - parent.cursorPosBeforeChildren >= parent.childrenBytes) + { + break; + } + } +} [[format("ChunkFormatter")]]; + +fn ChunkFormatter(ref Chunk chunk) +{ + return chunk.id; +}; + +VoxHeader header @ 0 [[inline]]; +Chunk mainChunk[readChunksAsLinear ? 0 : 1] @ $ [[inline]]; +Chunk chunks[while(readChunksAsLinear && !std::mem::eof())] @ $; + +if (std::core::member_count(mainChunk) > 0) +{ + std::core::set_display_name(mainChunk[0], "mainChunk"); +} \ No newline at end of file diff --git a/tests/patterns/test_data/vox.hexpat.vox b/tests/patterns/test_data/vox.hexpat.vox new file mode 100644 index 0000000000000000000000000000000000000000..717bd02b5d98b9c75b592c9d3b2b415190088eff GIT binary patch literal 26847 zcmc(n2Yee>9miih;yBycvr(NwffA$BNw!lckklbb$-+rW5?X|8-N|ZX$!Nq0o3hN_ zrWc{zR-*Y%vZK=>Ku_#{|Y>0^>1`{+Y-g>piqZk9PKL0kY+pBzZ~My}d`) z1X)vwHDF)VB%gz`bLMl2Nos@yBYCM)DId>E&gzo)co98pc0{^EU7^nG(S6lMq_U(+ zW0IFfIwXT6jY?{Uq!B4sQ2<_qh}9X|VOgQBxn!v!sZv_<2X>w8y^CaKl65AG4ixIK9x8f-a2+@P*~ADEbm@bj8l^9~rmW^3p$63=IT2zk;}}tAhBgtAZG)tAboE>#88v z)4D3i#j36fa^B5f- zKQ8>Z@Z-Xd4zG@q^o+QX7vk_^!;cL=HvHJ|W5bV51NgDw$A%vper)*BsR2JW{Mhhg z!-$PWb-Dm%qoR#MHUc*M;2(rI{Mhhg!;cL=2tzVsWR#?5#0_5XW8lZYkAWWpKL&nK z9YP#_4Ez}QG4KNd_%ZNf;0Mh>h{KP89|J!IX3#W*IDiI~4GK;pI0=nGqtFO6 z3>}AhpmqpfFJ&_gO+sVPC^P~ML&u>Ws2ys9%rrC!jX|T(2s8{GhkBrPsI5FbJzbuh zoGg!xjg?17N6RB4Bjw@Y;qvk0$ICrEJ>~ZH_HtWW8_LQp(Xj*YqDii0vdq#oVm?<& z4ag!b++j&Dp3dciU0XKsd|EbEhpp?hm6FnO|O zW%6WA%jC(Dm&ubAF_R|?W+qS8(@dT$x0yUyjWc<&SZDHNEzji1QlH6_I{_w7ZWNe2 zxsPD-v$h;?#c~2tq zo=oOFh0Gfv^PWoPJ&nwJI+^zjGVhsW-m}QOXOnr)A@iO~<~@(hdp?=>0y6J~WZsL& zycd&sFCp_hGVi5i-pk0mmy>y~AoE^H=Dmu{yNS$uHJSGsGViry-s{M`*OPf~AoJcx z=Dmr`do!6gO6I+V%zG=D_ck)`?PT6N$h@1$ymyj$?;`WwP3FCa%zH1H_dYW3{bb$; z$h;4dc^@M4#>l)6lX>H0-bcv1kCJ%udj&}oPz%%y#f#^L9L+d`>Ec3(T;%`YtwI(6 zV(FfnDV>$Q$lU+k`!NKXpgYpmT;9jYyibsMpCt1>Mdp2)%=-+PcMF;KSu*c)WZtc0 zo=@g|p3M6KnfFC9?@MIfm&v@Zka=Gv^S(yreVxqv2ATIwGVfbt-nYrT?~r-lCG);V z=1r1$-zW2aK<53B%=;0U_hT~eCuH7jWZq9nJo&3MNmG!l#dXClcLTaGA9RX|%xq5A z^t!8e2a8Z1&-V$3qg}SFS4^ZrKW{hiGF2buRzGVfnx-oMGb|B!iQGEbg=dPlE+`NO-fxemT0 z$>OEDfRUeV#`|)mY`-Iy{&+I&x}}06YnVu@1PVh=e<_p5OC+X63jK*<(Fp=t-N`!n z)Hx|&OFP?NY2$(8mChMVJGBKRaJ$_oAEbg#>yBxwIXj-z zPOI!`Ju9qTAF?d<+9O7hq-NEtP8BzoidQ-`;Ar2T-?#=-)xo*l8KK~elPzdwD4)AQ z%S)SaH+M!5(U{KI#E_FOmhw(;Adz6w_GJ=DC!WZbj0VIJ`+t9ix+=OJ4IzjZ2^m6d} zba^~rUYj1MJabe*mkR|+`m+iWYP>Hs3z1JSQG=;^C3Ym$w5@fWWGc8r(w3{0lrx-8 z7H6J)s=ai50IBUIrKIxdIsU5@9mT>>qCcJ0fe6kc#RelEj%X2__d}V4UY>NIZ=7jJ zK2*L8M*wq*MZo5Cn+|E!gbsl2$$`!8^`+wdHG2u&)5Fw}lsZ-jFRk3~WHM5~%3rWC9XmtuRTrvCy?h|(+o literal 0 HcmV?d00001