Files
retroDE_ps2/tools/generate_synthetic_image.py
thejayman77 ec82764bef Initial commit: retroDE_ps2 — first-of-its-kind PS2 GS FPGA core (DE25-Nano / Agilex 5)
RTL (GS rasterizer, EE core stub, platform bridge, LPDDR4B path), sim regression
(272 TBs), docs, and tooling. Copyrighted PS2 content (BIOS, game code, GS dumps,
and all dump-derived textures/traces) is excluded via .gitignore and stays local.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 20:10:50 -04:00

170 lines
7.3 KiB
Python

#!/usr/bin/env python3
"""
Generate a synthetic EE-RAM image + manifest for Ch270's ELF runner TB.
Produces two files at the requested output prefix:
<prefix>.image.hex iverilog $readmemh compatible. Uses @<hex_qw_idx>
directives so only the populated 128-bit qwords
appear (the TB pre-zeros the array before reading).
Each line is 32 hex chars = one 128-bit qword,
MSB-first (byte 15 leftmost, byte 0 rightmost).
<prefix>.manifest.hex Two lines:
line 0: ELF entry point (32-bit hex)
line 1: stack-top hint (32-bit hex; unused
by current TB but reserved)
The synthetic program lives at PHYS 0x00100000. Entry is given as a
kseg0 address (0x80100008) because the ee_memory_map stub routes
useg (top bit = 0) to a separate useg_shadow region, not ee_ram —
real PS2 ELFs use kseg0 entries for the same reason (cached text):
PHYS 0x00100000 / kseg0 0x80100000: nop pad
PHYS 0x00100004 / kseg0 0x80100004: nop pad
PHYS 0x00100008 / kseg0 0x80100008: addiu $v0,$0,0x1234 *** entry ***
PHYS 0x0010000C / kseg0 0x8010000C: addiu $v1,$0,0x5678
PHYS 0x00100010 / kseg0 0x80100010: j 0x80100010 loop-to-self
PHYS 0x00100014 / kseg0 0x80100014: nop delay slot
The J encoding (0x08040004) is PC-relative: at runtime, j_tgt =
{PC+4[31:28], imm26<<2}, so the high 4 bits come from the PC.
PC=0x80100010 ⇒ j_tgt = 0x80100010 (self) — same encoding works for
both kseg0 and kuseg views.
Expected TB verdict: `elf_timeout_with_hot_pc` with hot_pc near
0x80100010. That confirms the ELF-load + entry-bootstrap + strict-
trace pipeline is sound (no traps, no halts, no unmapped MMIO, EE
reaches and executes real code).
"""
import sys
import struct
import argparse
def encode_addiu(rt: int, rs: int, imm: int) -> int:
"""ADDIU rt, rs, imm. op=0x09."""
return (0x09 << 26) | ((rs & 0x1F) << 21) | ((rt & 0x1F) << 16) | (imm & 0xFFFF)
def encode_j(target: int) -> int:
"""J target. op=0x02. Target must be word-aligned."""
assert target & 3 == 0, "J target must be word-aligned"
return (0x02 << 26) | ((target >> 2) & 0x03FFFFFF)
def encode_lui(rt: int, imm: int) -> int:
"""LUI rt, imm. op=0x0F."""
return (0x0F << 26) | ((rt & 0x1F) << 16) | (imm & 0xFFFF)
def encode_ori(rt: int, rs: int, imm: int) -> int:
"""ORI rt, rs, imm. op=0x0D."""
return (0x0D << 26) | ((rs & 0x1F) << 21) | ((rt & 0x1F) << 16) | (imm & 0xFFFF)
def encode_jr(rs: int) -> int:
"""JR rs. SPECIAL/funct=0x08."""
return ((rs & 0x1F) << 21) | 0x08
def write_word_le(image: bytearray, phys_addr: int, word: int) -> None:
"""Write a 32-bit word little-endian into the EE-RAM image."""
assert phys_addr + 4 <= len(image), "phys_addr out of image bounds"
image[phys_addr + 0] = (word >> 0) & 0xFF
image[phys_addr + 1] = (word >> 8) & 0xFF
image[phys_addr + 2] = (word >> 16) & 0xFF
image[phys_addr + 3] = (word >> 24) & 0xFF
def qword_to_hex(image: bytearray, qw_phys: int) -> str:
"""Return the 32-char hex string for the qword at byte offset qw_phys.
iverilog $readmemh expects the leftmost hex char to be the highest
bit of the 128-bit value. Byte 15 is the most significant byte;
byte 0 is the least.
"""
assert qw_phys + 16 <= len(image)
bytes16 = image[qw_phys:qw_phys + 16]
# Reverse to MSB-first for the hex string.
return bytes16[::-1].hex()
def emit_image_hex(image: bytearray, path: str, qw_size: int) -> None:
"""Emit a $readmemh-compatible hex file using @<idx> directives for
every populated (non-zero) qword. Empty qwords are skipped — the TB
pre-zeros the array before reading.
"""
with open(path, "w") as f:
f.write("// Ch270 synthetic EE-RAM image\n")
f.write(f"// {len(image)} bytes / {len(image)//qw_size} qwords\n")
f.write("// Populated qwords only; TB zero-inits before $readmemh.\n\n")
any_emitted = False
for qw_idx in range(0, len(image) // qw_size):
qw_byte = qw_idx * qw_size
qw_bytes = image[qw_byte:qw_byte + qw_size]
if any(b != 0 for b in qw_bytes):
f.write(f"@{qw_idx:08x}\n")
f.write(qword_to_hex(image, qw_byte) + "\n")
any_emitted = True
if not any_emitted:
# iverilog $readmemh errors on empty file; emit a benign entry.
f.write("@00000000\n00000000000000000000000000000000\n")
def emit_manifest_hex(path: str, entry: int, stack_top: int) -> None:
"""Emit the manifest as two 32-bit hex lines."""
with open(path, "w") as f:
f.write("// Ch270 manifest\n")
f.write(f"// line 0 = entry, line 1 = stack_top hint\n")
f.write(f"{entry:08x}\n")
f.write(f"{stack_top:08x}\n")
def build_synthetic_image(image_bytes: int, entry_phys: int) -> bytearray:
"""Build the EE-RAM image with the synthetic program at entry_phys."""
image = bytearray(image_bytes)
# Pad before entry so PC starts on real instructions:
write_word_le(image, entry_phys - 8, 0x00000000) # nop
write_word_le(image, entry_phys - 4, 0x00000000) # nop
# Body:
write_word_le(image, entry_phys + 0, encode_addiu(2, 0, 0x1234)) # $v0 = 0x1234
write_word_le(image, entry_phys + 4, encode_addiu(3, 0, 0x5678)) # $v1 = 0x5678
write_word_le(image, entry_phys + 8, encode_j(entry_phys + 8)) # j self
write_word_le(image, entry_phys + 12, 0x00000000) # nop delay slot
return image
def main() -> int:
p = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--out-prefix", required=True,
help="output file prefix (writes <prefix>.image.hex + <prefix>.manifest.hex)")
p.add_argument("--entry", type=lambda s: int(s, 0), default=0x80100008,
help="entry point VIRTUAL address (kseg0 default 0x80100008; "
"physical placement of the code segment is entry & 0x1FFFFFFF)")
p.add_argument("--ee-ram-bytes", type=lambda s: int(s, 0), default=2 * 1024 * 1024,
help="EE RAM size in bytes (default 2 MiB; must be >= entry+16)")
p.add_argument("--stack-top", type=lambda s: int(s, 0), default=0x801FFFF0,
help="stack top hint stored in manifest (default 0x801FFFF0 kseg0)")
args = p.parse_args()
entry_phys = args.entry & 0x1FFFFFFF
if entry_phys < 8 or entry_phys + 16 > args.ee_ram_bytes:
p.error(f"entry 0x{args.entry:08x} (phys 0x{entry_phys:08x}) "
f"doesn't fit into 0x{args.ee_ram_bytes:x}-byte EE RAM")
image = build_synthetic_image(args.ee_ram_bytes, entry_phys)
emit_image_hex(image, f"{args.out_prefix}.image.hex", qw_size=16)
emit_manifest_hex(f"{args.out_prefix}.manifest.hex",
entry=args.entry, stack_top=args.stack_top)
print(f"[generate_synthetic_image] wrote {args.out_prefix}.image.hex "
f"+ {args.out_prefix}.manifest.hex (entry=0x{args.entry:08x}, "
f"phys=0x{entry_phys:08x}, ee_ram={args.ee_ram_bytes} bytes)")
return 0
if __name__ == "__main__":
sys.exit(main())