├── 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 |
--------------------------------------------------------------------------------