├── README.md ├── imgs ├── frontispiece.png └── melonDSHotkeys.png └── import_melon_rip.py /README.md: -------------------------------------------------------------------------------- 1 |

2 | 4 |

5 |

MelonRipper

6 | 7 | This repo has some stuff for ripping 3D models from the Nintendo DS. 8 | It works Ninja Ripper-style, 9 | where to rip a model, you play in an emulator 10 | until the model is on screen, 11 | then press a key to dump everything drawn in one frame to a file. 12 | A Blender addon can import the dump. 13 | 14 | If you want to convert an .nsbmd model file instead, 15 | I have [another project](https://github.com/scurest/apicula) 16 | for that. 17 | 18 | ## How it works 19 | 20 | MelonRipper consists of two parts: 21 | a patched melonDS for ripping dump files, 22 | and a Blender addon for importing them. 23 | 24 | ### melonDS 25 | 26 | First, you need to build the patched melonDS. 27 | Windows users can download a 28 | [precompiled EXE](https://github.com/scurest/melonDS/releases/tag/MelonRipperBuild). 29 | Otherwise, compile the 30 | [scurest:MelonRipper branch](https://github.com/scurest/melonDS/tree/MelonRipper) 31 | from my copy of melonDS. 32 | Build instructions are in melonDS's ReadMe. 33 | 34 | Open the emulator. 35 | There should be a new hotkey for ripping a frame. 36 | Go to _Config ‣ Input and hotkeys ‣ Add-ons_ 37 | and assign a hotkey to "[MelonRipper] Rip" 38 | (I used [F]). 39 | 40 | 41 | 42 | Now when you're playing a game 43 | you can press the hotkey 44 | to rip the next frame to a `.dump` file 45 | in the current directory. 46 | 47 | ### Blender 48 | 49 | Blender 2.82+ is required. 50 | Last tested with Blender 3.5. 51 | 52 | To install the addon, open 53 | [`import_melon_rip.py`](https://raw.githubusercontent.com/scurest/MelonRipper/master/import_melon_rip.py) 54 | and save it to your computer. 55 | Then in Blender, 56 | go to _Edit ‣ Preferences ‣ Add-ons ‣ Install..._ 57 | and select the file you just saved. 58 | Enable the addon by clicking the checkbox 59 | next to "Import: MelonRipper NDS Dumps" in the addon list 60 | (use the search box to find it). 61 | See the 62 | [Blender Manual](https://docs.blender.org/manual/en/latest/editors/preferences/addons.html#rd-party-add-ons) 63 | or 64 | [this question](https://blender.stackexchange.com/questions/1688/installing-an-addon/1689) 65 | for more help installing addons. 66 | 67 | Then go to _File ‣ Import ‣ MelonRipper NDS Dump_ 68 | and pick the `.dump` file you ripped with melonDS 69 | to import it. 70 | 71 | 72 | ## Tips & Tricks 73 | 74 | * If the colors are washed out, 75 | try switching Blender's color space from "Filmic" to "Standard". 76 | See [this answer](https://blender.stackexchange.com/questions/164677/images-as-emitters-constantly-come-out-dull-white-emission-not-actually-white). 77 | 78 | * If you're having trouble finding the model in the viewport, 79 | try _View ‣ Frame Selected_. 80 | 81 | * Sometimes different parts of the scene are 82 | displaced relative to each other. 83 | I think that's because they're drawn with different "cameras". 84 | (Dumped vertex position are all after the ModelView matrix 85 | but before the Projection.) 86 | 87 | * Normals aren't ripped. 88 | The calculated lighting is baked into the vertex colors. 89 | 90 | * Strip connectivity is not preserved. 91 | All faces in Blender are totally separate from each other, 92 | even if they were originally part of a polygon strip. 93 | 94 | * Vertex colors in the middle of a quad 95 | will look different in Blender than on the DS 96 | because quads on a PC are rendered as two tris, 97 | while the DS renders quads as real quads. 98 | The [melonDS blog](http://melonds.kuribo64.net/comments.php?id=122) 99 | has a great explanation for this. 100 | 101 | * Translucent (partially transparent) faces are imported with "Alpha Blend". 102 | This may have sorting problems in the Eevee renderer. 103 | If you have sorting issues, try Cycles. 104 | 105 | * Some DS effects aren't implemented: 106 | fog, highlight, shadow, wireframe, edgemarking, depth equal, rear plane. 107 | 108 | * Exporting to .gltf sort of works (use Blender ≥2.92 for best results). 109 | You will probably need to modify the materials to export to other formats. 110 | -------------------------------------------------------------------------------- /imgs/frontispiece.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scurest/MelonRipper/4033ee7f9bd7ad46c32e0619bb5046fb4fae8d45/imgs/frontispiece.png -------------------------------------------------------------------------------- /imgs/melonDSHotkeys.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scurest/MelonRipper/4033ee7f9bd7ad46c32e0619bb5046fb4fae8d45/imgs/melonDSHotkeys.png -------------------------------------------------------------------------------- /import_melon_rip.py: -------------------------------------------------------------------------------- 1 | 2 | # ███╗ ███╗███████╗██╗ ██████╗ ███╗ ██╗██████╗ ██╗██████╗ ██████╗ ███████╗██████╗ 3 | # ████╗ ████║██╔════╝██║ ██╔═══██╗████╗ ██║██╔══██╗██║██╔══██╗██╔══██╗██╔════╝██╔══██╗ 4 | # ██╔████╔██║█████╗ ██║ ██║ ██║██╔██╗ ██║██████╔╝██║██████╔╝██████╔╝█████╗ ██████╔╝ 5 | # ██║╚██╔╝██║██╔══╝ ██║ ██║ ██║██║╚██╗██║██╔══██╗██║██╔═══╝ ██╔═══╝ ██╔══╝ ██╔══██╗ 6 | # ██║ ╚═╝ ██║███████╗███████╗╚██████╔╝██║ ╚████║██║ ██║██║██║ ██║ ███████╗██║ ██║ 7 | # ╚═╝ ╚═╝╚══════╝╚══════╝ ╚═════╝ ╚═╝ ╚═══╝╚═╝ ╚═╝╚═╝╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ 8 | 9 | bl_info = { 10 | "name": "MelonRipper NDS Dumps", 11 | "author": "scurest", 12 | "version": (1, 0, 0), 13 | "blender": (2, 82, 0), 14 | "location": "File > Import", 15 | "description": "Import scenes ripped from Nintendo DS with melonDS + MelonRipper", 16 | "doc_url": "https://github.com/scurest/MelonRipper", 17 | "category": "Import", 18 | } 19 | 20 | import bpy 21 | import os 22 | import struct 23 | import time 24 | 25 | from bpy.props import StringProperty 26 | from bpy_extras.io_utils import ImportHelper 27 | 28 | 29 | class ShowErrorMsg(RuntimeError): 30 | # Raise to show an error message 31 | pass 32 | 33 | 34 | class ImportMelonRipOp(bpy.types.Operator, ImportHelper): 35 | """Load a MelonRipper DS .dump file""" 36 | bl_idname = "import_model.melon_rip" 37 | bl_label = "Import MelonRipper NDS Dump" 38 | bl_options = {'PRESET', 'UNDO'} 39 | 40 | filename_ext = ".dump" 41 | filter_glob: StringProperty( 42 | default="*.dump;", 43 | options={'HIDDEN'}, 44 | ) 45 | 46 | def execute(self, context): 47 | start_t = time.time() 48 | 49 | try: 50 | import_rip(self.filepath) 51 | 52 | except ShowErrorMsg as e: 53 | self.report({'ERROR'}, e.args[0]) 54 | return {'CANCELLED'} 55 | 56 | end_t = time.time() 57 | elapsed = end_t - start_t 58 | 59 | print(f"Imported '{self.filepath}' in {elapsed:.1f} s") 60 | 61 | return {'FINISHED'} 62 | 63 | 64 | def menu_func_import(self, context): 65 | self.layout.operator(ImportMelonRipOp.bl_idname, text="MelonRipper NDS Dump") 66 | 67 | 68 | def register(): 69 | bpy.utils.register_class(ImportMelonRipOp) 70 | bpy.types.TOPBAR_MT_file_import.append(menu_func_import) 71 | 72 | 73 | def unregister(): 74 | bpy.types.TOPBAR_MT_file_import.remove(menu_func_import) 75 | bpy.utils.unregister_class(ImportMelonRipOp) 76 | 77 | 78 | if __name__ == "__main__": 79 | register() 80 | 81 | 82 | # # # # # # # # # # # # # # # # # # # # # # # # # # # # 83 | # # # # # # # # # # # # # # # # # # # # # # # # # # # # # 84 | # # # # # # # # # # # # # # # # # # # # # # # # # # # # 85 | 86 | 87 | # Precomputed table used for toon mode. 88 | # TOON_INDEX_TABLE[n]/255 is a color that will pick the nth texel 89 | # when used as the UV for a 32x1 texture. 90 | TOON_INDEX_TABLE = [ 91 | 24, 60, 78, 93, 105, 115, 124, 132, 92 | 140, 148, 155, 161, 167, 173, 179, 185, 93 | 190, 195, 200, 205, 209, 214, 218, 222, 94 | 226, 230, 234, 238, 242, 246, 249, 253, 95 | ] 96 | 97 | 98 | def import_rip(filepath): 99 | name = os.path.basename(filepath) 100 | if name.endswith('.dump'): 101 | name = name[:-len('.dump')] # remove suffix 102 | 103 | with open(filepath, 'rb') as f: 104 | dump = f.read() 105 | 106 | rip = Rip(dump) 107 | rip.parse() 108 | 109 | importer = Importer(name, rip) 110 | importer.create_blender_objects() 111 | 112 | 113 | class Rip: 114 | """Handles parsing .dump file.""" 115 | 116 | def __init__(self, dump): 117 | self.dump = dump 118 | 119 | # Default value for stuff missing from older versions of .dump 120 | # files; initialize for backwards compatiblity. 121 | self.disp_cnt = 0 122 | self.toon_table = [0xFFFF] * 32 123 | 124 | def check_magic(self): 125 | magic = self.dump[:24] 126 | magic = magic.rstrip(b'\0') 127 | prefix = b'melon ripper v' 128 | if not magic.startswith(prefix): 129 | raise ShowErrorMsg('Not a MelonRipper file') 130 | 131 | version = magic[len(prefix):] # remove prefix 132 | try: 133 | version = int(str(version, encoding='ascii')) 134 | except ValueError: 135 | raise ShowErrorMsg('Weird magic in MelonRipper file') 136 | 137 | min_version = 1 138 | max_version = 2 139 | if version < min_version: 140 | raise ShowErrorMsg( 141 | 'MelonRipper file too old; ' 142 | 'version is %d; must be at least %d' % (version, min_version)) 143 | if version > max_version: 144 | raise ShowErrorMsg( 145 | 'MelonRipper file too new, update this addon! ' 146 | 'Version is %d; I only support %d' % (version, max_version) 147 | ) 148 | 149 | def parse(self): 150 | self.check_magic() 151 | 152 | pos = 24 # end of magic 153 | 154 | verts = [] 155 | colors = [] 156 | uvs = [] 157 | faces = [] 158 | face_materials = [] 159 | materials = {} 160 | 161 | texparam = 0 162 | texpal = 0 163 | polygon_attr = 0 164 | blend_mode = 0 165 | texture_width = 8 166 | texture_height = 8 167 | 168 | dump = self.dump 169 | while pos < len(dump): 170 | op = dump[pos:pos+4] 171 | pos += 4 172 | 173 | if op in [b"TRI ", b"QUAD"]: 174 | nverts = 3 if op == b"TRI " else 4 175 | 176 | if (polygon_attr>>4) & 3 == 3: 177 | # Skip shadow volumes; no idea what to do with these 178 | pos += (4*3 + 4*3 + 2*2)*nverts 179 | continue 180 | 181 | vert_index = len(verts) 182 | for _ in range(nverts): 183 | x, y, z = struct.unpack_from('<3i', dump, offset=pos) 184 | pos += 4*3 185 | x *= 2**-12 ; y *= 2**-12 ; z *= 2**-12 # fixed point to float 186 | verts.append((x, -z, y)) # switch Yup2Zup 187 | 188 | r, g, b = struct.unpack_from('<3i', dump, offset=pos) 189 | pos += 4*3 190 | # Get back to 0-31 range (undo melonDS transform) 191 | r = (r - 0xFFF) >> 12 192 | g = (g - 0xFFF) >> 12 193 | b = (b - 0xFFF) >> 12 194 | colors += [r, g, b] 195 | # The final vertex color is affected by whether 196 | # toon/highlight mode is enabled in disp_cnt, but 197 | # that doesn't come until the end of the file. So 198 | # for now just remember this so we can compute the 199 | # final color at the end. 200 | use_toon_highlight = (blend_mode == 2) 201 | colors.append(use_toon_highlight) 202 | 203 | s, t = struct.unpack_from('<2h', dump, offset=pos) 204 | pos += 2*2 205 | # Textures are upside down in Blender, so we flip them, 206 | # but that means we need to flip the T coord too. 207 | uvs += [s/16/texture_width, 1 - t/16/texture_height] 208 | 209 | material_args = (texparam, texpal, polygon_attr) 210 | if material_args not in materials: 211 | materials[material_args] = len(materials) 212 | material_index = materials[material_args] 213 | 214 | faces.append(tuple(range(vert_index, vert_index + nverts))) 215 | face_materials.append(material_index) 216 | 217 | elif op == b"TPRM": 218 | texparam, = struct.unpack_from('> 20) & 7) 221 | texture_height = 8 << ((texparam >> 23) & 7) 222 | 223 | elif op == b"TPLT": 224 | texpal, = struct.unpack_from('> 4) & 3 230 | pos += 4 231 | 232 | elif op == b"VRAM": 233 | self.vram_map_texture = struct.unpack_from('<4I', dump, offset=pos) 234 | pos += 4*4 235 | 236 | self.vram_map_texpal = struct.unpack_from('<8I', dump, offset=pos) 237 | pos += 4*8 238 | 239 | banks = [] 240 | 241 | # Banks A-D, 128K each 242 | for _ in range(4): 243 | banks.append(dump[pos : pos + (128 << 10)]) 244 | pos += 128 << 10 245 | 246 | # Banks E-G, E is 64K, F-G are 16K 247 | for _ in range(6): 248 | banks.append(dump[pos : pos + (16 << 10)]) 249 | pos += 16 << 10 250 | 251 | self.load_vram(banks) 252 | 253 | elif op == b"DISP": 254 | self.disp_cnt, = struct.unpack_from('>1) & 1) == 1 274 | 275 | for i in range(0, len(tmp), 4): 276 | r, g, b, is_toon_highlight = tmp[i:i+4] 277 | 278 | if not is_toon_highlight: 279 | # Normal color 280 | colors += [r/31, g/31, b/31, 1.0] 281 | 282 | elif is_highlight: 283 | # Highlight mode 284 | colors += [r/31, r/31, r/31, 1.0] 285 | 286 | else: 287 | # Toon mode 288 | c = TOON_INDEX_TABLE[r] / 255 289 | colors += [c, c, c, 1.0] 290 | 291 | return colors 292 | 293 | def load_vram(self, banks): 294 | # Use the memory map to compute how banks are laid out in VRAM. 295 | vram_tex = bytearray() 296 | vram_pal = bytearray() 297 | 298 | for i in range(4): 299 | mask = self.vram_map_texture[i] 300 | if mask & (1 << 0): vram_tex += banks[0] 301 | elif mask & (1 << 1): vram_tex += banks[1] 302 | elif mask & (1 << 2): vram_tex += banks[2] 303 | elif mask & (1 << 3): vram_tex += banks[3] 304 | else: vram_tex += b"\0" * (128 << 10) 305 | 306 | for i in range(8): 307 | mask = self.vram_map_texpal[i] 308 | if mask & (1 << 4): vram_pal += banks[4 + (i & 3)] 309 | elif mask & (1 << 5): vram_pal += banks[8] 310 | elif mask & (1 << 6): vram_pal += banks[9] 311 | else: vram_pal += b"\0" * (16 << 10) 312 | 313 | # Palette memory always read as u16s, decode it now. 314 | vram_pal = struct.unpack("<%dH" % (len(vram_pal) // 2), vram_pal) 315 | 316 | self.vram_tex = vram_tex 317 | self.vram_pal = vram_pal 318 | 319 | 320 | class Importer: 321 | """Handles creating Blender objects.""" 322 | 323 | def __init__(self, name, rip): 324 | self.name = name 325 | self.rip = rip 326 | 327 | # Initialize caches 328 | self.texture_cache = {} 329 | self.toon_table = None 330 | 331 | def create_blender_objects(self): 332 | rip = self.rip 333 | 334 | mesh = bpy.data.meshes.new(self.name) 335 | mesh.from_pydata(rip.verts, [], rip.faces) 336 | 337 | vertex_colors = mesh.vertex_colors.new() 338 | vertex_colors.data.foreach_set('color', rip.colors) 339 | 340 | uvs = mesh.uv_layers.new() 341 | uvs.data.foreach_set('uv', rip.uvs) 342 | 343 | mesh.polygons.foreach_set('material_index', rip.face_materials) 344 | 345 | mesh.validate() 346 | 347 | for material_args in rip.materials: 348 | mesh.materials.append(self.create_material(*material_args)) 349 | 350 | ob = bpy.data.objects.new(mesh.name, mesh) 351 | bpy.context.scene.collection.objects.link(ob) 352 | 353 | if bpy.ops.object.select_all.poll(): 354 | bpy.ops.object.select_all(action='DESELECT') 355 | ob.select_set(True) 356 | bpy.context.view_layer.objects.active = ob 357 | 358 | def get_texture(self, texparam, texpal): 359 | vramaddr = (texparam & 0xFFFF) << 3 360 | width = 8 << ((texparam >> 20) & 7) 361 | height = 8 << ((texparam >> 23) & 7) 362 | alpha0 = 0 if (texparam & (1<<29)) else 31 363 | texformat = (texparam >> 26) & 7 364 | # alpha0 only matters for paletted textures 365 | if texformat not in [2, 3, 4]: 366 | alpha0 = 0 367 | 368 | # Cache on everything the texture depends on 369 | cache_key = (vramaddr, width, height, alpha0, texformat, texpal) 370 | 371 | if cache_key not in self.texture_cache: 372 | self.texture_cache[cache_key] = self.create_texture(texparam, texpal) 373 | 374 | return self.texture_cache[cache_key] 375 | 376 | def create_texture(self, texparam, texpal): 377 | width = 8 << ((texparam >> 20) & 7) 378 | height = 8 << ((texparam >> 23) & 7) 379 | 380 | pixels, is_opaque = decode_texture(self.rip, texparam, texpal) 381 | 382 | img = bpy.data.images.new('NDS Texture', width, height, alpha=not is_opaque) 383 | img.pixels[:] = pixels 384 | img.pack() 385 | 386 | return img 387 | 388 | def get_toon_table(self): 389 | if self.toon_table is None: 390 | self.toon_table = self.create_toon_table() 391 | return self.toon_table 392 | 393 | def create_toon_table(self): 394 | pixels = [] 395 | for i in range(32): 396 | c = self.rip.toon_table[i] 397 | r = c & 0x1f 398 | g = (c >> 5) & 0x1f 399 | b = (c >> 10) & 0x1f 400 | pixels += [r/31, g/31, b/31, 1.0] 401 | 402 | img = bpy.data.images.new('NDS ToonTable', 32, 1, alpha=False) 403 | img.pixels[:] = pixels 404 | img.pack() 405 | 406 | return img 407 | 408 | def create_material(self, texparam, texpal, polygon_attr): 409 | mat = bpy.data.materials.new('NDS Material') 410 | 411 | texformat = (texparam >> 26) & 7 412 | blend_mode = (polygon_attr >> 4) & 0x3 413 | poly_alpha = (polygon_attr >> 16) & 0x1F 414 | shading = (self.rip.disp_cnt >> 1) & 1 415 | 416 | texture = self.get_texture(texparam, texpal) if texformat != 0 else None 417 | 418 | is_toon = blend_mode == 2 and shading == 0 419 | toon_table = self.get_toon_table() if is_toon else None 420 | 421 | if poly_alpha < 31: 422 | mat.blend_method = 'BLEND' 423 | elif texture and blend_mode in [0, 2]: 424 | if texformat in [1, 6]: 425 | # Translucent texture 426 | mat.blend_method = 'BLEND' 427 | elif texformat in [2, 3, 4] and (texparam & (1<<29)): 428 | # Palette texture with transparent alpha0 429 | mat.blend_method = 'CLIP' 430 | elif texformat == 5: 431 | # Compressed texture 432 | mat.blend_method = 'CLIP' 433 | 434 | mat.use_backface_culling = (polygon_attr>>6) & 1 == 0 435 | 436 | mat.use_nodes = True 437 | setup_nodetree( 438 | node_tree=mat.node_tree, 439 | poly_alpha=poly_alpha, 440 | texture=texture, 441 | repeat_s=bool( (texparam>>16) & 1 ), 442 | repeat_t=bool( (texparam>>17) & 1 ), 443 | flip_s=bool( (texparam>>18) & 1 ), 444 | flip_t=bool( (texparam>>19) & 1 ), 445 | blend_mode=blend_mode, 446 | toon_table=toon_table 447 | ) 448 | 449 | # Useful for debugging. 450 | mat['nds:TexParam'] = str(texparam) 451 | mat['nds:TexPal'] = str(texpal) 452 | mat['nds:PolygonAttr'] = str(polygon_attr) 453 | mat['nds:Texture Format'] = str(texformat) 454 | mat['nds:Polygon Mode'] = str(blend_mode) 455 | mat['nds:Polygon Alpha'] = str(poly_alpha) 456 | mat['nds:Polygon Back Surface'] = str((polygon_attr>>6)&1) 457 | mat['nds:Polygon From Surface'] = str((polygon_attr>>7)&1) 458 | 459 | return mat 460 | 461 | 462 | def setup_nodetree( 463 | node_tree, 464 | poly_alpha, 465 | texture, 466 | repeat_s, repeat_t, 467 | flip_s, flip_t, 468 | blend_mode, 469 | toon_table, 470 | ): 471 | # Will look like 472 | # 473 | # [ Vertex Color ] - [Combine] - [Transparency] - [Output] 474 | # / 475 | # [Texture] 476 | # 477 | texture_has_alpha = texture and texture.depth == 32 478 | needs_alpha = poly_alpha < 31 or (texture_has_alpha and blend_mode in [0, 2]) 479 | x = 120 480 | 481 | # Clear existing nodes 482 | while node_tree.nodes: 483 | node_tree.nodes.remove(node_tree.nodes[0]) 484 | 485 | # Output node 486 | output = node_tree.nodes.new(type='ShaderNodeOutputMaterial') 487 | output.location = 300, 300 488 | socket = output.inputs[0] 489 | 490 | # Tranparency 491 | if needs_alpha: 492 | mix_transp = node_tree.nodes.new(type='ShaderNodeMixShader') 493 | mix_transp.location = x, 230 494 | mix_transp.inputs[0].default_value = poly_alpha / 31 495 | node_tree.links.new(socket, mix_transp.outputs[0]) 496 | 497 | transp = node_tree.nodes.new(type='ShaderNodeBsdfTransparent') 498 | transp.location = x - 200, -30 499 | node_tree.links.new(mix_transp.inputs[1], transp.outputs[0]) 500 | 501 | socket = mix_transp.inputs[2] 502 | x -= 200 503 | 504 | if texture: 505 | # Mix node to combine vertex color and texture with the blend mode 506 | mix = node_tree.nodes.new(type='ShaderNodeMixRGB') 507 | mix.location = x, 350 508 | node_tree.links.new(socket, mix.outputs[0]) 509 | 510 | # Texture 511 | tex_img = texture_node( 512 | node_tree=node_tree, 513 | image=texture, 514 | repeat_s=repeat_s, 515 | repeat_t=repeat_t, 516 | flip_s=flip_s, 517 | flip_t=flip_t, 518 | location=(x - 70, 100), 519 | ) 520 | 521 | if blend_mode in [0, 2]: 522 | # Modulate mode: vertex_color * texture_color 523 | mix.label = 'Modulate' 524 | mix.blend_type = 'MULTIPLY' 525 | mix.inputs[0].default_value = 1 526 | node_tree.links.new(mix.inputs[2], tex_img.outputs['Color']) 527 | 528 | # Connect texture alpha to transparency 529 | if texture_has_alpha: 530 | if poly_alpha == 31: 531 | node_tree.links.new(mix_transp.inputs[0], tex_img.outputs['Alpha']) 532 | else: 533 | # Multiply poly_alpha and texture_alpha 534 | mul_alpha = node_tree.nodes.new(type='ShaderNodeMath') 535 | mul_alpha.location = x, 150 536 | mul_alpha.operation = 'MULTIPLY' 537 | mul_alpha.inputs[1].default_value = poly_alpha / 31 538 | node_tree.links.new(mix_transp.inputs[0], mul_alpha.outputs[0]) 539 | node_tree.links.new(mul_alpha.inputs[0], tex_img.outputs['Alpha']) 540 | 541 | else: 542 | # Decal mode; texture alpha is the mix factor 543 | mix.label = 'Decal' 544 | mix.blend_type = 'MIX' 545 | node_tree.links.new(mix.inputs[0], tex_img.outputs['Alpha']) 546 | node_tree.links.new(mix.inputs[2], tex_img.outputs['Color']) 547 | 548 | socket = mix.inputs[1] 549 | x -= 120 550 | 551 | x, y = x - 100, 320 552 | if texture: 553 | x, y = x - 40, y + 60 554 | 555 | # Toon table 556 | if toon_table: 557 | toon_tex = node_tree.nodes.new('ShaderNodeTexImage') 558 | toon_tex.location = x - 50, y + 100 559 | toon_tex.image = toon_table 560 | toon_tex.interpolation = 'Closest' 561 | toon_tex.extension = 'EXTEND' 562 | node_tree.links.new(socket, toon_tex.outputs['Color']) 563 | 564 | socket = toon_tex.inputs[0] 565 | x -= 300 566 | 567 | # Vertex color 568 | vcolor = node_tree.nodes.new(type='ShaderNodeVertexColor') 569 | vcolor.location = x, y 570 | vcolor.layer_name = 'Col' 571 | node_tree.links.new(socket, vcolor.outputs['Color']) 572 | 573 | 574 | def texture_node(node_tree, image, repeat_s, repeat_t, flip_s, flip_t, location): 575 | x, y = location 576 | 577 | tex_img = node_tree.nodes.new('ShaderNodeTexImage') 578 | tex_img.location = x - 240, y 579 | tex_img.image = image 580 | tex_img.interpolation = 'Closest' 581 | uv_socket = tex_img.inputs['Vector'] 582 | 583 | x -= 360 584 | 585 | # Wrapping 586 | if not repeat_s: flip_s = False 587 | if not repeat_t: flip_t = False 588 | if repeat_s == repeat_t and not flip_s and not flip_t: 589 | tex_img.extension = 'REPEAT' if repeat_s else 'EXTEND' 590 | else: 591 | # Use math nodes to emulate other wrap modes 592 | # Based on the glTF importer 593 | 594 | tex_img.extension = 'EXTEND' 595 | 596 | frame = node_tree.nodes.new('NodeFrame') 597 | frame.label = 'Texcoord Wrapping' 598 | 599 | # Combine XYZ 600 | com_uv = node_tree.nodes.new('ShaderNodeCombineXYZ') 601 | com_uv.parent = frame 602 | com_uv.location = x - 80, y - 110 603 | node_tree.links.new(uv_socket, com_uv.outputs[0]) 604 | u_socket = com_uv.inputs[0] 605 | v_socket = com_uv.inputs[1] 606 | x -= 120 607 | 608 | for i in [0, 1]: 609 | repeat = repeat_s if i == 0 else repeat_t 610 | flip = flip_s if i == 0 else flip_t 611 | socket = com_uv.inputs[i] 612 | if repeat and not flip: 613 | math = node_tree.nodes.new('ShaderNodeMath') 614 | math.parent = frame 615 | math.location = x - 140, y + 30 - i*200 616 | math.operation = 'WRAP' 617 | math.inputs[1].default_value = 0 618 | math.inputs[2].default_value = 1 619 | node_tree.links.new(socket, math.outputs[0]) 620 | socket = math.inputs[0] 621 | elif repeat and flip: 622 | math = node_tree.nodes.new('ShaderNodeMath') 623 | math.parent = frame 624 | math.location = x - 140, y + 30 - i*200 625 | math.operation = 'PINGPONG' 626 | math.inputs[1].default_value = 1 627 | node_tree.links.new(socket, math.outputs[0]) 628 | socket = math.inputs[0] 629 | else: 630 | # Clamp doesn't require a node since the default on the 631 | # Texture node is EXTEND. 632 | # Adjust node location for aesthetics though. 633 | if i == 0: 634 | com_uv.location[1] += 90 635 | if i == 0: 636 | u_socket = socket 637 | else: 638 | v_socket = socket 639 | x -= 180 640 | 641 | # Separate XYZ 642 | sep_uv = node_tree.nodes.new('ShaderNodeSeparateXYZ') 643 | sep_uv.parent = frame 644 | sep_uv.location = x - 140, y - 100 645 | node_tree.links.new(u_socket, sep_uv.outputs[0]) 646 | node_tree.links.new(v_socket, sep_uv.outputs[1]) 647 | uv_socket = sep_uv.inputs[0] 648 | 649 | x -= 180 650 | 651 | # UVMap node 652 | uv_map = node_tree.nodes.new('ShaderNodeUVMap') 653 | uv_map.location = x - 160, y - 70 654 | uv_map.uv_map = 'UVMap' 655 | node_tree.links.new(uv_socket, uv_map.outputs[0]) 656 | 657 | return tex_img 658 | 659 | 660 | def decode_texture(rip, texparam, texpal): 661 | color = [] 662 | alpha = [] 663 | 664 | vramaddr = (texparam & 0xFFFF) << 3 665 | width = 8 << ((texparam >> 20) & 7) 666 | height = 8 << ((texparam >> 23) & 7) 667 | alpha0 = 0 if (texparam & (1<<29)) else 31 668 | texformat = (texparam >> 26) & 7 669 | 670 | vram_tex = rip.vram_tex 671 | vram_pal = rip.vram_pal 672 | 673 | if texformat == 1: # A3I5 674 | texpal <<= 3 675 | for addr in range(vramaddr, vramaddr + width*height): 676 | pixel = vram_tex[addr & 0x7FFFF] 677 | color.append(vram_pal[( texpal + (pixel&0x1F) ) & 0xFFFF]) 678 | alpha.append( ((pixel>>3) & 0x1C) + (pixel>>6) ) 679 | 680 | elif texformat == 6: # A5I3 681 | texpal <<= 3 682 | for addr in range(vramaddr, vramaddr + width*height): 683 | pixel = vram_tex[addr & 0x7FFFF] 684 | color.append(vram_pal[( texpal + (pixel&0x7) ) & 0xFFFF]) 685 | alpha.append(pixel>>3) 686 | 687 | elif texformat == 2: # 4-color 688 | texpal <<= 2 689 | for addr in range(vramaddr, vramaddr + width*height//4): 690 | pixelx4 = vram_tex[addr & 0x7FFFF] 691 | p0 = pixelx4 & 0x3 692 | p1 = (pixelx4 >> 2) & 0x3 693 | p2 = (pixelx4 >> 4) & 0x3 694 | p3 = pixelx4 >> 6 695 | 696 | color.append(vram_pal[( texpal + p0 ) & 0xFFFF]) 697 | color.append(vram_pal[( texpal + p1 ) & 0xFFFF]) 698 | color.append(vram_pal[( texpal + p2 ) & 0xFFFF]) 699 | color.append(vram_pal[( texpal + p3 ) & 0xFFFF]) 700 | 701 | alpha.append(alpha0 if p0==0 else 31) 702 | alpha.append(alpha0 if p1==0 else 31) 703 | alpha.append(alpha0 if p2==0 else 31) 704 | alpha.append(alpha0 if p3==0 else 31) 705 | 706 | elif texformat == 3: # 16-color 707 | texpal <<= 3 708 | for addr in range(vramaddr, vramaddr + width*height//2): 709 | pixelx2 = vram_tex[addr & 0x7FFFF] 710 | p0 = pixelx2 & 0xF 711 | p1 = pixelx2 >> 4 712 | 713 | color.append(vram_pal[( texpal + p0 ) & 0xFFFF]) 714 | color.append(vram_pal[( texpal + p1 ) & 0xFFFF]) 715 | 716 | alpha.append(alpha0 if p0==0 else 31) 717 | alpha.append(alpha0 if p1==0 else 31) 718 | 719 | elif texformat == 4: # 256-color 720 | texpal <<= 3 721 | for addr in range(vramaddr, vramaddr + width*height): 722 | pixel = vram_tex[addr & 0x7FFFF] 723 | color.append(vram_pal[( texpal + pixel ) & 0xFFFF]) 724 | alpha.append(alpha0 if pixel==0 else 31) 725 | 726 | elif texformat == 7: # direct color 727 | for addr in range(vramaddr, vramaddr + width*height*2, 2): 728 | pixel = rip.vram_tex[addr & 0x7FFFF] 729 | pixel |= rip.vram_tex[(addr+1) & 0x7FFFF] << 8 730 | color.append(pixel) 731 | alpha.append(31 if (pixel & 0x8000) else 0) 732 | 733 | elif texformat == 5: # compressed 734 | color = [0] * (width * height) 735 | alpha = [0] * (width * height) 736 | block_color = [0, 0, 0, 0] 737 | block_alpha = [31, 31, 31, 31] 738 | x_ofs = 0 739 | y_ofs = 0 740 | 741 | texpal <<= 3 742 | 743 | for addr in range(vramaddr, vramaddr + width*height//4, 4): 744 | 745 | # Read slot1 data for this block 746 | 747 | slot1addr = 0x20000 + ((addr & 0x1FFFC) >> 1) 748 | if addr >= 0x40000: 749 | slot1addr += 0x10000 750 | 751 | palinfo = vram_tex[slot1addr & 0x7FFFF] 752 | palinfo |= vram_tex[(slot1addr + 1) & 0x7FFFF] << 8 753 | paloffset = texpal + ((palinfo & 0x3FFF) << 1) 754 | palmode = palinfo >> 14 755 | 756 | # Calculate block CLUT 757 | 758 | col0 = vram_pal[( paloffset ) & 0xFFFF] 759 | col1 = vram_pal[( paloffset + 1 ) & 0xFFFF] 760 | block_color[0] = col0 761 | block_color[1] = col1 762 | block_alpha[3] = 31 if palmode >= 2 else 0 763 | 764 | if palmode == 0: 765 | block_color[2] = vram_pal[( paloffset + 2 ) & 0xFFFF] 766 | block_color[3] = 0 767 | 768 | elif palmode == 2: 769 | block_color[2] = vram_pal[( paloffset + 2 ) & 0xFFFF] 770 | block_color[3] = vram_pal[( paloffset + 3 ) & 0xFFFF] 771 | 772 | elif palmode == 1: 773 | r0 = col0 & 0x001F 774 | g0 = col0 & 0x03E0 775 | b0 = col0 & 0x7C00 776 | r1 = col1 & 0x001F 777 | g1 = col1 & 0x03E0 778 | b1 = col1 & 0x7C00 779 | 780 | r2 = (r0 + r1) >> 1 781 | g2 = ((g0 + g1) >> 1) & 0x03E0 782 | b2 = ((b0 + b1) >> 1) & 0x7C00 783 | 784 | block_color[2] = r2 | g2 | b2 785 | block_color[3] = 0 786 | 787 | else: 788 | r0 = col0 & 0x001F 789 | g0 = col0 & 0x03E0 790 | b0 = col0 & 0x7C00 791 | r1 = col1 & 0x001F 792 | g1 = col1 & 0x03E0 793 | b1 = col1 & 0x7C00 794 | 795 | r2 = (r0*5 + r1*3) >> 3 796 | g2 = ((g0*5 + g1*3) >> 3) & 0x03E0 797 | b2 = ((b0*5 + b1*3) >> 3) & 0x7C00 798 | 799 | r3 = (r0*3 + r1*5) >> 3 800 | g3 = ((g0*3 + g1*5) >> 3) & 0x03E0 801 | b3 = ((b0*3 + b1*5) >> 3) & 0x7C00 802 | 803 | block_color[2] = r2 | g2 | b2 804 | block_color[3] = r3 | g3 | b3 805 | 806 | # Read block of 4x4 pixels at addr 807 | # 2bpp indices into the block CLUT 808 | 809 | for y in range(4): 810 | ofs = y_ofs + y*width + x_ofs 811 | 812 | pixelx4 = vram_tex[(addr + y) & 0x7FFFF] 813 | 814 | p0 = pixelx4 & 0x3 815 | p1 = (pixelx4 >> 2) & 0x3 816 | p2 = (pixelx4 >> 4) & 0x3 817 | p3 = pixelx4 >> 6 818 | 819 | color[ofs] = block_color[p0] 820 | color[ofs+1] = block_color[p1] 821 | color[ofs+2] = block_color[p2] 822 | color[ofs+3] = block_color[p3] 823 | 824 | alpha[ofs] = block_alpha[p0] 825 | alpha[ofs+1] = block_alpha[p1] 826 | alpha[ofs+2] = block_alpha[p2] 827 | alpha[ofs+3] = block_alpha[p3] 828 | 829 | # Advance to next block position 830 | 831 | x_ofs += 4 832 | if x_ofs == width: 833 | x_ofs = 0 834 | y_ofs += 4*width 835 | 836 | # Decode to floats 837 | # Also reverse the rows so the image is right-side-up 838 | pixels = [] 839 | for t in reversed(range(height)): 840 | for i in range(t*width, (t+1)*width): 841 | c, a = color[i], alpha[i] 842 | r = c & 0x1f 843 | g = (c >> 5) & 0x1f 844 | b = (c >> 10) & 0x1f 845 | pixels += [r/31, g/31, b/31, a/31] 846 | 847 | is_opaque = all(a == 31 for a in alpha) 848 | 849 | return pixels, is_opaque 850 | --------------------------------------------------------------------------------