October 18, 20241 yr Hi! I want to rip from a ps2 visual novel game developed called "Junjou Romantica Koi no Doki Doki Daisakusen" by Marvelous Entertainment. I've extracted the files of the game, but the script and images, which i'm interested, are stored in .PTD files இ௰இ Here are the files https://drive.google.com/drive/folders/1JcVjA7iT3Fr9dg7QzTUwZ5fJojLyxDsi?usp=sharing Thank you very much!
December 8, 2025Dec 8 SLES_526.07.ELF (PS2 game executable) SCRIPT.PTD (1,728,512 bytes) 1. INITIAL FILE ANALYSIS: hexdump -C SCRIPT.PTD | head -50 First 32 bytes: unknown header Bytes 0x20-0x11F: 256-byte table Rest: encrypted data With Radare2, I examined the ELF a bit: Found function at 0x0010da30 (file handling?) 3. CALL TRACING: 0x0010da30 → 0x10ccf0 → 0x10a3f8 → 0x0010d850 4. DECODING FUNCTION ANALYSIS (0x0010d850): 🔓 ALGORITHMS FOUND: 1. LZSS DECOMPRESSION: def yklz_lzss_decompress(data): param_byte = data[7] # Always 0x0A shift = param_byte - 8 # 2 mask = (1 << shift) - 1 # 3 decompressed_size = int.from_bytes(data[8:12], 'little') # ... standard LZSS implementation 2. DECRYPTION ALGORITHM (0x0010D850): def junroma_decrypt_exact(data): if len(data) <= 0x120: return data result = bytearray(data) t1 = 0 # Initialized to 0 base_ptr = 0 # Base pointer (t3 in code) for i in range(0x120, len(data)): encrypted_byte = data[i] # t7 = (t1 XOR encrypted_byte) + base_ptr t7 = (t1 ^ encrypted_byte) + base_ptr # decrypted_byte = TABLE[(t7 + 0x20) & 0xFF] idx = (t7 + 0x20) & 0xFF decrypted_byte = SUBSTITUTION_TABLE[idx] result[i] = decrypted_byte # t1 = (t1 - 1) & 0xFF t1 = (t1 - 1) & 0xFF return bytes(result) 3. SUBSTITUTION TABLE (0x0055F7A0): SUBSTITUTION_TABLE = bytes([ 0x82, 0x91, 0x42, 0x88, 0x35, 0xBB, 0x0F, 0x85, 0x96, 0x2C, 0x56, 0xFF, 0x8E, 0x3C, 0x7C, 0x0D, 0x61, 0xBF, 0xB8, 0xEF, 0xD1, 0x16, 0x07, 0xEE, 0x4F, 0x09, 0xCB, 0x0C, 0xE2, 0xB1, 0xDD, 0x12, 0xFB, 0x08, 0x89, 0x8B, 0x03, 0xC9, 0x27, 0x19, 0x6A, 0x32, 0x5D, 0xCD, 0x98, 0x17, 0xF4, 0xE7, 0x9F, 0x1A, 0xF9, 0x1B, 0x6C, 0x5C, 0x44, 0x3B, 0x6E, 0x3E, 0x60, 0xD5, 0x4D, 0x21, 0x43, 0x4E, 0x65, 0xFD, 0x0B, 0x92, 0x8C, 0x2B, 0x41, 0xED, 0x76, 0x22, 0xC1, 0x74, 0xA3, 0x47, 0x14, 0x67, 0xE0, 0xDE, 0x0A, 0xE3, 0x1E, 0x5F, 0x1C, 0x84, 0xEA, 0xA0, 0x02, 0x69, 0x52, 0xB9, 0xC5, 0x20, 0x6D, 0xC8, 0x79, 0xD0, 0x05, 0x77, 0xB3, 0xDA, 0x7F, 0xBA, 0xF1, 0xB2, 0x72, 0x9E, 0x9A, 0xB5, 0x6B, 0x1F, 0x58, 0xD2, 0x11, 0xA6, 0xD8, 0x80, 0x23, 0x46, 0x73, 0xB6, 0x2E, 0xE4, 0xAD, 0x81, 0xC6, 0xDB, 0x57, 0x95, 0x01, 0xEC, 0xC4, 0xF2, 0xEB, 0xDF, 0xC0, 0x28, 0x49, 0xE9, 0x37, 0x15, 0x5E, 0x34, 0x31, 0x00, 0xA8, 0x8D, 0x9C, 0xBC, 0xA2, 0x62, 0x90, 0xCA, 0x66, 0x3D, 0x70, 0x4C, 0x24, 0x48, 0xBE, 0xA9, 0x5A, 0x94, 0xD4, 0xF5, 0x1D, 0x38, 0x25, 0x8F, 0x26, 0xB4, 0x83, 0x45, 0x8A, 0x5B, 0xFC, 0x63, 0xA4, 0xFA, 0xAF, 0xF8, 0x10, 0xAB, 0x53, 0x54, 0x2F, 0xDC, 0xF6, 0xD3, 0x0E, 0x68, 0xE1, 0x59, 0xAA, 0x30, 0xC2, 0x51, 0xD7, 0xE6, 0xB0, 0xBD, 0x6F, 0x06, 0x93, 0x7D, 0x3A, 0xF7, 0x04, 0x78, 0x2D, 0x55, 0xA5, 0x2A, 0xA7, 0x40, 0x71, 0x9B, 0x7A, 0xC3, 0xD6, 0xFE, 0xCF, 0xE5, 0x4A, 0x7B, 0xC7, 0x99, 0xF0, 0xCC, 0x3F, 0xAC, 0xB7, 0x87, 0x7E, 0x33, 0x13, 0x97, 0xE8, 0x75, 0xCE, 0xA1, 0x50, 0x4B, 0x39, 0xD9, 0x86, 0x64, 0x9D, 0x29, 0x36, 0x18, 0xAE, 0xF3 ]) EXTRATION CODE (PYTHON) import os import struct # ==================== SUBSTITUTION TABLE ==================== SUBSTITUTION_TABLE = bytes([ 0x82, 0x91, 0x42, 0x88, 0x35, 0xBB, 0x0F, 0x85, 0x96, 0x2C, 0x56, 0xFF, 0x8E, 0x3C, 0x7C, 0x0D, # ... (same as above, shortened for brevity) 0xE8, 0x75, 0xCE, 0xA1, 0x50, 0x4B, 0x39, 0xD9, 0x86, 0x64, 0x9D, 0x29, 0x36, 0x18, 0xAE, 0xF3 ]) # ==================== LZSS DECOMPRESSION ==================== def yklz_lzss_decompress(data): if len(data) < 16: raise ValueError("File too small (smaller than the 16-byte header).") # Extract header parameters param_byte = data[7] shift = param_byte - 8 if shift < 0: shift = 4 mask = (1 << shift) - 1 # Decompressed size (uint32, little-endian) decompressed_size = int.from_bytes(data[8:12], 'little') # LZSS decompression src_pos = 16 output = bytearray() while len(output) < decompressed_size and src_pos < len(data): flags = data[src_pos] src_pos += 1 for bit in range(8): if len(output) >= decompressed_size or src_pos >= len(data): break is_reference = (flags & 0x80) != 0 flags = (flags << 1) & 0xFF if not is_reference: # Literal byte output.append(data[src_pos]) src_pos += 1 else: # Reference (match) if src_pos + 1 >= len(data): break b1 = data[src_pos] b2 = data[src_pos + 1] src_pos += 2 length = (b1 >> shift) + 3 offset = ((b1 & mask) << 8) | b2 offset += 1 start_index = len(output) - offset for i in range(length): idx = start_index + i if idx < 0: output.append(0) else: output.append(output[idx]) return bytes(output) # ==================== JRS DECRYPTION ==================== def junroma_decrypt_exact(data): """ EXACT implementation of the decryption algorithm (0x0010D850). Confirmed by debugger analysis. Initial t1 = 0 t3 (base_ptr) = 0 for our data Start offset: 0x120 """ if len(data) <= 0x120: return data result = bytearray(data) # Initial t1 = 0 (confirmed by debugger) t1 = 0 # base_ptr = 0 (t3 = base pointer to data) base_ptr = 0 for i in range(0x120, len(data)): encrypted_byte = data[i] # t7 = (t1 XOR encrypted_byte) + base_ptr t7 = (t1 ^ encrypted_byte) + base_ptr # decrypted_byte = TABLE[(t7 + 0x20) & 0xFF] idx = (t7 + 0x20) & 0xFF decrypted_byte = SUBSTITUTION_TABLE[idx] result[i] = decrypted_byte # t1 = (t1 - 1) & 0xFF t1 = (t1 - 1) & 0xFF return bytes(result) # ==================== JRS FILE EXTRACTION ==================== def extract_jrs_files(data): """Extracts individual .JRS files from decrypted data.""" jrs_magic = b'\x8F\x83\xDB\xCF' # JRS Magic: "純ロマ" files = [] pos = 0 while pos < len(data): idx = data.find(jrs_magic, pos) if idx == -1: break # Determine size (find next magic or use header size) next_magic = data.find(jrs_magic, idx + 4) if next_magic != -1: file_size = next_magic - idx else: file_size = len(data) - idx file_data = data[idx:idx + file_size] files.append({ 'offset': idx, 'size': file_size, 'data': file_data, 'is_jrs': True }) pos = idx + file_size return files # ==================== COMPLETE EXTRACTOR ==================== def extract_all_yklz_sections(): """Extracts and processes all YKLZ sections from SCRIPT.PTD.""" print("=== JUNJOU ROMANTICA PS2 EXTRACTOR ===") # Read file try: with open('SCRIPT.PTD', 'rb') as f: raw_data = f.read() print(f"File SCRIPT.PTD read: {len(raw_data):,} bytes") except FileNotFoundError: print("❌ ERROR: SCRIPT.PTD not found") return # Search for YKLZ sections yklz_signature = b'YKLZ' positions = [] pos = 0 while True: idx = raw_data.find(yklz_signature, pos) if idx == -1: break positions.append(idx) pos = idx + 1 print(f"YKLZ sections found: {len(positions)}") if not positions: print("❌ No YKLZ sections found") return # Create directories os.makedirs('EXTRACTED', exist_ok=True) os.makedirs('EXTRACTED/JRS_FILES', exist_ok=True) total_jrs = 0 # Process each section for i, pos in enumerate(positions): print(f"\n--- Processing section #{i:03d} (offset 0x{pos:08X}) ---") # Extract YKLZ data if i + 1 < len(positions): next_pos = positions[i + 1] yklz_data = raw_data[pos:next_pos] else: yklz_data = raw_data[pos:] try: # 1. Decompress LZSS decompressed = yklz_lzss_decompress(yklz_data) print(f" Decompressed: {len(decompressed):,} bytes") # Save decompressed version decomp_filename = f'EXTRACTED/section_{i:03d}_decompressed.bin' with open(decomp_filename, 'wb') as f: f.write(decompressed) # 2. Apply decryption (except section 0) if i == 0: # Section 0 is ASCII text without encryption decrypted = decompressed print(f" Section 0 (metadata) - not decrypted") else: decrypted = junroma_decrypt_exact(decompressed) print(f" Decryption applied (from offset 0x120)") # Save decrypted version decrypted_filename = f'EXTRACTED/section_{i:03d}_decrypted.bin' with open(decrypted_filename, 'wb') as f: f.write(decrypted) # 3. Extract JRS files if decrypted[:4] == b'\x8F\x83\xDB\xCF': print(f" ✅ Contains JRS file(s)") jrs_files = extract_jrs_files(decrypted) for j, jrs in enumerate(jrs_files): filename = f'EXTRACTED/JRS_FILES/section_{i:03d}_file_{j:03d}.jrs' with open(filename, 'wb') as f: f.write(jrs['data']) print(f" JRS file #{j}: {jrs['size']:,} bytes") total_jrs += 1 # Analyze JRS header if len(jrs['data']) >= 0x40: version = int.from_bytes(jrs['data'][4:8], 'little') declared_size = int.from_bytes(jrs['data'][12:16], 'little') print(f" Version: {version}, Declared size: {declared_size:,}") # 4. For section 0, show ASCII content if i == 0: try: text = decrypted.decode('ascii', errors='ignore').strip() if text: print(f" ASCII content: {text[:100]}...") # Save as text text_filename = f'EXTRACTED/section_000_metadata.txt' with open(text_filename, 'w', encoding='utf-8') as f: f.write(text) except: pass except Exception as e: print(f" ❌ Error: {e}") import traceback traceback.print_exc() print(f"\n{'='*60}") print("EXTRACTION COMPLETED!") print(f"Total JRS files extracted: {total_jrs}") print(f"Everything saved to: EXTRACTED/") # ==================== EXECUTION ==================== if __name__ == "__main__": extract_all_yklz_sections() NOW, THE RESULTING FILES ALL HAVE A HEADER WITH THE MAGIC NUMBER "JUNROMA" AND APPEAR TO BE ENCRYPTED. TRYING THE SAME ALGORITHMS OR APPLYING SHIFT-JIS JAPANESE DIDN'T WORK. Edited December 14, 2025Dec 14 by ninoochan
December 8, 2025Dec 8 Supporter On 10/17/2024 at 10:59 PM, Pato said: Hi! I want to rip from a ps2 visual novel game developed called "Junjou Romantica Koi no Doki Doki Daisakusen" by Marvelous Entertainment. I've extracted the files of the game, but the script and images, which i'm interested, are stored in .PTD files இ௰இ Here are the files https://drive.google.com/drive/folders/1JcVjA7iT3Fr9dg7QzTUwZ5fJojLyxDsi?usp=sharing Thank you very much! THE IMAGE.PTD ITS A CONTAINER, ALL TIM2 IMAGES ARE COMPRESSED WITH A CUSTOM LZSS. Edited December 8, 2025Dec 8 by Rabatini
December 9, 2025Dec 9 Supporter There is the tool PS2JunjouUnpacker-decompressor.zip Edited December 9, 2025Dec 9 by Rabatini
December 11, 2025Dec 11 1. FILE STRUCTURE (script.ptd) | Offset | Size | Content | |--------|------|---------| | `0x00` | 32 bytes | **Header** with signature "PETA" (50 45 54 41) | | `0x20` | 256 bytes | **SBOX** (Substitution Box for decryption) | | `0x120` | 1,728,224 bytes | **Encrypted data** | 2. DECRYPTION & DECOMPRESSION PROCESS script.ptd → Decryption → YKLZ/LZSS → Binary Script 249 YKLZ/LZSS compressed sections found in decrypted data YKLZ/LZSS decompression works correctly 3. DECOMPRESSED SCRIPT ANALYSIS Header identified: `純ロマ = "JUNROMAN" Current issue:Shift-JIS Japanese text not found - appears encrypted even after decompression Example decompressed output: 純ロマ####@###シg##@###ト###4.##X)##イ*##4... 4. SUSPECTED SCRIPT STRUCTURE?? [Shift-JIS Text] [Padding "####"] [Command "@" + 3 params] [More Text]... Parsing logic??? 1. Parser detects `@` (0x40) → Command indicator? 2. Reads next 3 bytes → Command parameters? 3. Processes Shift-JIS text (1-2 byte characters)? 4. Skips padding `#` (0x23) → Alignment bytes 5. GAME EXECUTION FLOW (pcsx2 debugger) fcn.0010e048 (Interpreter) ↓ fcn.0010ded0 (Parser) ↓ fcn.00119fc0 / fcn.0011a0ec (Handles dialogues?) ↓ fcn.00106800 (Context configuration) ↓ fcn.001068d0 (Text rendering) ↓ fcn.0016e400 / fcn.0016e4a8 (Unknown final processing) 6. CURRENT PROBLEM The text appears garbled/encrypted even after YKLZ decompression....... Additional encryption layer** after LZSS decompression?? Edited December 14, 2025Dec 14 by ninoochan
December 14, 2025Dec 14 When debugging function 0010D850, I found these filenames in the t0 register (after the decryption result; it also loads the filenames of files with the .JRS header into memory): LOGO.JRS MAINSCRIPT.JRS SCENARIO.JRS SCENARIO00_ROMA.JRS SCENARIO00_ROMA_TGS.JRS SCENARIO00_ROMA_TRIAL.JRS SCENARIO01_EGOI.JRS SCENARIO01_EGOI_TGS.JRS RES/SCRIPT RES/SCRIPT/SC RES/SCRIPT/SC/00_ROMA RES/SCRIPT/SC/01_EGOI RES/SCRIPT/SC/02_TERO RES/SCRIPT/SC/10_KAISOU SCRIPT.SVL etc... The flow is: SCRIPT.PTD (disk) → [AT GAME STARTUP] → Decompress YKLZ → Decrypt .JRS → Load into PS2 memory --- Knowing this, I set a read/write/change breakpoint in the PS2 debugger after the initial file-loading process in RAM. In this case, I set it at 0056AC20 (which corresponds to `SCENARIO00_ROMA.JRS`), and as expected, this is the first dialogue shown in the Romantica route. 0011A010 (game_load_resource) ↓ 0010DED0 (file_system_wrapper) ↓ 0010DB58 (process_filename) → Converts "SCENARIO00_ROMA.JRS" to something ↓ 0010DC48 (binary_search) → Searches table using 0018E4FC (optimized strcmp) ↓ 0010DD98 (get_file_info) → Returns data_ptr (already in memory?) ↓ RETURNS to 0011A010 ↓ ...... 00106800 (PROCESS .JRS?) → UNKNOWN ↓ 001068D0 (FINISHES?) → Another unknown --- FUNCTIONS 0010DC48 – Binary Search - Searches for files in a master table sorted alphabetically (only to verify file calls are correct) 0010DD98– Get File Info - Returns data_ptr (already in memory), size, and flags 0010DED0 – File System Wrapper - Orchestrates the search and retrieval process 0011A010 – Game Resource Loader - Manages memory pool - Calls the entire system --- The only thing left to do is trace the flow and see what gets called after the .JRS file is loaded (which is obviously to process and render the text). With the information on how the game processes and displays text, we can process the previously extracted files from script.ptd to view the Japanese dialogues. I need to rest my brain… haha Edited December 14, 2025Dec 14 by ninoochan
July 1Jul 1 Sorry for the delay; I only got back to it last week and managed to make some progress (thanks to my "useless" comments, I knew where I’d left off, haha).I finally did it—I found the function that was stripping the final compression from the text.I hadn't mentioned it before, but I love Junjou Romantica! So, I took this opportunity to translate it, into Spanish.#!/usr/bin/env python3"""JUNROMAN Script Extractor for PS2 visual novel (SCRIPT.PTD)=============================================================Algoritmo de descifrado real, confirmado mediante reverse engineeringcompleto del binario del juego (función de descifrado real ubicadaen 0x0013579C del ELF, encontrada tras trazar la cadena de llamadasdesde el loop de renderizado principal).FORMATO DEL PTD: 0x00-0x1F: header "PETA" + campos 0x20-0x11F: SBOX global (256 bytes), usada SOLO para descifrar el ÍNDICE de archivos (no el contenido de los JRS) 0x120+: índice de archivos cifrado con la SBOX, t3=0x00: result[i] = SBOX[t1 ^ enc[i]], t1 -= 1 cada byte Cada entrada del índice descifrado: [offset(u32)][size(u32)][flag(u32)][pad(u32)][nombre...] 'offset' apunta DIRECTAMENTE a una sección 'YKLZ' en el PTD (confirmado: ptd[offset:offset+4] == b'YKLZ').FORMATO DE CADA .JRS (tras descomprimir YKLZ/LZSS): 0x000: magic 8F83DBCF ("純ロマ") + header 0x040-0x13F: SUBSTITUTION_TABLE propia del archivo (256 bytes) +0x18 (campo header, u32): offset al BLOB DE TEXTO CIFRADO +0x2C (campo header, u16!): longitud del blob cifrado (halfword) +0x34 (campo header, byte): flag "requiere descifrado" (0/1)ALGORITMO DE DESCIFRADO DEL BLOB (in-place, byte a byte): sub_table = jrs[0x40:0x140] # 256 bytes blob_offset = u32(jrs[0x18:0x1C]) length = u16(jrs[0x2C:0x2E]) flag = jrs[0x34] if flag != 0: t4 = 0 for i in range(length): pos = blob_offset + i idx = (t4 ^ jrs[pos]) & 0xFF jrs[pos] = sub_table[idx] t4 = (t4 - 1) & 0xFFEl blob descifrado contiene texto Shift-JIS/CP932 con rutas dearchivo ASCII (imágenes, audio, voz) intercaladas, todo separadopor bytes 0x00: [texto japonés]\\0[ruta imagen]\\0[texto adicional]\\0[ruta audio]\\0..."""import structimport osimport sysJUNROMAN_MAGIC = bytes([0x8F, 0x83, 0xDB, 0xCF])# ─────────────────────────────────────────────────────────────# YKLZ / LZSS decompression# ─────────────────────────────────────────────────────────────def yklz_lzss_decompress(data): if len(data) < 16: raise ValueError("Datos demasiado pequeños para YKLZ") param_byte = data[7] shift = param_byte - 8 if param_byte >= 8 else 4 mask = (1 << shift) - 1 decompressed_size = struct.unpack('<I', data[8:12])[0] src_pos = 16 output = bytearray() while len(output) < decompressed_size and src_pos < len(data): flags = data[src_pos]; src_pos += 1 for _ in range(8): if len(output) >= decompressed_size or src_pos >= len(data): break is_ref = (flags & 0x80) != 0 flags = (flags << 1) & 0xFF if not is_ref: output.append(data[src_pos]); src_pos += 1 else: if src_pos + 1 >= len(data): break b1 = data[src_pos]; b2 = data[src_pos + 1]; src_pos += 2 length = (b1 >> shift) + 3 offset = ((b1 & mask) << 8) | b2 + 1 start = len(output) - offset for i in range(length): idx = start + i output.append(0 if idx < 0 else output[idx]) return bytes(output)# ─────────────────────────────────────────────────────────────# PTD directory index (encrypted with global SBOX, t3=0x00)# ─────────────────────────────────────────────────────────────def parse_ptd_directory(ptd_data, index_size=0x20000): """ Descifra el índice de archivos del PTD y devuelve una lista de tuplas (offset, size, flag, name) para cada archivo .JRS (o cualquier otro tipo) encontrado. """ sbox = ptd_data[0x20:0x120] encrypted = ptd_data[0x120:] decrypted = bytearray() t1 = 0x00 for i in range(min(index_size, len(encrypted))): lookup = (t1 ^ encrypted[i]) & 0xFF decrypted.append(sbox[lookup]) t1 = (t1 - 1) & 0xFF # Escanear el índice descifrado buscando todo uint32 que apunte # a una sección 'YKLZ' válida dentro del PTD original. entries = [] for i in range(0, len(decrypted) - 4, 4): val = struct.unpack('<I', decrypted[i:i + 4])[0] if 0 < val < len(ptd_data) - 4 and ptd_data[val:val + 4] == b'YKLZ': fsize = struct.unpack('<I', decrypted[i + 4:i + 8])[0] flag = struct.unpack('<I', decrypted[i + 8:i + 12])[0] name_raw = decrypted[i + 16:i + 16 + 64] null_idx = name_raw.find(b'\x00') if null_idx == -1: null_idx = len(name_raw) name = name_raw[:null_idx].decode('ascii', errors='replace') entries.append((val, fsize, flag, name)) return entries# ─────────────────────────────────────────────────────────────# JRS decryption (per-file substitution table)# ─────────────────────────────────────────────────────────────def decrypt_jrs(jrs_data): """ Aplica el algoritmo de descifrado real sobre un JRS ya descomprimido. Devuelve una nueva copia con el blob de texto descifrado in-place (el resto del archivo queda igual). """ if jrs_data[:4] != JUNROMAN_MAGIC: return jrs_data if len(jrs_data) < 0x140: return jrs_data output = bytearray(jrs_data) sub_table = output[0x40:0x140] if len(sub_table) < 0x100: return bytes(output) flag = output[0x34] if flag == 0: return bytes(output) blob_offset = struct.unpack('<I', output[0x18:0x1C])[0] length = struct.unpack('<H', output[0x2C:0x2E])[0] if blob_offset <= 0 or blob_offset + length > len(output): return bytes(output) t4 = 0 for i in range(length): pos = blob_offset + i idx = (t4 ^ output[pos]) & 0xFF output[pos] = sub_table[idx] t4 = (t4 - 1) & 0xFF return bytes(output)def extract_strings_from_blob(blob): """ Separa el blob de texto descifrado en strings individuales, usando 0x00 como separador (formato nativo del motor JUNROMAN). Filtra strings vacías o puramente binarias. """ raw_strings = blob.split(b'\x00') results = [] for raw in raw_strings: if not raw: continue try: text = raw.decode('cp932', errors='strict') except UnicodeDecodeError: text = raw.decode('cp932', errors='replace') bad = text.count('\ufffd') if bad > len(text) * 0.3: continue results.append(text) return results# ─────────────────────────────────────────────────────────────# Extracción completa# ─────────────────────────────────────────────────────────────def extract_all_from_ptd(ptd_path, out_path=None, name_filter=None): with open(ptd_path, 'rb') as f: ptd_data = f.read() print(f"Archivo: {ptd_path} ({len(ptd_data):,} bytes)") entries = parse_ptd_directory(ptd_data) print(f"Archivos en el índice: {len(entries)}") jrs_entries = [e for e in entries if e[3].endswith('.JRS')] print(f"Archivos .JRS: {len(jrs_entries)}") if name_filter: jrs_entries = [e for e in jrs_entries if name_filter in e[3]] print(f"Tras filtro '{name_filter}': {len(jrs_entries)}") all_output = [] processed = 0 decrypted_count = 0 for f0, fsize, flag, name in jrs_entries: try: raw = ptd_data[f0:f0 + fsize + 0x40] decompressed = yklz_lzss_decompress(raw) except Exception: continue if decompressed[:4] != JUNROMAN_MAGIC: continue processed += 1 decrypted = decrypt_jrs(decompressed) blob_offset_field = struct.unpack('<I', decrypted[0x18:0x1C])[0] need_decrypt = decrypted[0x34] if len(decrypted) > 0x34 else 0 if need_decrypt: decrypted_count += 1 blob = decrypted[blob_offset_field:] if blob_offset_field else decrypted strings = extract_strings_from_blob(blob) japanese_strings = [ s for s in strings if any(0x3040 <= ord(c) <= 0x30FF or 0x4E00 <= ord(c) <= 0x9FFF for c in s) ] if japanese_strings: all_output.append(f"\n{'='*60}") all_output.append(f"# {name} (PTD offset=0x{f0:X})") all_output.append(f"{'='*60}") all_output.extend(japanese_strings) if processed % 50 == 0: print(f" Procesados {processed}/{len(jrs_entries)}...") print(f"\nResultado:") print(f" Archivos JRS procesados: {processed}") print(f" Con descifrado aplicado: {decrypted_count}") print(f" Bloques con texto japonés: {len(all_output)}") output_text = '\n'.join(all_output) if out_path: with open(out_path, 'w', encoding='utf-8') as f: f.write(output_text) print(f"\nGuardado en: {out_path}") return all_outputif name == '__main__': ptd_file = sys.argv[1] if len(sys.argv) > 1 else 'SCRIPT.PTD' out_file = sys.argv[2] if len(sys.argv) > 2 else None name_filter = sys.argv[3] if len(sys.argv) > 3 else None if not os.path.exists(ptd_file): print(f"Error: no se encuentra {ptd_file}") sys.exit(1) extract_all_from_ptd(ptd_file, out_file, name_filter)now for images:#!/usr/bin/env python3"""extract_images.py — Extractor de texturas TIM2 (PS2) desde IMAGE.PTD========================================================================Mismo contenedor PTD que SCRIPT.PTD (header "PETA", índice cifrado conSBOX global + t1=0x00, entradas comprimidas con YKLZ/LZSS), pero acácada entrada es una textura .TM2 (formato nativo "TIM2" de la PS2)en vez de un blob de texto cifrado.Soporta: - CLUT8 (8bpp, paleta de 256 colores) — con el reordenamiento de paleta ("unswizzle") típico de la GS de PS2 - CLUT4 (4bpp, paleta de 16 colores) - Color directo 16/24/32bpp (sin paleta) - Múltiples "pictures" dentro de un mismo .TM2 (raro, pero el formato lo permite vía el campo 'count' del header)Uso: python3 extract_images.py IMAGE.PTD carpeta_salida/"""import structimport osimport sysimport numpy as npfrom PIL import ImageYKLZ_MAGIC = b'YKLZ'TIM2_MAGIC = b'TIM2'def yklz_lzss_decompress(data): if len(data) < 16: raise ValueError("Datos demasiado pequeños para YKLZ") param_byte = data[7] shift = param_byte - 8 if param_byte >= 8 else 4 mask = (1 << shift) - 1 decompressed_size = struct.unpack('<I', data[8:12])[0] src_pos = 16 output = bytearray() while len(output) < decompressed_size and src_pos < len(data): flags = data[src_pos]; src_pos += 1 for _ in range(8): if len(output) >= decompressed_size or src_pos >= len(data): break is_ref = (flags & 0x80) != 0 flags = (flags << 1) & 0xFF if not is_ref: output.append(data[src_pos]); src_pos += 1 else: if src_pos + 1 >= len(data): break b1 = data[src_pos]; b2 = data[src_pos + 1]; src_pos += 2 length = (b1 >> shift) + 3 offset = ((b1 & mask) << 8) | b2 + 1 start = len(output) - offset for i in range(length): idx = start + i output.append(0 if idx < 0 else output[idx]) return bytes(output)def parse_ptd_directory(ptd_data, index_size=0x20000): sbox = ptd_data[0x20:0x120] encrypted = ptd_data[0x120:] decrypted = bytearray() t1 = 0x00 for i in range(min(index_size, len(encrypted))): lookup = (t1 ^ encrypted[i]) & 0xFF decrypted.append(sbox[lookup]) t1 = (t1 - 1) & 0xFF entries = [] for i in range(0, len(decrypted) - 4, 4): val = struct.unpack('<I', decrypted[i:i + 4])[0] if 0 < val < len(ptd_data) - 4 and ptd_data[val:val + 4] == b'YKLZ': fsize = struct.unpack('<I', decrypted[i + 4:i + 8])[0] flag = struct.unpack('<I', decrypted[i + 8:i + 12])[0] name_raw = decrypted[i + 16:i + 16 + 64] null_idx = name_raw.find(b'\x00') if null_idx == -1: null_idx = len(name_raw) name = name_raw[:null_idx].decode('ascii', errors='replace') entries.append((val, fsize, flag, name)) return entriesdef unswizzle_clut256(colors): """Reordenamiento de paleta de 256 colores propio de la GS de PS2 (los bloques de 8 en las posiciones 8-15 y 16-23 de cada grupo de 32 entradas están intercambiados respecto al orden 'visual').""" out = [None] * len(colors) for i in range(len(colors)): block = i // 32 pos = i % 32 if 8 <= pos < 16: pos += 8 elif 16 <= pos < 24: pos -= 8 out[block * 32 + pos] = colors[i] return outdef unpack_color(raw, fmt): """Desempaqueta un color de la CLUT según su formato (1=16bit,2=24bit,3=32bit).""" if fmt == 3: r, g, b, a = struct.unpack('<4B', raw[:4]) return (r, g, b, min(255, a * 2)) elif fmt == 2: r, g, b = struct.unpack('<3B', raw[:3]) return (r, g, b, 255) elif fmt == 1: val = struct.unpack('<H', raw[:2])[0] r = (val & 0x1F) << 3 g = ((val >> 5) & 0x1F) << 3 b = ((val >> 10) & 0x1F) << 3 a = 255 if (val & 0x8000) else 0 return (r, g, b, a) return (0, 0, 0, 255)def decode_picture(dec, pos): """Decodifica una 'picture' TIM2 empezando en 'pos' (offset del picture header dentro de los datos ya descomprimidos). Devuelve (imagen_PIL, bytes_totales_de_esta_picture).""" ph = dec[pos:pos + 48] TotalSize, ClutSize, ImageSize = struct.unpack('<III', ph[0:12]) HeaderSize, ColorEntries = struct.unpack('<HH', ph[12:16]) ImgFormat, MipMapCount, ClutType, BitsPerPixel = ph[16], ph[17], ph[18], ph[19] Width, Height = struct.unpack('<HH', ph[20:24]) payload_start = pos + HeaderSize img_data = dec[payload_start:payload_start + ImageSize] clut_data = dec[payload_start + ImageSize:payload_start + ImageSize + ClutSize] clut_fmt = ClutType & 0x0F if BitsPerPixel in (4, 5): # Indexado con paleta n_colors = ColorEntries if ColorEntries else (16 if BitsPerPixel == 4 else 256) color_size = 4 if clut_fmt 3 else (3 if clut_fmt 2 else 2) colors = [unpack_color(clut_data[i color_size:(i + 1) color_size], clut_fmt) for i in range(n_colors)] if n_colors == 256: colors = unswizzle_clut256(colors) palette = np.array(colors, dtype=np.uint8) if BitsPerPixel == 4: raw = np.frombuffer(img_data[:((Width * Height) // 2)], dtype=np.uint8) lo = raw & 0x0F hi = (raw >> 4) & 0x0F idx = np.empty(raw.size * 2, dtype=np.uint8) idx[0::2] = lo idx[1::2] = hi idx = idx[:Width * Height].reshape(Height, Width) else: idx = np.frombuffer(img_data[:Width * Height], dtype=np.uint8).reshape(Height, Width) rgba = palette[idx] im = Image.fromarray(rgba, 'RGBA') elif BitsPerPixel in (1, 2, 3): bpp_bytes = {1: 2, 2: 3, 3: 4}[BitsPerPixel] n_pixels = Width * Height raw = img_data[:n_pixels * bpp_bytes] pixels = [unpack_color(raw[i bpp_bytes:(i + 1) bpp_bytes], BitsPerPixel) for i in range(n_pixels)] arr = np.array(pixels, dtype=np.uint8).reshape(Height, Width, 4) im = Image.fromarray(arr, 'RGBA') else: raise ValueError(f"BitsPerPixel desconocido: {BitsPerPixel}") return im, TotalSizedef extract_tim2(dec, base_name, out_dir): if dec[:4] != TIM2_MAGIC: return 0 count = struct.unpack('<H', dec[6:8])[0] pos = 16 saved = 0 for i in range(count): try: im, total_size = decode_picture(dec, pos) except Exception as e: print(f" [!] error decodificando {base_name} picture {i}: {e}") break suffix = "" if count == 1 else f"_{i}" out_path = os.path.join(out_dir, f"{os.path.splitext(base_name)[0]}{suffix}.png") im.save(out_path) saved += 1 pos += total_size return saveddef extract_all(ptd_path, out_dir): with open(ptd_path, 'rb') as f: ptd_data = f.read() print(f"Archivo: {ptd_path} ({len(ptd_data):,} bytes)") os.makedirs(out_dir, exist_ok=True) entries = parse_ptd_directory(ptd_data) print(f"Entradas en el índice: {len(entries)}") processed = 0 saved_total = 0 errors = 0 for f0, fsize, flag, name in entries: try: raw = ptd_data[f0:f0 + fsize + 0x40] dec = yklz_lzss_decompress(raw) except Exception as e: errors += 1 continue if dec[:4] != TIM2_MAGIC: errors += 1 continue processed += 1 saved_total += extract_tim2(dec, name, out_dir) if processed % 50 == 0: print(f" Procesados {processed}/{len(entries)}...") print(f"\nResultado:") print(f" Archivos TIM2 procesados: {processed}") print(f" PNGs guardados: {saved_total}") print(f" Errores/omitidos: {errors}") print(f" Carpeta de salida: {os.path.abspath(out_dir)}")if name == '__main__': ptd_file = sys.argv[1] if len(sys.argv) > 1 else 'IMAGE.PTD' if not os.path.exists(ptd_file): print(f"Error: no se encuentra {ptd_file}") sys.exit(1) if len(sys.argv) > 2: out_dir = sys.argv[2] else: out_dir = os.path.join(os.path.dirname(os.path.abspath(ptd_file)), 'images_extracted') extract_all(ptd_file, out_dir)
Create an account or sign in to comment