Skip to content
View in the app

A better way to browse. Learn more.

ResHax

A full-screen app on your home screen with push notifications, badges and more.

To install this app on iOS and iPadOS
  1. Tap the Share icon in Safari
  2. Scroll the menu and tap Add to Home Screen.
  3. Tap Add in the top-right corner.
To install this app on Android
  1. Tap the 3-dot menu (⋮) in the top-right corner of the browser.
  2. Tap Add to Home screen or Install app.
  3. Confirm by tapping Install.
Help us keep the site running.
Zero Tolerance for Disrespect

[PS2] Junjou Romantica Koi no Doki Doki Daisakusen PTD file extraction

Featured Replies

  • 1 year later...

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 by ninoochan

  • 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.

 

IMAGE_seg_0000_YKLZ.YKLZ.decomp@0000000064.png

Edited by Rabatini

 

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 by ninoochan

 

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 by ninoochan

  • 6 months later...

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 engineering

completo del binario del juego (función de descifrado real ubicada

en 0x0013579C del ELF, encontrada tras trazar la cadena de llamadas

desde 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) & 0xFF

El blob descifrado contiene texto Shift-JIS/CP932 con rutas de

archivo ASCII (imágenes, audio, voz) intercaladas, todo separado

por bytes 0x00:

[texto japonés]\\0[ruta imagen]\\0[texto adicional]\\0[ruta audio]\\0...

"""

import struct

import os

import sys

JUNROMAN_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_output

if 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 con

SBOX 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 struct

import os

import sys

import numpy as np

from PIL import Image

YKLZ_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 entries

def 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 out

def 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, TotalSize

def 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 saved

def 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

Account

Navigation

Search

Search

Configure browser push notifications

Chrome (Android)
  1. Tap the lock icon next to the address bar.
  2. Tap Permissions → Notifications.
  3. Adjust your preference.
Chrome (Desktop)
  1. Click the padlock icon in the address bar.
  2. Select Site settings.
  3. Find Notifications and adjust your preference.