Jump to content

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


Recommended Posts

  • 1 year later...
Posted (edited)

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
  • Like 1
  • Confused 1
  • Engineers
Posted (edited)
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
Posted (edited)

 

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
  • Like 1
Posted (edited)

 

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
  • Like 1

Create an account or sign in to comment

You need to be a member in order to leave a comment

Create an account

Sign up for a new account in our community. It's easy!

Register a new account

Sign in

Already have an account? Sign in here.

Sign In Now
×
×
  • Create New...