Initial public source release

Split reusable rendering and format support into vectorail-core and vectorail-gc.
This commit is contained in:
2026-08-02 17:05:27 +02:00
commit 831d96e562
109 changed files with 20558 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env sh
set -eu
repo_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
build_timestamp=${OPENROLLER_BUILD_TIMESTAMP:-$(date +%Y%m%d-%H%M%S)}
docker run --rm \
--user "$(id -u):$(id -g)" \
--volume "$repo_dir:/src" \
--workdir /src/psp \
pspdev/pspdev:latest \
make -B -f GNUmakefile BUILD_TIMESTAMP="$build_timestamp" "$@"
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""
Convert all .dds files under a directory to .png into a single output folder.
We intentionally use ffmpeg for decoding DDS, since ImageMagick often lacks a DDS delegate.
Example:
python tools/dds_to_png.py GC/data /tmp/gc_dds_png --jobs 8
"""
from __future__ import annotations
import argparse
import hashlib
import os
import shutil
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, List, Tuple
@dataclass(frozen=True)
class Task:
src: Path
dst: Path
def _iter_dds(root: Path) -> Iterable[Path]:
for dirpath, _, filenames in os.walk(root):
for fn in filenames:
if fn.lower().endswith(".dds"):
yield Path(dirpath) / fn
def _safe_flat_name(root: Path, p: Path) -> str:
rel = p.relative_to(root).as_posix()
# Flatten path to a filename. Keep ASCII-ish and avoid huge names.
flat = rel.replace("/", "__").replace("\\", "__")
if len(flat) > 180:
h = hashlib.sha1(rel.encode("utf-8")).hexdigest()[:10]
base = Path(flat).stem[:120]
flat = f"{base}__{h}.dds"
return Path(flat).with_suffix(".png").name
def _run_ffmpeg(src: Path, dst: Path) -> Tuple[bool, str]:
# -frames:v 1 ensures we only keep the first image if ffmpeg treats it as a sequence.
cmd = [
"ffmpeg",
"-y",
"-hide_banner",
"-loglevel",
"error",
"-i",
str(src),
"-frames:v",
"1",
str(dst),
]
try:
p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
except FileNotFoundError:
return False, "ffmpeg not found"
if p.returncode != 0:
msg = (p.stderr or p.stdout or "").strip()
if not msg:
msg = f"ffmpeg failed with code {p.returncode}"
return False, msg
return True, ""
def _convert_one(t: Task, overwrite: bool) -> Tuple[bool, str]:
if t.dst.exists():
if not overwrite:
return True, "skip"
try:
t.dst.unlink()
except Exception as e:
return False, f"unlink failed: {e}"
t.dst.parent.mkdir(parents=True, exist_ok=True)
ok, err = _run_ffmpeg(t.src, t.dst)
if not ok:
return False, err
return True, ""
def main(argv: List[str]) -> int:
ap = argparse.ArgumentParser()
ap.add_argument("input_dir", help="Root directory to scan for .dds (e.g. GC/data)")
ap.add_argument("output_dir", help="Output directory for flat .png files")
ap.add_argument("--jobs", type=int, default=8, help="Parallel jobs (default: 8)")
ap.add_argument("--overwrite", action="store_true", help="Overwrite existing .png")
ap.add_argument("--max", type=int, default=0, help="Only convert first N files (0 = all)")
ap.add_argument("--list", action="store_true", help="List discovered files and exit")
args = ap.parse_args(argv)
root = Path(args.input_dir).resolve()
out = Path(args.output_dir).resolve()
if not root.exists():
print(f"input_dir does not exist: {root}", file=sys.stderr)
return 2
if shutil.which("ffmpeg") is None:
print("ffmpeg not found in PATH", file=sys.stderr)
return 2
sources = sorted(_iter_dds(root))
if args.max and args.max > 0:
sources = sources[: args.max]
if args.list:
for p in sources:
print(p)
return 0
tasks: List[Task] = []
used = set()
collisions = 0
for p in sources:
name = _safe_flat_name(root, p)
if name in used:
collisions += 1
h = hashlib.sha1(str(p).encode("utf-8")).hexdigest()[:10]
name = Path(name).with_suffix("").name + f"__{h}.png"
used.add(name)
tasks.append(Task(src=p, dst=out / name))
print(f"Found {len(tasks)} DDS files under {root}")
if collisions:
print(f"Name collisions: {collisions} (resolved with hashes)")
print(f"Output dir: {out}")
ok = 0
fail = 0
skipped = 0
errors: List[Tuple[Path, str]] = []
jobs = max(1, int(args.jobs))
with ThreadPoolExecutor(max_workers=jobs) as ex:
futs = {ex.submit(_convert_one, t, args.overwrite): t for t in tasks}
done = 0
for f in as_completed(futs):
t = futs[f]
done += 1
try:
success, msg = f.result()
except Exception as e:
success, msg = False, f"exception: {e}"
if success:
if msg == "skip":
skipped += 1
else:
ok += 1
else:
fail += 1
errors.append((t.src, msg))
if done % 200 == 0 or done == len(tasks):
print(f"Progress: {done}/{len(tasks)} ok={ok} skip={skipped} fail={fail}")
if errors:
log = out / "_errors.txt"
out.mkdir(parents=True, exist_ok=True)
with log.open("w", encoding="utf-8") as fp:
for src, msg in errors[:2000]:
fp.write(f"{src}\t{msg}\n")
print(f"Failures: {fail} (see {log})")
print(f"Done: ok={ok} skip={skipped} fail={fail}")
return 0 if fail == 0 else 1
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+216
View File
@@ -0,0 +1,216 @@
typedef unsigned int u32;
typedef unsigned char u8;
typedef int i32;
__declspec(dllimport) void *__stdcall CreateFileA(const char *name, u32 access, u32 share,
void *security, u32 creation,
u32 flags, void *template_file);
__declspec(dllimport) u32 __stdcall SetFilePointer(void *file, i32 distance,
i32 *distance_high, u32 method);
__declspec(dllimport) int __stdcall WriteFile(void *file, const void *buffer, u32 bytes,
u32 *written, void *overlapped);
__declspec(dllimport) int __stdcall CloseHandle(void *object);
#define GENERIC_WRITE 0x40000000u
#define FILE_SHARE_READ_WRITE 0x00000003u
#define OPEN_ALWAYS 4u
#define FILE_ATTRIBUTE_NORMAL 0x00000080u
#define FILE_END 2u
#define INVALID_HANDLE_VALUE ((void *)-1)
#define FT_OK 0u
#define FT_INVALID_HANDLE 1u
#define FT_LIST_NUMBER_ONLY 0x80000000u
#define FT_LIST_BY_INDEX 0x40000000u
static void *g_handle = (void *)0x46544449u; /* "FTDI" */
static u32 g_log_count;
static char *append_char(char *p, char c)
{
*p++ = c;
return p;
}
static char *append_text(char *p, const char *s)
{
while (*s) {
*p++ = *s++;
}
return p;
}
static char *append_hex(char *p, u32 value)
{
static const char digits[] = "0123456789abcdef";
int i;
p = append_text(p, "0x");
for (i = 7; i >= 0; --i) {
*p++ = digits[(value >> (i * 4)) & 0xf];
}
return p;
}
static void log4(const char *name, u32 a, u32 b, u32 c, u32 d)
{
char line[192];
char *p;
u32 written;
void *file;
if (g_log_count++ > 20000) {
return;
}
p = line;
p = append_text(p, name);
p = append_char(p, '(');
p = append_hex(p, a);
p = append_text(p, ", ");
p = append_hex(p, b);
p = append_text(p, ", ");
p = append_hex(p, c);
p = append_text(p, ", ");
p = append_hex(p, d);
p = append_text(p, ")\r\n");
file = CreateFileA("Z:\\tmp\\ftd2xx_shim.log", GENERIC_WRITE, FILE_SHARE_READ_WRITE,
0, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, 0);
if (file == INVALID_HANDLE_VALUE) {
return;
}
SetFilePointer(file, 0, 0, FILE_END);
WriteFile(file, line, (u32)(p - line), &written, 0);
CloseHandle(file);
}
static void copy_text(char *dst, const char *src, u32 max)
{
u32 i;
if (!dst || !max) {
return;
}
for (i = 0; i + 1 < max && src[i]; ++i) {
dst[i] = src[i];
}
dst[i] = 0;
}
int __attribute__((stdcall)) DllMain(void *module, unsigned long reason, void *reserved)
{
(void)module;
(void)reason;
(void)reserved;
return 1;
}
u32 __attribute__((stdcall)) FT_Open(i32 device_number, void **handle_out)
{
log4("FT_Open", (u32)device_number, (u32)handle_out, 0, 0);
if (handle_out) {
*handle_out = g_handle;
}
return FT_OK;
}
u32 __attribute__((stdcall)) FT_OpenEx(void *arg, u32 flags, void **handle_out)
{
log4("FT_OpenEx", (u32)arg, flags, (u32)handle_out, 0);
if (handle_out) {
*handle_out = g_handle;
}
return FT_OK;
}
u32 __attribute__((stdcall)) FT_Close(void *handle)
{
log4("FT_Close", (u32)handle, 0, 0, 0);
return handle ? FT_OK : FT_INVALID_HANDLE;
}
u32 __attribute__((stdcall)) FT_Read(void *handle, void *buffer, u32 bytes, u32 *read_out)
{
u32 i;
log4("FT_Read", (u32)handle, (u32)buffer, bytes, (u32)read_out);
if (buffer) {
for (i = 0; i < bytes; ++i) {
((u8 *)buffer)[i] = 0;
}
}
if (read_out) {
*read_out = 0;
}
return FT_OK;
}
u32 __attribute__((stdcall)) FT_Write(void *handle, const void *buffer, u32 bytes, u32 *written_out)
{
log4("FT_Write", (u32)handle, (u32)buffer, bytes, (u32)written_out);
if (written_out) {
*written_out = bytes;
}
return FT_OK;
}
u32 __attribute__((stdcall)) FT_ListDevices(void *arg1, void *arg2, u32 flags)
{
log4("FT_ListDevices", (u32)arg1, (u32)arg2, flags, 0);
if (flags & FT_LIST_NUMBER_ONLY) {
if (arg1) {
*(u32 *)arg1 = 1;
}
} else if ((flags & FT_LIST_BY_INDEX) && arg2) {
copy_text((char *)arg2, "FTD2XX-GC-RFID", 32);
} else if (arg1) {
*(u32 *)arg1 = 1;
}
return FT_OK;
}
u32 __attribute__((stdcall)) FT_GetStatus(void *handle, u32 *rx_bytes, u32 *tx_bytes, u32 *event_status)
{
log4("FT_GetStatus", (u32)handle, (u32)rx_bytes, (u32)tx_bytes, (u32)event_status);
if (rx_bytes) {
*rx_bytes = 0;
}
if (tx_bytes) {
*tx_bytes = 0;
}
if (event_status) {
*event_status = 0;
}
return FT_OK;
}
u32 __attribute__((stdcall)) FT_W32_CreateFile(const char *name, u32 access, u32 share,
void *security, u32 creation,
u32 flags, void *template_file)
{
log4("FT_W32_CreateFile", (u32)name, access, share, creation);
(void)security;
(void)flags;
(void)template_file;
return (u32)g_handle;
}
u32 __attribute__((stdcall)) FT_EE_Read(void *handle, void *data)
{
log4("FT_EE_Read", (u32)handle, (u32)data, 0, 0);
return FT_OK;
}
u32 __attribute__((stdcall)) FT_EE_Program(void *handle, void *data)
{
log4("FT_EE_Program", (u32)handle, (u32)data, 0, 0);
return FT_OK;
}
u32 __attribute__((stdcall)) FT_ResetDevice(void *handle) { log4("FT_ResetDevice", (u32)handle, 0, 0, 0); return FT_OK; }
u32 __attribute__((stdcall)) FT_SetBaudRate(void *handle, u32 baud) { log4("FT_SetBaudRate", (u32)handle, baud, 0, 0); return FT_OK; }
u32 __attribute__((stdcall)) FT_SetDataCharacteristics(void *handle, u8 word_length, u8 stop_bits, u8 parity) { log4("FT_SetDataCharacteristics", (u32)handle, word_length, stop_bits, parity); return FT_OK; }
u32 __attribute__((stdcall)) FT_Purge(void *handle, u32 mask) { log4("FT_Purge", (u32)handle, mask, 0, 0); return FT_OK; }
u32 __attribute__((stdcall)) FT_SetTimeouts(void *handle, u32 read_ms, u32 write_ms) { log4("FT_SetTimeouts", (u32)handle, read_ms, write_ms, 0); return FT_OK; }
u32 __attribute__((stdcall)) FT_SetEventNotification(void *handle, u32 mask, void *event) { log4("FT_SetEventNotification", (u32)handle, mask, (u32)event, 0); return FT_OK; }
u32 __attribute__((stdcall)) FT_SetLatencyTimer(void *handle, u8 timer) { log4("FT_SetLatencyTimer", (u32)handle, timer, 0, 0); return FT_OK; }
u32 __attribute__((stdcall)) FT_SetUSBParameters(void *handle, u32 in_size, u32 out_size) { log4("FT_SetUSBParameters", (u32)handle, in_size, out_size, 0); return FT_OK; }
u32 __attribute__((stdcall)) FT_CyclePort(void *handle) { log4("FT_CyclePort", (u32)handle, 0, 0, 0); return FT_OK; }
+21
View File
@@ -0,0 +1,21 @@
LIBRARY FTD2XX.dll
EXPORTS
FT_Open @1
FT_Close @2
FT_Read @3
FT_Write @4
FT_ResetDevice @6
FT_SetBaudRate @7
FT_SetDataCharacteristics @8
FT_Purge @16
FT_SetTimeouts @17
FT_SetEventNotification @19
FT_GetStatus @21
FT_OpenEx @27
FT_ListDevices @28
FT_SetLatencyTimer @31
FT_SetUSBParameters @33
FT_EE_Program @37
FT_EE_Read @38
FT_W32_CreateFile @43
FT_CyclePort @69
+7
View File
@@ -0,0 +1,7 @@
Windows Registry Editor Version 5.00
[HKEY_LOCAL_MACHINE\SOFTWARE\taito\typex]
"Country"=dword:00000002
[HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\taito\typex]
"Country"=dword:00000002
@@ -0,0 +1,37 @@
// GhidraScript: find decompiled functions containing text, optionally limiting
// qualified function names to a second substring.
// Usage: DecompileAllSearch.java 0x6458 GameScene
import ghidra.app.decompiler.DecompInterface;
import ghidra.app.decompiler.DecompileResults;
import ghidra.app.script.GhidraScript;
import ghidra.program.model.listing.Function;
import ghidra.program.model.listing.FunctionIterator;
public class DecompileAllSearch extends GhidraScript {
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length == 0) {
printerr("DecompileAllSearch: needs text and optional function-name substring");
return;
}
String needle = args[0];
String nameNeedle = args.length > 1 ? args[1] : "";
DecompInterface decompiler = new DecompInterface();
decompiler.openProgram(currentProgram);
FunctionIterator functions = currentProgram.getFunctionManager().getFunctions(true);
while (functions.hasNext() && !monitor.isCancelled()) {
Function function = functions.next();
if (!nameNeedle.isEmpty() && !function.getName(true).contains(nameNeedle)) continue;
DecompileResults result = decompiler.decompileFunction(function, 20, monitor);
if (!result.decompileCompleted() || result.getDecompiledFunction() == null) continue;
String code = result.getDecompiledFunction().getC();
if (code == null || !code.contains(needle)) continue;
println(function.getEntryPoint() + " " + function.getName(true));
for (String line : code.split("\\R")) {
if (line.contains(needle)) println(" " + line.trim());
}
}
}
}
+65
View File
@@ -0,0 +1,65 @@
// GhidraScript: DecompileByAddr.java
// Usage (headless):
// analyzeHeadless <projDir> <projName> -process <progName> -noanalysis -readOnly \
// -scriptPath <path> -postScript DecompileByAddr.java 0x401000 0x...
//
// Prints decompiled C for the function at (or containing) each address.
import ghidra.app.decompiler.DecompInterface;
import ghidra.app.decompiler.DecompileResults;
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Function;
public class DecompileByAddr extends GhidraScript {
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length == 0) {
printerr("DecompileByAddr: needs one or more addresses, e.g. 0x63ea70");
return;
}
DecompInterface decomp = new DecompInterface();
decomp.openProgram(currentProgram);
for (String a : args) {
long va;
try {
va = Long.decode(a);
} catch (Exception e) {
printerr("bad address: " + a);
continue;
}
Address addr = toAddr(va);
if (addr == null) {
printerr("addr not in program: " + a);
continue;
}
Function f = getFunctionContaining(addr);
if (f == null) f = getFunctionAt(addr);
println("================================================================================");
println("addr: " + addr);
if (f == null) {
println("(no function found)");
continue;
}
println("function: " + f.getName() + " @ " + f.getEntryPoint());
DecompileResults res = decomp.decompileFunction(f, 60, monitor);
if (!res.decompileCompleted()) {
println("(decompile failed)");
continue;
}
String c = res.getDecompiledFunction().getC();
// Keep output reasonable in headless logs; truncate if huge.
if (c != null && c.length() > 120000) {
c = c.substring(0, 120000) + "\n/* ... truncated ... */\n";
}
println(c);
}
}
}
@@ -0,0 +1,69 @@
// GhidraScript: decompile every caller of a function and print call-site context.
// Usage: DecompileCallContexts.java 0x0063c0f0 [context-lines]
import ghidra.app.decompiler.DecompInterface;
import ghidra.app.decompiler.DecompileResults;
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Function;
import ghidra.program.model.symbol.Reference;
import ghidra.program.model.symbol.ReferenceIterator;
import java.util.LinkedHashMap;
import java.util.Map;
public class DecompileCallContexts extends GhidraScript {
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length == 0) {
printerr("DecompileCallContexts: needs a function address");
return;
}
Address target = toAddr(Long.decode(args[0]));
Function targetFunction = getFunctionAt(target);
if (targetFunction == null) {
printerr("target is not a function");
return;
}
int context = args.length > 1 ? Integer.decode(args[1]) : 5;
Map<Address, Function> callers = new LinkedHashMap<>();
ReferenceIterator references =
currentProgram.getReferenceManager().getReferencesTo(target);
while (references.hasNext()) {
Reference reference = references.next();
Function caller = getFunctionContaining(reference.getFromAddress());
if (caller != null) callers.put(caller.getEntryPoint(), caller);
}
DecompInterface decompiler = new DecompInterface();
decompiler.openProgram(currentProgram);
String needle = targetFunction.getName();
for (Function caller : callers.values()) {
if (monitor.isCancelled()) break;
DecompileResults result = decompiler.decompileFunction(caller, 90, monitor);
if (!result.decompileCompleted() || result.getDecompiledFunction() == null) continue;
String[] lines = result.getDecompiledFunction().getC().split("\\R");
boolean[] selected = new boolean[lines.length];
for (int i = 0; i < lines.length; ++i) {
if (!lines[i].contains(needle)) continue;
for (int j = Math.max(0, i - context);
j <= Math.min(lines.length - 1, i + context); ++j) {
selected[j] = true;
}
}
println("================================================================================");
println(caller.getEntryPoint() + " " + caller.getName(true));
boolean gap = false;
for (int i = 0; i < lines.length; ++i) {
if (selected[i]) {
if (gap) println("...");
println(String.format("%5d %s", i + 1, lines[i]));
gap = false;
} else if (i > 0 && selected[i - 1]) {
gap = true;
}
}
}
}
}
+50
View File
@@ -0,0 +1,50 @@
// GhidraScript: decompile one function and print matching lines with context.
// Usage: DecompileSearch.java 0x005ed4c0 0x104 8
import ghidra.app.decompiler.DecompInterface;
import ghidra.app.decompiler.DecompileResults;
import ghidra.app.script.GhidraScript;
import ghidra.program.model.listing.Function;
public class DecompileSearch extends GhidraScript {
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length < 2) {
printerr("DecompileSearch: needs address, text, and optional context-line count");
return;
}
Function function = getFunctionContaining(toAddr(Long.decode(args[0])));
if (function == null) {
printerr("function not found");
return;
}
int context = args.length > 2 ? Integer.decode(args[2]) : 5;
DecompInterface decomp = new DecompInterface();
decomp.openProgram(currentProgram);
DecompileResults result = decomp.decompileFunction(function, 120, monitor);
if (!result.decompileCompleted()) {
printerr("decompile failed");
return;
}
String[] lines = result.getDecompiledFunction().getC().split("\\R");
boolean[] selected = new boolean[lines.length];
for (int i = 0; i < lines.length; ++i) {
if (!lines[i].contains(args[1])) continue;
for (int j = Math.max(0, i - context); j <= Math.min(lines.length - 1, i + context); ++j) {
selected[j] = true;
}
}
println("function: " + function.getName() + " @ " + function.getEntryPoint());
boolean gap = false;
for (int i = 0; i < lines.length; ++i) {
if (selected[i]) {
if (gap) println("...");
println(String.format("%5d %s", i + 1, lines[i]));
gap = false;
} else if (i > 0 && selected[i - 1]) {
gap = true;
}
}
}
}
+24
View File
@@ -0,0 +1,24 @@
// GhidraScript: print little-endian dwords as hex, signed integers, and floats.
// Usage: DumpData.java 0x006fcbf0 32
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
public class DumpData extends GhidraScript {
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length != 2) {
printerr("DumpData: needs address and dword count");
return;
}
Address base = toAddr(Long.decode(args[0]));
int count = Integer.decode(args[1]);
for (int i = 0; i < count; ++i) {
Address address = base.add(i * 4L);
int value = getInt(address);
println(address + " 0x" + String.format("%08x", value) +
" int=" + value + " float=" + Float.intBitsToFloat(value));
}
}
}
@@ -0,0 +1,38 @@
// GhidraScript: print instructions around one or more addresses.
// Usage: DumpInstructions.java 0x001b184c 0x001b1870
import ghidra.app.script.GhidraScript;
import ghidra.program.model.listing.Function;
import ghidra.program.model.listing.Instruction;
public class DumpInstructions extends GhidraScript {
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length == 0) {
printerr("DumpInstructions: needs one or more addresses");
return;
}
for (String arg : args) {
Instruction center = getInstructionContaining(toAddr(Long.decode(arg)));
println("=== " + arg + " ===");
if (center == null) {
println("(no instruction)");
continue;
}
Instruction cursor = center;
for (int i = 0; i < 18; ++i) {
Instruction previous = cursor.getPrevious();
if (previous == null) break;
cursor = previous;
}
for (int i = 0; i < 40 && cursor != null; ++i) {
Function function = getFunctionContaining(cursor.getAddress());
String marker = cursor.getAddress().equals(center.getAddress()) ? "=>" : " ";
println(marker + " " + cursor.getAddress() + " " +
(function == null ? "" : function.getName() + " ") + cursor);
cursor = cursor.getNext();
}
}
}
}
@@ -0,0 +1,42 @@
// GhidraScript: dump an array of 32-bit pointers as ASCII strings.
// Usage: DumpPointerStrings.java <address> [count]
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.mem.MemoryAccessException;
public class DumpPointerStrings extends GhidraScript {
private String readAscii(Address at) throws MemoryAccessException {
StringBuilder out = new StringBuilder();
for (int i = 0; i < 256; ++i) {
int value = getByte(at.add(i)) & 0xff;
if (value == 0) break;
if (value < 0x20 || value > 0x7e) return "<non-ascii>";
out.append((char)value);
}
return out.toString();
}
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length == 0) {
printerr("DumpPointerStrings: needs address and optional count");
return;
}
Address start = toAddr(Long.decode(args[0]));
int count = args.length > 1 ? Integer.decode(args[1]) : 32;
for (int i = 0; i < count; ++i) {
Address slot = start.add(i * 4L);
long raw = getInt(slot) & 0xffffffffL;
Address target = toAddr(raw);
String value;
try {
value = currentProgram.getMemory().contains(target) ? readAscii(target) : "<outside>";
} catch (Exception exc) {
value = "<invalid>";
}
println(String.format("%4d %s -> %08x %s", i, slot, raw, value));
}
}
}
+30
View File
@@ -0,0 +1,30 @@
// GhidraScript: DumpPointers.java
// Usage:
// analyzeHeadless <projDir> <projName> -process <progName> -noanalysis -readOnly \
// -scriptPath tools/ghidra_scripts -postScript DumpPointers.java 0x006f8990 16
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Function;
public class DumpPointers extends GhidraScript {
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length < 1) {
printerr("DumpPointers: needs address and optional count");
return;
}
Address at = toAddr(Long.decode(args[0]));
int count = args.length >= 2 ? Integer.decode(args[1]) : 32;
for (int i = 0; i < count; i++) {
Address slot = at.add(i * 4L);
long ptr = getInt(slot) & 0xffffffffL;
Address dst = toAddr(ptr);
Function f = getFunctionAt(dst);
if (f == null) f = getFunctionContaining(dst);
String name = f == null ? "" : f.getName() + " @ " + f.getEntryPoint();
println(String.format("%s +0x%02x -> %08x %s", slot, i * 4, ptr, name));
}
}
}
+26
View File
@@ -0,0 +1,26 @@
// GhidraScript: dump 32-bit words as hex, signed integer, and IEEE-754 float.
// Usage: DumpScalars.java <address> [count]
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
public class DumpScalars extends GhidraScript {
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length == 0) {
printerr("DumpScalars: needs address and optional count");
return;
}
Address start = toAddr(Long.decode(args[0]));
int count = args.length > 1 ? Integer.decode(args[1]) : 1;
for (int i = 0; i < count; ++i) {
Address slot = start.add(i * 4L);
int raw = getInt(slot);
println(String.format(
"%s hex=%08x int=%d float=%.9g",
slot, raw, raw, Float.intBitsToFloat(raw)));
}
}
}
+28
View File
@@ -0,0 +1,28 @@
// GhidraScript: FindAddressRefs.java
// Usage: ... -postScript FindAddressRefs.java 0x401000 [...]
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Function;
import ghidra.program.model.symbol.Reference;
public class FindAddressRefs extends GhidraScript {
@Override
public void run() throws Exception {
for (String arg : getScriptArgs()) {
Address target = toAddr(Long.decode(arg));
println("================================================================================");
println("target: " + target);
Reference[] refs = getReferencesTo(target);
println("refs: " + refs.length);
for (Reference ref : refs) {
Address from = ref.getFromAddress();
Function function = getFunctionContaining(from);
println(" " + from + " -> " +
(function == null ? "(no function)" :
function.getName() + " @ " + function.getEntryPoint()) +
" type=" + ref.getReferenceType());
}
}
}
}
@@ -0,0 +1,87 @@
// GhidraScript: FindD3DSetTransformSites.java
// Usage:
// analyzeHeadless <projDir> <projName> -process <progName> -noanalysis -readOnly \
// -scriptPath tools/ghidra_scripts -postScript FindD3DSetTransformSites.java [minAddr] [maxAddr]
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Function;
import ghidra.program.model.listing.Instruction;
import ghidra.program.model.scalar.Scalar;
public class FindD3DSetTransformSites extends GhidraScript {
private boolean hasScalar(Instruction ins, long want) {
for (int op = 0; op < ins.getNumOperands(); op++) {
for (Object obj : ins.getOpObjects(op)) {
if (obj instanceof Scalar) {
Scalar s = (Scalar)obj;
if (s.getUnsignedValue() == want || s.getSignedValue() == want) return true;
}
}
}
return false;
}
private boolean inRange(Address addr, Address min, Address max) {
if (min != null && addr.compareTo(min) < 0) return false;
if (max != null && addr.compareTo(max) > 0) return false;
return true;
}
private boolean followedByCall(Instruction ins, int maxSteps) {
Instruction cur = ins;
for (int i = 0; i < maxSteps; i++) {
cur = cur.getNext();
if (cur == null) return false;
if ("CALL".equals(cur.getMnemonicString())) return true;
}
return false;
}
private boolean nearbyPushState(Instruction ins) {
Instruction cur = ins;
for (int i = 0; i < 10; i++) {
cur = cur.getPrevious();
if (cur == null) break;
if (!"PUSH".equals(cur.getMnemonicString())) continue;
if (hasScalar(cur, 2) || hasScalar(cur, 3)) return true;
}
return false;
}
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
Address min = args.length > 0 ? toAddr(Long.decode(args[0])) : null;
Address max = args.length > 1 ? toAddr(Long.decode(args[1])) : null;
println("=== D3D SetTransform VIEW/PROJ candidates ===");
int hits = 0;
for (Instruction ins : currentProgram.getListing().getInstructions(true)) {
if (!inRange(ins.getAddress(), min, max)) continue;
if (!hasScalar(ins, 0xb0)) continue;
if (!followedByCall(ins, 3)) continue;
if (!nearbyPushState(ins)) continue;
hits++;
Function f = getFunctionContaining(ins.getAddress());
String fs = f == null ? "(no func)" : f.getName() + " @ " + f.getEntryPoint();
println("");
println("HIT " + hits + " " + ins.getAddress() + " " + fs);
Instruction cur = ins;
for (int i = 0; i < 10; i++) {
Instruction prev = cur.getPrevious();
if (prev == null) break;
cur = prev;
}
for (int i = 0; i < 18 && cur != null; i++) {
String mark = cur.getAddress().equals(ins.getAddress()) ? "=>" : " ";
println(mark + " " + cur.getAddress() + " " + cur);
cur = cur.getNext();
}
if (monitor.isCancelled()) break;
}
println("hits: " + hits);
}
}
@@ -0,0 +1,63 @@
// GhidraScript: FindFunctionCallers.java
// Usage (headless):
// analyzeHeadless <projDir> <projName> -process <progName> -noanalysis -readOnly \
// -scriptPath <path> -postScript FindFunctionCallers.java 0x0063ea70
//
// Prints the functions which contain callsites (or any references) to the given function entry.
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Function;
import ghidra.program.model.symbol.Reference;
import ghidra.program.model.symbol.ReferenceIterator;
import java.util.LinkedHashSet;
public class FindFunctionCallers extends GhidraScript {
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length == 0) {
printerr("FindFunctionCallers: needs one or more function entry addresses, e.g. 0x613710");
return;
}
for (String a : args) {
long va;
try {
va = Long.decode(a);
} catch (Exception e) {
printerr("bad address: " + a);
continue;
}
Address entry = toAddr(va);
if (entry == null) {
printerr("addr not in program: " + a);
continue;
}
println("=== callers for " + entry + " ===");
LinkedHashSet<String> callers = new LinkedHashSet<>();
ReferenceIterator it = currentProgram.getReferenceManager().getReferencesTo(entry);
int n = 0;
while (it.hasNext()) {
Reference r = it.next();
n++;
Function f = getFunctionContaining(r.getFromAddress());
if (f != null) {
callers.add(f.getName() + " @ " + f.getEntryPoint() + " (from " + r.getFromAddress() + ")");
} else {
callers.add("(no func) from " + r.getFromAddress());
}
if (n > 5000) break;
}
println("refs: " + n);
if (callers.isEmpty()) {
println("(none)");
} else {
for (String s : callers) println(" " + s);
}
println("");
}
}
}
+25
View File
@@ -0,0 +1,25 @@
// GhidraScript: list functions whose symbol name contains a substring.
// Usage: FindFunctions.java DrawMark
import ghidra.app.script.GhidraScript;
import ghidra.program.model.listing.Function;
import ghidra.program.model.listing.FunctionIterator;
public class FindFunctions extends GhidraScript {
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length == 0) {
printerr("FindFunctions: needs a case-sensitive name substring");
return;
}
String needle = args[0];
FunctionIterator functions = currentProgram.getFunctionManager().getFunctions(true);
while (functions.hasNext() && !monitor.isCancelled()) {
Function function = functions.next();
if (function.getName(true).contains(needle)) {
println(function.getEntryPoint() + " " + function.getName(true));
}
}
}
}
+64
View File
@@ -0,0 +1,64 @@
// GhidraScript: FindScalarOps.java
// Usage:
// analyzeHeadless <projDir> <projName> -process <progName> -noanalysis -readOnly \
// -scriptPath tools/ghidra_scripts -postScript FindScalarOps.java 0x3b 0x3f800000
import ghidra.app.script.GhidraScript;
import ghidra.program.model.listing.Function;
import ghidra.program.model.listing.Instruction;
import ghidra.program.model.scalar.Scalar;
import java.util.LinkedHashSet;
public class FindScalarOps extends GhidraScript {
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length == 0) {
printerr("FindScalarOps: needs one or more scalar values");
return;
}
for (String arg : args) {
long want;
try {
want = Long.decode(arg);
} catch (Exception e) {
printerr("bad scalar: " + arg);
continue;
}
println("=== scalar " + arg + " ===");
int hits = 0;
LinkedHashSet<String> funcs = new LinkedHashSet<>();
for (Instruction ins : currentProgram.getListing().getInstructions(true)) {
int n = ins.getNumOperands();
boolean matched = false;
for (int op = 0; op < n; op++) {
for (Object obj : ins.getOpObjects(op)) {
if (obj instanceof Scalar) {
Scalar s = (Scalar)obj;
long v = s.getUnsignedValue();
long sv = s.getSignedValue();
if (v == want || sv == want) {
matched = true;
break;
}
}
}
if (matched) break;
}
if (!matched) continue;
hits++;
Function f = getFunctionContaining(ins.getAddress());
String fs = f == null ? "(no func)" : f.getName() + " @ " + f.getEntryPoint();
funcs.add(fs);
if (hits <= 250) println(ins.getAddress() + " " + fs + " " + ins);
if (monitor.isCancelled()) break;
}
println("hits: " + hits);
for (String fs : funcs) println(" " + fs);
println("");
}
}
}
@@ -0,0 +1,76 @@
// GhidraScript: FindScalarWindow.java
// Usage:
// analyzeHeadless <projDir> <projName> -process <progName> -noanalysis -readOnly \
// -scriptPath tools/ghidra_scripts -postScript FindScalarWindow.java 0xb0 [minAddr] [maxAddr] [maxHits]
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Function;
import ghidra.program.model.listing.Instruction;
import ghidra.program.model.scalar.Scalar;
public class FindScalarWindow extends GhidraScript {
private boolean hasScalar(Instruction ins, long want) {
for (int op = 0; op < ins.getNumOperands(); op++) {
for (Object obj : ins.getOpObjects(op)) {
if (obj instanceof Scalar) {
Scalar s = (Scalar)obj;
if (s.getUnsignedValue() == want || s.getSignedValue() == want) return true;
}
}
}
return false;
}
private boolean inRange(Address addr, Address min, Address max) {
if (min != null && addr.compareTo(min) < 0) return false;
if (max != null && addr.compareTo(max) > 0) return false;
return true;
}
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length == 0) {
printerr("FindScalarWindow: needs scalar, optional minAddr maxAddr maxHits");
return;
}
long want = Long.decode(args[0]);
Address min = args.length > 1 ? toAddr(Long.decode(args[1])) : null;
Address max = args.length > 2 ? toAddr(Long.decode(args[2])) : null;
int maxHits = args.length > 3 ? Integer.decode(args[3]) : 120;
println("=== scalar window " + args[0] + " ===");
int hits = 0;
for (Instruction ins : currentProgram.getListing().getInstructions(true)) {
if (!inRange(ins.getAddress(), min, max)) continue;
if (!hasScalar(ins, want)) continue;
hits++;
Function f = getFunctionContaining(ins.getAddress());
String fs = f == null ? "(no func)" : f.getName() + " @ " + f.getEntryPoint();
println("");
println("HIT " + hits + " " + ins.getAddress() + " " + fs);
Instruction cur = ins;
for (int i = 0; i < 8; i++) {
Instruction prev = cur.getPrevious();
if (prev == null) break;
cur = prev;
}
for (int i = 0; i < 17 && cur != null; i++) {
String mark = cur.getAddress().equals(ins.getAddress()) ? "=>" : " ";
println(mark + " " + cur.getAddress() + " " + cur);
cur = cur.getNext();
}
if (hits >= maxHits) {
println("(truncated at " + maxHits + " hits)");
break;
}
if (monitor.isCancelled()) break;
}
println("hits: " + hits);
}
}
+96
View File
@@ -0,0 +1,96 @@
// GhidraScript: FindStringXrefs.java
// Usage (headless):
// analyzeHeadless <projDir> <projName> -process <progName> \
// -scriptPath <path> -postScript FindStringXrefs.java "<needle1>" ["<needle2>" ...]
//
// Prints the addresses where the ASCII needle is found (optionally with NUL terminator)
// and lists the functions that reference the string address.
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Function;
import ghidra.program.model.mem.Memory;
import ghidra.program.model.symbol.Reference;
import ghidra.program.model.symbol.ReferenceIterator;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashSet;
public class FindStringXrefs extends GhidraScript {
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length == 0) {
printerr("FindStringXrefs: needs at least one ASCII needle arg");
return;
}
Memory mem = currentProgram.getMemory();
Address min = mem.getMinAddress();
Address max = mem.getMaxAddress();
for (String needle : args) {
if (needle == null) continue;
if (needle.isEmpty()) continue;
println("=== needle: " + needle + " ===");
byte[] pat0 = needle.getBytes(StandardCharsets.US_ASCII);
byte[] pat1 = new byte[pat0.length + 1];
System.arraycopy(pat0, 0, pat1, 0, pat0.length);
pat1[pat1.length - 1] = 0;
// First try NUL-terminated.
int hits = 0;
Address at = min;
while (true) {
Address found = mem.findBytes(at, max, pat1, null, true, monitor);
if (found == null) break;
hits++;
dumpHit(found);
at = found.add(1);
}
// If no NUL-terminated matches, fall back to raw bytes search.
if (hits == 0) {
at = min;
while (true) {
Address found = mem.findBytes(at, max, pat0, null, true, monitor);
if (found == null) break;
hits++;
dumpHit(found);
at = found.add(1);
}
}
if (hits == 0) {
println("(no hits)");
}
println("");
}
}
private void dumpHit(Address strAddr) {
println("hit @ " + strAddr);
LinkedHashSet<String> funcs = new LinkedHashSet<>();
ReferenceIterator it = currentProgram.getReferenceManager().getReferencesTo(strAddr);
int nref = 0;
while (it.hasNext()) {
Reference r = it.next();
nref++;
Function f = getFunctionContaining(r.getFromAddress());
if (f != null) {
funcs.add(f.getName() + " @ " + f.getEntryPoint());
} else {
funcs.add("(no func) from " + r.getFromAddress());
}
if (nref > 2000) break; // avoid pathological spam
}
println("refs: " + nref);
for (String s : funcs) {
println(" " + s);
if (funcs.size() > 200) break;
}
}
}
+45
View File
@@ -0,0 +1,45 @@
// GhidraScript: FindSymbolRefs.java
// Usage:
// analyzeHeadless <projDir> <projName> -process <progName> -noanalysis -readOnly \
// -scriptPath <path> -postScript FindSymbolRefs.java D3DXMatrixLookAtLH ...
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Function;
import ghidra.program.model.symbol.Reference;
import ghidra.program.model.symbol.Symbol;
import ghidra.program.model.symbol.SymbolIterator;
public class FindSymbolRefs extends GhidraScript {
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length == 0) {
printerr("FindSymbolRefs: needs symbol names");
return;
}
for (String needle : args) {
println("================================================================================");
println("symbol needle: " + needle);
boolean any = false;
SymbolIterator it = currentProgram.getSymbolTable().getAllSymbols(true);
while (it.hasNext() && !monitor.isCancelled()) {
Symbol s = it.next();
if (!s.getName(true).contains(needle)) continue;
any = true;
Address addr = s.getAddress();
println("symbol: " + s.getName(true) + " @ " + addr);
Reference[] refs = getReferencesTo(addr);
println("refs: " + refs.length);
for (Reference r : refs) {
Address from = r.getFromAddress();
Function f = getFunctionContaining(from);
String fn = f == null ? "(no function)" : f.getName() + " @ " + f.getEntryPoint();
println(" " + from + " -> " + fn + " type=" + r.getReferenceType());
}
}
if (!any) println("(no symbol hits)");
}
}
}
@@ -0,0 +1,78 @@
// GhidraScript: FindVirtualCallOffset.java
// Usage:
// analyzeHeadless <projDir> <projName> -process <progName> -noanalysis -readOnly \
// -scriptPath tools/ghidra_scripts -postScript FindVirtualCallOffset.java 0xc4 [minAddr] [maxAddr]
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Function;
import ghidra.program.model.listing.Instruction;
import ghidra.program.model.scalar.Scalar;
public class FindVirtualCallOffset extends GhidraScript {
private boolean hasScalar(Instruction ins, long want) {
for (int op = 0; op < ins.getNumOperands(); op++) {
for (Object obj : ins.getOpObjects(op)) {
if (obj instanceof Scalar) {
Scalar s = (Scalar)obj;
if (s.getUnsignedValue() == want || s.getSignedValue() == want) return true;
}
}
}
return false;
}
private boolean inRange(Address addr, Address min, Address max) {
if (min != null && addr.compareTo(min) < 0) return false;
if (max != null && addr.compareTo(max) > 0) return false;
return true;
}
private boolean followedByCall(Instruction ins) {
Instruction n = ins.getNext();
if (n != null && "CALL".equals(n.getMnemonicString())) return true;
n = n == null ? null : n.getNext();
return n != null && "CALL".equals(n.getMnemonicString());
}
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length == 0) {
printerr("FindVirtualCallOffset: needs vtable offset");
return;
}
long want = Long.decode(args[0]);
Address min = args.length > 1 ? toAddr(Long.decode(args[1])) : null;
Address max = args.length > 2 ? toAddr(Long.decode(args[2])) : null;
println("=== virtual call offset " + args[0] + " ===");
int hits = 0;
for (Instruction ins : currentProgram.getListing().getInstructions(true)) {
if (!inRange(ins.getAddress(), min, max)) continue;
if (!"MOV".equals(ins.getMnemonicString())) continue;
if (!hasScalar(ins, want)) continue;
if (!followedByCall(ins)) continue;
hits++;
Function f = getFunctionContaining(ins.getAddress());
String fs = f == null ? "(no func)" : f.getName() + " @ " + f.getEntryPoint();
println("");
println("HIT " + hits + " " + ins.getAddress() + " " + fs);
Instruction cur = ins;
for (int i = 0; i < 8; i++) {
Instruction prev = cur.getPrevious();
if (prev == null) break;
cur = prev;
}
for (int i = 0; i < 14 && cur != null; i++) {
String mark = cur.getAddress().equals(ins.getAddress()) ? "=>" : " ";
println(mark + " " + cur.getAddress() + " " + cur);
cur = cur.getNext();
}
if (monitor.isCancelled()) break;
}
println("hits: " + hits);
}
}
@@ -0,0 +1,62 @@
// GhidraScript: FindVtableOffsetCalls.java
// Usage:
// analyzeHeadless <projDir> <projName> -process <progName> -noanalysis -readOnly \
// -scriptPath tools/ghidra_scripts -postScript FindVtableOffsetCalls.java 0xb0
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Function;
import ghidra.program.model.listing.Instruction;
import ghidra.program.model.scalar.Scalar;
public class FindVtableOffsetCalls extends GhidraScript {
private boolean hasScalar(Instruction ins, long want) {
for (int op = 0; op < ins.getNumOperands(); op++) {
for (Object obj : ins.getOpObjects(op)) {
if (obj instanceof Scalar) {
Scalar s = (Scalar)obj;
if (s.getUnsignedValue() == want || s.getSignedValue() == want) return true;
}
}
}
return false;
}
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length == 0) {
printerr("FindVtableOffsetCalls: needs one or more vtable offsets, e.g. 0xb0");
return;
}
for (String arg : args) {
long want = Long.decode(arg);
println("=== vtable call offset " + arg + " ===");
int hits = 0;
for (Instruction ins : currentProgram.getListing().getInstructions(true)) {
if (!"CALL".equals(ins.getMnemonicString())) continue;
if (!hasScalar(ins, want)) continue;
hits++;
Function f = getFunctionContaining(ins.getAddress());
String fs = f == null ? "(no func)" : f.getName() + " @ " + f.getEntryPoint();
println("");
println(ins.getAddress() + " " + fs + " " + ins);
Instruction prev = ins;
for (int i = 0; i < 10; i++) {
prev = prev.getPrevious();
if (prev == null) break;
println(" " + prev.getAddress() + " " + prev);
}
if (hits >= 300) {
println("(truncated at 300 hits)");
break;
}
if (monitor.isCancelled()) break;
}
println("hits: " + hits);
}
}
}
@@ -0,0 +1,28 @@
// GhidraScript: list functions whose entry points fall inside an address range.
// Usage: ListFunctionsRange.java 0x005b6700 0x005b8600
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Function;
import ghidra.program.model.listing.FunctionIterator;
public class ListFunctionsRange extends GhidraScript {
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length != 2) {
printerr("ListFunctionsRange: needs minAddr maxAddr");
return;
}
Address min = toAddr(Long.decode(args[0]));
Address max = toAddr(Long.decode(args[1]));
FunctionIterator it = currentProgram.getFunctionManager().getFunctions(min, true);
while (it.hasNext() && !monitor.isCancelled()) {
Function function = it.next();
Address entry = function.getEntryPoint();
if (entry.compareTo(max) > 0) break;
println(entry + " " + function.getName() + " size=0x" +
Long.toHexString(function.getBody().getNumAddresses()));
}
}
}
+17
View File
@@ -0,0 +1,17 @@
// GhidraScript: print little-endian 32-bit words and their float view.
// Usage: ReadScalars.java 0x2703f8 0x2703fc
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
public class ReadScalars extends GhidraScript {
@Override
public void run() throws Exception {
for (String arg : getScriptArgs()) {
Address address = toAddr(Long.decode(arg));
int word = getInt(address);
println(address + " u32=0x" + Integer.toHexString(word) +
" f32=" + Float.intBitsToFloat(word));
}
}
}
+28
View File
@@ -0,0 +1,28 @@
// GhidraScript: rename functions from address/name pairs.
// Usage: RenameFunctions.java 0x00100000 FunctionName 0x00100100 OtherName
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Function;
import ghidra.program.model.symbol.SourceType;
public class RenameFunctions extends GhidraScript {
@Override
public void run() throws Exception {
String[] args = getScriptArgs();
if (args == null || args.length == 0 || (args.length & 1) != 0) {
printerr("RenameFunctions: needs address/name pairs");
return;
}
for (int i = 0; i < args.length; i += 2) {
Address address = toAddr(Long.decode(args[i]));
Function function = getFunctionAt(address);
if (function == null) {
printerr("no function at " + address);
continue;
}
function.setName(args[i + 1], SourceType.USER_DEFINED);
println(address + " " + function.getName());
}
}
}
+234
View File
@@ -0,0 +1,234 @@
typedef unsigned int u32;
typedef unsigned char u8;
typedef int i32;
__declspec(dllimport) void *__stdcall CreateFileA(const char *name, u32 access, u32 share,
void *security, u32 creation,
u32 flags, void *template_file);
__declspec(dllimport) u32 __stdcall SetFilePointer(void *file, i32 distance,
i32 *distance_high, u32 method);
__declspec(dllimport) int __stdcall WriteFile(void *file, const void *buffer, u32 bytes,
u32 *written, void *overlapped);
__declspec(dllimport) int __stdcall CloseHandle(void *object);
#define GENERIC_WRITE 0x40000000u
#define FILE_SHARE_READ_WRITE 0x00000003u
#define OPEN_ALWAYS 4u
#define FILE_ATTRIBUTE_NORMAL 0x00000080u
#define FILE_END 2u
#define INVALID_HANDLE_VALUE ((void *)-1)
static u32 g_handle = 0x49444d43u; /* "IDMC" */
static u32 g_status = 0;
static u32 g_regs[0x2000] = {
[0x4150 >> 2] = 0x0000825cu,
[0x4140 >> 2] = 0x80000004u,
[0x41a4 >> 2] = 0x80000005u,
};
static u8 g_buffer[0x8000];
static u32 g_log_count;
static char *append_char(char *p, char c)
{
*p++ = c;
return p;
}
static char *append_text(char *p, const char *s)
{
while (*s) {
*p++ = *s++;
}
return p;
}
static char *append_hex(char *p, u32 value)
{
static const char digits[] = "0123456789abcdef";
int i;
p = append_text(p, "0x");
for (i = 7; i >= 0; --i) {
*p++ = digits[(value >> (i * 4)) & 0xf];
}
return p;
}
static void log3(const char *name, u32 a, u32 b, u32 c)
{
char line[160];
char *p;
u32 written;
void *file;
if (g_log_count++ > 20000) {
return;
}
p = line;
p = append_text(p, name);
p = append_char(p, '(');
p = append_hex(p, a);
p = append_text(p, ", ");
p = append_hex(p, b);
p = append_text(p, ", ");
p = append_hex(p, c);
p = append_text(p, ")\r\n");
file = CreateFileA("Z:\\tmp\\idmac_shim.log", GENERIC_WRITE, FILE_SHARE_READ_WRITE,
0, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, 0);
if (file == INVALID_HANDLE_VALUE) {
return;
}
SetFilePointer(file, 0, 0, FILE_END);
WriteFile(file, line, (u32)(p - line), &written, 0);
CloseHandle(file);
}
static u32 load_le32(const void *ptr)
{
const u8 *p = (const u8 *)ptr;
return ((u32)p[0]) | ((u32)p[1] << 8) | ((u32)p[2] << 16) | ((u32)p[3] << 24);
}
int __attribute__((stdcall)) DllMain(void *module, unsigned long reason, void *reserved)
{
(void)module;
(void)reason;
(void)reserved;
return 1;
}
int iDmacDrvOpen(u32 device, u32 *handle_out, u32 *status_out)
{
log3("Open", device, (u32)handle_out, (u32)status_out);
(void)device;
if (handle_out) {
*handle_out = g_handle;
}
if (status_out) {
*status_out = g_status;
}
return 0;
}
int iDmacDrvClose(u32 handle, u32 *status_out)
{
log3("Close", handle, (u32)status_out, 0);
(void)handle;
if (status_out) {
*status_out = g_status;
}
return 0;
}
int iDmacDrvRegisterRead(u32 handle, u32 address, u32 *value_out, u32 *status_out)
{
log3("RegisterRead", handle, address, (u32)value_out);
(void)handle;
if (status_out) {
*status_out = g_status;
}
if (!value_out) {
return 0x57;
}
switch (address) {
case 0x400:
*value_out = 0x01010313u;
break;
case 0x4000:
*value_out = 0x00ff00ffu;
break;
case 0x4004:
*value_out = 0x00ff0000u;
break;
default:
if ((address >> 2) < (sizeof(g_regs) / sizeof(g_regs[0]))) {
*value_out = g_regs[address >> 2];
} else {
*value_out = 0;
}
break;
}
log3("RegisterReadValue", address, *value_out, status_out ? *status_out : 0);
return 0;
}
int iDmacDrvRegisterWrite(u32 handle, u32 address, u32 value, u32 *status_out)
{
log3("RegisterWrite", handle, address, value);
(void)handle;
if (status_out) {
*status_out = g_status;
}
if ((address >> 2) < (sizeof(g_regs) / sizeof(g_regs[0]))) {
g_regs[address >> 2] = value;
}
return 0;
}
int iDmacDrvRegisterBufferRead(u32 handle, u32 address, void *buffer, u32 bytes, u32 *status_out)
{
u32 i;
log3("BufferRead", handle, address, bytes);
(void)handle;
if (status_out) {
*status_out = g_status;
}
if (!buffer) {
return 0x57;
}
if (bytes >= 4) {
log3("BufferReadBefore", address, bytes, load_le32(buffer));
}
for (i = 0; i < bytes; i++) {
((u8 *)buffer)[i] = (address + i < sizeof(g_buffer)) ? g_buffer[address + i] : 0;
}
if (bytes >= 4) {
log3("BufferReadAfter", address, bytes, load_le32(buffer));
}
return 0;
}
int iDmacDrvRegisterBufferWrite(u32 handle, u32 address, const void *buffer, u32 bytes, u32 *status_out)
{
u32 i;
log3("BufferWrite", handle, address, bytes);
(void)handle;
if (status_out) {
*status_out = g_status;
}
if (!buffer) {
return 0x57;
}
if (bytes >= 4) {
log3("BufferWriteData", address, bytes, load_le32(buffer));
}
for (i = 0; i < bytes; i++) {
if (address + i < sizeof(g_buffer)) {
g_buffer[address + i] = ((const u8 *)buffer)[i];
}
}
return 0;
}
int iDmacDrvDmaRead(u32 handle, u32 address, void *buffer, u32 bytes, u32 *status_out)
{
return iDmacDrvRegisterBufferRead(handle, address, buffer, bytes, status_out);
}
int iDmacDrvDmaWrite(u32 handle, u32 address, const void *buffer, u32 bytes, u32 *status_out)
{
return iDmacDrvRegisterBufferWrite(handle, address, buffer, bytes, status_out);
}
int iDmacDrvProgramDownload(u32 handle, u32 command, u32 *status_out)
{
log3("ProgramDownload", handle, command, (u32)status_out);
(void)handle;
(void)command;
if (status_out) {
*status_out = g_status;
}
return 0;
}
+11
View File
@@ -0,0 +1,11 @@
LIBRARY iDmacDrv32.dll
EXPORTS
iDmacDrvOpen @1
iDmacDrvClose @2
iDmacDrvDmaRead @3
iDmacDrvDmaWrite @4
iDmacDrvRegisterRead @5
iDmacDrvRegisterWrite @6
iDmacDrvRegisterBufferRead @7
iDmacDrvRegisterBufferWrite @8
iDmacDrvProgramDownload @13
+6
View File
@@ -0,0 +1,6 @@
LIBRARY kernel32.dll
EXPORTS
CreateFileA
SetFilePointer
WriteFile
CloseHandle
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env python3
import argparse
import struct
import subprocess
from pathlib import Path
def main() -> int:
parser = argparse.ArgumentParser(description="Convert a GC menu DDS jacket to PSP RGBA4444")
parser.add_argument("input_dds")
parser.add_argument("output_orpj")
args = parser.parse_args()
command = [
"ffmpeg", "-hide_banner", "-loglevel", "error", "-i", args.input_dds,
"-vf", "crop=197:197:0:0,scale=128:128:flags=lanczos",
"-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "rgba", "-",
]
result = subprocess.run(command, stdout=subprocess.PIPE, check=False)
expected = 128 * 128 * 4
if result.returncode != 0 or len(result.stdout) != expected:
raise SystemExit(f"ffmpeg produced {len(result.stdout)} bytes, expected {expected}")
pixels = bytearray(128 * 128 * 2)
for index in range(128 * 128):
r, g, b, a = result.stdout[index * 4:index * 4 + 4]
value = (r >> 4) | ((g >> 4) << 4) | ((b >> 4) << 8) | ((a >> 4) << 12)
struct.pack_into("<H", pixels, index * 2, value)
output = Path(args.output_orpj)
output.parent.mkdir(parents=True, exist_ok=True)
header = struct.pack("<4sHHHHI", b"ORPJ", 1, 128, 128, 0, len(pixels))
output.write_bytes(header + pixels)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+263
View File
@@ -0,0 +1,263 @@
#!/usr/bin/env python3
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "GC" / "game471.exe"
FULL_DST = ROOT / "GC" / "game471_winefix.exe"
GUARD_DST = ROOT / "GC" / "game471_guard.exe"
NESYSKIP_DST = ROOT / "GC" / "game471_nesyskip.exe"
OFFLINE_DST = ROOT / "GC" / "game471_offline.exe"
BOOTSKIP_DST = ROOT / "GC" / "game471_bootskip.exe"
LOCAL_PATCHES = (
# VA 0x004e2e74 / 0x004e2f1d: skip two calls through null [esi+0x1d8].
(
0x0E2274,
bytes.fromhex("8b 10 8b 92 88 00 00 00 ff d2"),
bytes.fromhex("83 c4 0c eb 05 90 90 90 90 90"),
),
(
0x0E231D,
bytes.fromhex("8b 10 8b 92 88 00 00 00 ff d2"),
bytes.fromhex("83 c4 0c eb 05 90 90 90 90 90"),
),
# VA 0x004e3df4 / 0x004e3e3a: force fallback path for the same missing object.
(0x0E31F4, bytes.fromhex("74 19"), bytes.fromhex("eb 19")),
(0x0E323A, bytes.fromhex("74 52"), bytes.fromhex("eb 52")),
# VA 0x004e460e: skip renderer/state block when the object is absent under Wine.
(
0x0E3A0E,
bytes.fromhex("8b 86 d8 01 00 00"),
bytes.fromhex("e9 57 00 00 00 90"),
),
# VA 0x004e499d: skip another matrix upload through the same absent object.
(
0x0E3D9D,
bytes.fromhex("8b 86 d8 01 00 00"),
bytes.fromhex("e9 18 00 00 00 90"),
),
# VA 0x004e4bf0: skip final absent-object flush in the same draw/update method.
(
0x0E3FF0,
bytes.fromhex("8b b6 d8 01 00 00"),
bytes.fromhex("e9 0c 00 00 00 90"),
),
)
FULL_ONLY_PATCHES = (
# VA 0x004e72d9: do not enable the optional path that expects [esi+0x1d8].
(
0x0E66D9,
bytes.fromhex("c6 86 d4 05 00 00 01"),
bytes.fromhex("c6 86 d4 05 00 00 00"),
),
)
NESYS_SKIP_PATCHES = (
# VA 0x006391c3: boot state 6 waits for CNesysBase+0x250 before it can
# leave the "Starting NESYS" screen. For offline Wine runs we skip that
# wait state and let the boot state machine continue to state 7.
(
0x2385C3,
bytes.fromhex(
"e8 58 29 00 00 8b c8 e8 71 de dc ff 85 c0 74 7f e8 28"
),
bytes.fromhex(
"8b 85 f0 fe ff ff c7 40 08 07 00 00 00 e9 8f 01 00 00"
),
),
)
FREEPLAY_PATCHES = (
# VA 0x00634570: force the credit controller's free-play predicate.
(
0x233970,
bytes.fromhex("55 8b ec 51 89 4d fc e8 e4 cc dc ff"),
bytes.fromhex("b0 01 c3 90 90 90 90 90 90 90 90 90"),
),
)
BOOT_IO_SKIP_PATCHES = (
# VA 0x00552f90 / 0x00553120 / 0x005532b0: adjacent serial/input
# self-tests for missing cabinet devices. They use the same global serial
# backend and otherwise leave the boot screen on I/O Device Error 1.
(
0x152390,
bytes.fromhex("55 8b ec 81 ec 90 00 00 00"),
bytes.fromhex("b0 01 c3 90 90 90 90 90 90"),
),
(
0x152520,
bytes.fromhex("55 8b ec 81 ec 90 00 00 00"),
bytes.fromhex("b0 01 c3 90 90 90 90 90 90"),
),
(
0x1526B0,
bytes.fromhex("55 8b ec 81 ec 90 00 00 00"),
bytes.fromhex("b0 01 c3 90 90 90 90 90 90"),
),
# VA 0x00553410: boot state 19 calls this I/O board self-test and advances
# only when AL is non-zero. The real FAST IO HUB is absent under Wine, so
# bypass this gate to keep moving toward the actual game/runtime code.
(
0x152810,
bytes.fromhex("55 8b ec 81 ec 50 01 00 00"),
bytes.fromhex("b0 01 c3 90 90 90 90 90 90"),
),
# VA 0x00633240: boot state 7 checks a low-level input-device readiness
# flag and posts error 0x302 when it is false. Under Wine there is no
# cabinet input backend, so let the boot sequence continue.
(
0x232640,
bytes.fromhex("55 8b ec e8 08 2b e2 ff 33 c9 3b c8 1b c0 f7 d8"),
bytes.fromhex("b8 01 00 00 00 c3 90 90 90 90 90 90 90 90 90 90"),
),
)
RFID_NULL_OK_PATCHES = (
# VA 0x004c3980..0x004c4b20: thin RFIReader/NESiCAReader wrappers all
# dispatch through global 0x7cf334. When the reader object is absent they
# normally return -1, which the boot diagnostics report as I/O Device Error
# 1 / RFID READ WRITE MODULE. Treat the missing reader as "no event/no card"
# instead of a fatal hardware error.
(0x0C2D8A, bytes.fromhex("83 c8 ff c3"), bytes.fromhex("33 c0 90 c3")),
(0x0C2DAA, bytes.fromhex("83 c8 ff c3"), bytes.fromhex("33 c0 90 c3")),
(0x0C2DEA, bytes.fromhex("83 c8 ff c3"), bytes.fromhex("33 c0 90 c3")),
(
0x0C2E2E,
bytes.fromhex("83 c8 ff 8b e5 5d c3"),
bytes.fromhex("33 c0 90 8b e5 5d c3"),
),
(0x0C2E6A, bytes.fromhex("83 c8 ff c3"), bytes.fromhex("33 c0 90 c3")),
(0x0C2E8C, bytes.fromhex("83 c8 ff 5d c3"), bytes.fromhex("33 c0 90 5d c3")),
(0x0C2F7C, bytes.fromhex("83 c8 ff 5d c3"), bytes.fromhex("33 c0 90 5d c3")),
(0x0C302D, bytes.fromhex("83 c8 ff 5d c3"), bytes.fromhex("33 c0 90 5d c3")),
(0x0C318A, bytes.fromhex("83 c8 ff c3"), bytes.fromhex("33 c0 90 c3")),
(0x0C31AC, bytes.fromhex("83 c8 ff 5d c3"), bytes.fromhex("33 c0 90 5d c3")),
(0x0C325A, bytes.fromhex("83 c8 ff c3"), bytes.fromhex("33 c0 90 c3")),
(0x0C327A, bytes.fromhex("83 c8 ff c3"), bytes.fromhex("33 c0 90 c3")),
(0x0C329A, bytes.fromhex("83 c8 ff c3"), bytes.fromhex("33 c0 90 c3")),
(0x0C32DA, bytes.fromhex("83 c8 ff c3"), bytes.fromhex("33 c0 90 c3")),
(0x0C32FA, bytes.fromhex("83 c8 ff c3"), bytes.fromhex("33 c0 90 c3")),
(0x0C33BA, bytes.fromhex("83 c8 ff c3"), bytes.fromhex("33 c0 90 c3")),
(0x0C33FA, bytes.fromhex("83 c8 ff c3"), bytes.fromhex("33 c0 90 c3")),
(0x0C341A, bytes.fromhex("83 c8 ff c3"), bytes.fromhex("33 c0 90 c3")),
(0x0C34DC, bytes.fromhex("83 c8 ff 5d c3"), bytes.fromhex("33 c0 90 5d c3")),
(0x0C364C, bytes.fromhex("83 c8 ff 5d c3"), bytes.fromhex("33 c0 90 5d c3")),
(0x0C380C, bytes.fromhex("83 c8 ff 5d c3"), bytes.fromhex("33 c0 90 5d c3")),
(0x0C39EC, bytes.fromhex("83 c8 ff 5d c3"), bytes.fromhex("33 c0 90 5d c3")),
(0x0C3C2A, bytes.fromhex("83 c8 ff c3"), bytes.fromhex("33 c0 90 c3")),
(0x0C3C6C, bytes.fromhex("83 c8 ff 5d c3"), bytes.fromhex("33 c0 90 5d c3")),
(0x0C3CEA, bytes.fromhex("83 c8 ff c3"), bytes.fromhex("33 c0 90 c3")),
(0x0C3D0C, bytes.fromhex("83 c8 ff 5d c3"), bytes.fromhex("33 c0 90 5d c3")),
(0x0C3D9C, bytes.fromhex("83 c8 ff 5d c3"), bytes.fromhex("33 c0 90 5d c3")),
(0x0C3E2A, bytes.fromhex("83 c8 ff c3"), bytes.fromhex("33 c0 90 c3")),
(0x0C3E6A, bytes.fromhex("83 c8 ff c3"), bytes.fromhex("33 c0 90 c3")),
(0x0C3EAA, bytes.fromhex("83 c8 ff c3"), bytes.fromhex("33 c0 90 c3")),
(0x0C3EEC, bytes.fromhex("83 c8 ff 5d c3"), bytes.fromhex("33 c0 90 5d c3")),
)
BOOT_ERROR_PUBLISH_SKIP_PATCHES = (
# VA 0x00576a00: central boot/test-mode error publisher. Keep diagnostics
# from parking the screen on cabinet hardware errors while we are running
# without the original reader/I/O devices.
(
0x175E00,
bytes.fromhex("55 8b ec 83 3d 84 25 7f 00 00"),
bytes.fromhex("c3 90 90 90 90 90 90 90 90 90"),
),
)
BOOT_RFID_ERROR1_SKIP_PATCHES = (
# VA 0x006394a5: boot state 9 treats status 2 from the reader self-test as
# I/O Device Error 1 / RFID READ WRITE MODULE. Ignore that failed status so
# the boot flow can keep building the runtime objects.
(
0x2388A5,
bytes.fromhex("0f 84 b7 01 00 00"),
bytes.fromhex("90 90 90 90 90 90"),
),
)
BOOT_FASTIO_ERROR_LATCH_PATCHES = (
# VA 0x00455d10: returns the current FAST I/O device error latch from the
# input backend. Under Wine our iDmac shim is still skeletal, so the latch
# reaches boot state 15 as I/O Device Error 3 / FAST IO UNIVERSAL PCB.
# Report "no device error" while we reverse the real DMA protocol.
(
0x055110,
bytes.fromhex("e8 7b 2d 00 00 8b c8 e8 94 3f 00 00 8b 80 34 11 00 00 c3"),
bytes.fromhex("33 c0 c3 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90"),
),
)
COMMON_TOP_LAYER_SKIP_PATCHES = (
# VA 0x006ea20c / 0x006ea59c: keep the common RVB object alive, but start
# it from its hidden init frame instead of jf_com_all. The full common
# frame leaves title/insert fragments at screen origin with Wine's current
# renderer path, while jf_com_ini preserves the later vtable/timer setup.
(0x2E8A0C, b"jf_com_all\x00", b"jf_com_ini\x00"),
(0x2E8D9C, b"jf_com_all\x00", b"jf_com_ini\x00"),
# VA 0x00639710 / 0x0063971e: boot state 11 fades in the common head and
# foot widgets before the game task starts. With the Wine null-renderer
# guards above, parts of that RVB common layer keep rendering at the top
# origin instead of their intended transforms. Keep the playable title/demo
# flow, but do not start those broken top widgets.
(0x238B10, bytes.fromhex("e8 eb bc fb ff"), bytes.fromhex("90 90 90 90 90")),
(0x238B1E, bytes.fromhex("e8 fd bb fb ff"), bytes.fromhex("90 90 90 90 90")),
# VA 0x005f6d16: mode switch helper always starts the insert/credit board.
# That board is the large magenta/top-left INSERT/CREDIT strip in Wine.
# NOP only the insert-board update/start block; leave the common mode
# selection and state latch intact.
(
0x1F6116,
bytes.fromhex(
"6a 01 8b 4d fc e8 e0 fd ff ff 68 a8 a5 6e 00 "
"8b 4d fc 83 c1 54 e8 30 4a e1 ff 8b c8 e8 e9 41 ee ff"
),
bytes.fromhex(
"90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 "
"90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90 90"
),
),
)
def apply_patches(dst: Path, patches: tuple[tuple[int, bytes, bytes], ...]) -> None:
data = bytearray(SRC.read_bytes())
for offset, expected, patch in patches:
found = bytes(data[offset : offset + len(expected)])
if found != expected:
raise SystemExit(
f"unexpected bytes at 0x{offset:x}: "
f"{found.hex(' ')} != {expected.hex(' ')}"
)
data[offset : offset + len(patch)] = patch
dst.write_bytes(data)
dst.chmod(0o755)
print(f"wrote {dst}")
def main() -> None:
apply_patches(GUARD_DST, LOCAL_PATCHES)
apply_patches(FULL_DST, LOCAL_PATCHES + FULL_ONLY_PATCHES)
apply_patches(NESYSKIP_DST, LOCAL_PATCHES + NESYS_SKIP_PATCHES)
apply_patches(OFFLINE_DST, LOCAL_PATCHES + NESYS_SKIP_PATCHES + FREEPLAY_PATCHES)
apply_patches(
BOOTSKIP_DST,
LOCAL_PATCHES
+ FULL_ONLY_PATCHES
+ NESYS_SKIP_PATCHES
+ FREEPLAY_PATCHES
+ BOOT_IO_SKIP_PATCHES
+ RFID_NULL_OK_PATCHES
+ BOOT_ERROR_PUBLISH_SKIP_PATCHES
+ BOOT_RFID_ERROR1_SKIP_PATCHES
+ BOOT_FASTIO_ERROR_LATCH_PATCHES
+ COMMON_TOP_LAYER_SKIP_PATCHES,
)
if __name__ == "__main__":
main()
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env sh
set -eu
repo_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
stage_path=${1:-"$repo_dir/GC/data/stage/ac_10pt8tion_easy.dat"}
output_dir=${2:-"$repo_dir/dist/PSP/GAME/OpenRoller"}
audio_path=${3:-}
cmake -S "$repo_dir" -B "$repo_dir/build"
cmake --build "$repo_dir/build" --target openroller-psp-pack
"$repo_dir/tools/build_psp.sh"
mkdir -p "$output_dir"
cp "$repo_dir/psp/EBOOT.PBP" "$output_dir/EBOOT.PBP"
"$repo_dir/build/openroller-psp-pack" "$stage_path" "$output_dir/stage.orps"
if [ -z "$audio_path" ]; then
stage_name=$(basename "$stage_path" .dat)
song_token=${stage_name#ac_}
song_token=${song_token%_easy}
song_token=${song_token%_normal}
song_token=${song_token%_hard}
song_token=${song_token%_extra}
audio_path=$(find "$(dirname "$stage_path")/sound" -maxdepth 1 -type f \
-iname "*_${song_token}_BGM.wav" -print -quit)
fi
if [ -n "$audio_path" ] && [ -f "$audio_path" ]; then
ffmpeg -y -hide_banner -loglevel error -i "$audio_path" \
-map 0:a:0 -ar 44100 -ac 2 -c:a libmp3lame -b:a 128k \
-write_xing 0 -id3v2_version 0 "$output_dir/audio.mp3"
else
echo "Warning: BGM WAV was not found; demo will use the fallback timer" >&2
fi
echo "Prepared: $output_dir"
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env sh
set -eu
repo_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
gc_root=${1:-"$repo_dir/GC"}
output_dir=${2:-"$repo_dir/dist/PSP/GAME/OpenRoller"}
# Curated for ASCII titles, available BGM/jackets, the decoded one-mesh TUMO
# layout, and the PSP-1000 per-stage object/memory budgets.
songs="10pt8tion oshama bonetrousle shadow planet departure journey spacearc altale analysis acid2 agentcrisis aou-flower adr 7days satis mikumiku syositu2 world2 rollingirl2 uraomote unknown redial tellyour umiyuri karakuri dappo echo vampire pa3"
cmake -S "$repo_dir" -B "$repo_dir/build"
cmake --build "$repo_dir/build" --target openroller-psp-pack openroller-psp-catalog
"$repo_dir/tools/build_psp.sh"
mkdir -p "$output_dir/songs"
# A catalog build supersedes the old one-song fallback payload.
rm -f "$output_dir/stage.orps" "$output_dir/audio.mp3" "$output_dir/shot.mp3"
cp "$repo_dir/psp/EBOOT.PBP" "$output_dir/EBOOT.PBP"
mkdir -p "$output_dir/sounds"
ffmpeg -y -hide_banner -loglevel error -i "$gc_root/data/sound/SE_ARRANGE.wav" \
-filter:a "volume=0.87" -ar 44100 -ac 2 -f s16le "$output_dir/sounds/adlib.pcm"
ffmpeg -y -hide_banner -loglevel error -i "$gc_root/data/sound/TAP_SE1.wav" \
-filter:a "volume=0.80" -ar 44100 -ac 2 -f s16le "$output_dir/sounds/tap1.pcm"
ffmpeg -y -hide_banner -loglevel error -i "$gc_root/data/sound/TAP_SE2.wav" \
-filter:a "volume=0.77" -ar 44100 -ac 2 -f s16le "$output_dir/sounds/tap2.pcm"
manifest=$(mktemp)
trap 'rm -f "$manifest"' EXIT HUP INT TERM
"$repo_dir/build/openroller-psp-catalog" \
"$gc_root/data/boot/stage_param.dat" "$output_dir/catalog.orpc" $songs > "$manifest"
tab=$(printf '\t')
while IFS="$tab" read -r token image difficulty chart bgm_file shot_file bgm_volume shot_volume <&3; do
song_dir="$output_dir/songs/$token"
mkdir -p "$song_dir"
rm -f "$song_dir/audio.mp3" "$song_dir/shot.mp3"
"$repo_dir/build/openroller-psp-pack" \
"$gc_root/data/stage/$chart.dat" "$song_dir/$difficulty.orps"
bgm_audio="$gc_root/data/stage/sound/$bgm_file"
shot_audio="$gc_root/data/stage/sound/$shot_file"
if [ ! -f "$bgm_audio" ]; then
echo "$token/$difficulty: missing BGM $bgm_audio" >&2
exit 1
fi
if [ ! -f "$shot_audio" ]; then
echo "$token/$difficulty: missing SHOT $shot_audio" >&2
exit 1
fi
# Real PSP-1000 hardware stutters when sceMp3 is asked to decode both
# authored stems while rendering a stage. Pre-mix them once and feed the
# runtime a single stream. The limiter has latency compensation enabled,
# so it does not move chart timing.
rm -f "$song_dir/${difficulty}_shot.mp3"
ffmpeg -y -hide_banner -loglevel error \
-i "$bgm_audio" -i "$shot_audio" \
-filter_complex \
"[0:a]volume=$bgm_volume/100[bgm];[1:a]volume=$shot_volume/100[shot];[bgm][shot]amix=inputs=2:duration=longest:normalize=0,alimiter=limit=0.95:attack=5:release=50:level=0:latency=1[mix]" \
-map "[mix]" -ar 44100 -ac 2 -c:a libmp3lame -b:a 128k \
-write_xing 0 -id3v2_version 0 "$song_dir/${difficulty}_bgm.mp3"
jacket="$gc_root/data/stage/2d/eng/${image}_menu.dds"
[ -f "$jacket" ] || jacket="$gc_root/data/stage/2d/${image}_menu.dds"
if [ ! -f "$jacket" ]; then
echo "$token: missing jacket $jacket" >&2
exit 1
fi
if [ ! -f "$song_dir/jacket.orpj" ]; then
"$repo_dir/tools/pack_psp_jacket.py" "$jacket" "$song_dir/jacket.orpj"
fi
done 3< "$manifest"
echo "Prepared 30-song PSP library with single-stream per-chart mixes: $output_dir"
+326
View File
@@ -0,0 +1,326 @@
#!/usr/bin/env python3
"""
Tiny helper for static RE of GC/game471.exe:
- Find string xrefs (VA pointers to string bytes in the image)
- Find IAT callsites by scanning for "call [abs]" where abs == IAT VA
- Dump capstone disassembly around hits (no decompilation)
This is intended for format/behavior understanding (clean-room), not patching.
"""
from __future__ import annotations
import argparse
import os
import struct
from dataclasses import dataclass
from typing import Iterable, List, Optional, Tuple
import pefile # type: ignore
from capstone import Cs, CS_ARCH_X86, CS_MODE_32 # type: ignore
@dataclass(frozen=True)
class Section:
name: str
rva: int
vsize: int
raw: int
raw_size: int
def contains_rva(self, rva: int) -> bool:
return self.rva <= rva < self.rva + max(self.vsize, self.raw_size)
def contains_raw(self, off: int) -> bool:
return self.raw <= off < self.raw + self.raw_size
@dataclass(frozen=True)
class PeImage:
path: str
blob: bytes
image_base: int
entry_rva: int
sections: List[Section]
def rva_to_raw(self, rva: int) -> Optional[int]:
for s in self.sections:
if s.contains_rva(rva):
return s.raw + (rva - s.rva)
return None
def raw_to_rva(self, off: int) -> Optional[int]:
for s in self.sections:
if s.contains_raw(off):
return s.rva + (off - s.raw)
return None
def va_to_raw(self, va: int) -> Optional[int]:
return self.rva_to_raw(va - self.image_base)
def raw_to_va(self, off: int) -> Optional[int]:
rva = self.raw_to_rva(off)
return None if rva is None else self.image_base + rva
def load_pe(path: str) -> PeImage:
with open(path, "rb") as f:
blob = f.read()
pe = pefile.PE(path, fast_load=False)
sections: List[Section] = []
for s in pe.sections:
name = s.Name.rstrip(b"\x00").decode("ascii", "replace")
sections.append(
Section(
name=name,
rva=int(s.VirtualAddress),
vsize=int(s.Misc_VirtualSize),
raw=int(s.PointerToRawData),
raw_size=int(s.SizeOfRawData),
)
)
return PeImage(
path=path,
blob=blob,
image_base=int(pe.OPTIONAL_HEADER.ImageBase),
entry_rva=int(pe.OPTIONAL_HEADER.AddressOfEntryPoint),
sections=sections,
)
def find_bytes(haystack: bytes, needle: bytes) -> List[int]:
out: List[int] = []
pos = 0
while True:
i = haystack.find(needle, pos)
if i < 0:
return out
out.append(i)
pos = i + 1
def iter_xrefs_to_va(img: PeImage, target_va: int) -> Iterable[Tuple[int, int]]:
# Returns (xref_raw_off, xref_va) where xref contains little-endian dword == target_va.
pat = struct.pack("<I", target_va & 0xFFFFFFFF)
for raw_off in find_bytes(img.blob, pat):
va = img.raw_to_va(raw_off)
if va is None:
continue
yield (raw_off, va)
def disasm_window(img: PeImage, va: int, before: int, after: int) -> List[str]:
md = Cs(CS_ARCH_X86, CS_MODE_32)
md.detail = False
# Heuristic: try to start at a function prolog to avoid decoding garbage.
start_va = max(img.image_base, va - before)
end_va = va + after
start_off = img.va_to_raw(start_va)
end_off = img.va_to_raw(end_va)
if start_off is None or end_off is None or end_off <= start_off:
return [f"(cannot map VA window {hex(start_va)}..{hex(end_va)})"]
# Scan backward for common prologs.
scan_start = start_off
scan_end = img.va_to_raw(va)
if scan_end is None:
scan_end = start_off
scan = img.blob[scan_start:scan_end]
prologs = [b"\x55\x8b\xec", b"\x8b\xff\x55\x8b\xec"]
best = None
for p in prologs:
j = scan.rfind(p)
if j >= 0:
cand = scan_start + j
if best is None or cand > best:
best = cand
if best is not None:
start_off = best
start_va2 = img.raw_to_va(start_off)
if start_va2 is not None:
start_va = start_va2
code = img.blob[start_off:end_off]
lines: List[str] = []
for ins in md.disasm(code, start_va):
lines.append(f"{ins.address:08x}: {ins.mnemonic:6s} {ins.op_str}")
if not lines:
lines.append("(no instructions decoded)")
return lines
def find_string_va(img: PeImage, s: bytes) -> Optional[int]:
# Find first occurrence in the file and map to VA if inside a section.
# Some strings are stored as "...\n\0" rather than a direct "\0" terminator.
idx = img.blob.find(s)
if idx < 0:
# Try common variants.
if not s.endswith(b"\x00"):
idx = img.blob.find(s + b"\x00")
if idx < 0 and not s.endswith(b"\n"):
idx = img.blob.find(s + b"\n\x00")
if idx < 0 and not s.endswith(b"\x00"):
idx = img.blob.find(s + b"\n")
if idx < 0:
return None
rva = img.raw_to_rva(idx)
if rva is None:
return None
return img.image_base + rva
def list_import_vas(path: str) -> dict[str, int]:
pe = pefile.PE(path, fast_load=False)
pe.parse_data_directories(
directories=[
pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_IMPORT"],
]
)
out: dict[str, int] = {}
for entry in getattr(pe, "DIRECTORY_ENTRY_IMPORT", []):
dll = entry.dll.decode("ascii", "ignore").lower()
for imp in entry.imports:
if not imp.name:
continue
name = imp.name.decode("ascii", "ignore")
key = f"{dll}!{name}"
# imp.address is the VA of the IAT slot (already ImageBase + RVA)
out[key] = int(imp.address)
return out
def find_iat_callsites(img: PeImage, iat_va: int) -> List[int]:
"""
Find call dword ptr [abs32] with abs32 == iat_va.
x86 encoding: FF 15 <imm32>
"""
pat = b"\xFF\x15" + struct.pack("<I", iat_va & 0xFFFFFFFF)
hits = find_bytes(img.blob, pat)
# Return VAs of the *instruction* where it occurs.
out: List[int] = []
for off in hits:
va = img.raw_to_va(off)
if va is not None:
out.append(va)
return out
def find_rel32_callsites_to(img: PeImage, target_va: int) -> List[int]:
"""
Find `E8 rel32` calls that land at target_va. Returns callsite VA.
"""
out: List[int] = []
blob = img.blob
# Limit scan to .text section for speed and relevance.
text = next((s for s in img.sections if s.name == ".text"), None)
if not text:
return out
start = text.raw
end = text.raw + text.raw_size
i = start
while i + 5 <= end:
if blob[i] == 0xE8:
rel = struct.unpack_from("<i", blob, i + 1)[0]
callsite_va = img.raw_to_va(i)
if callsite_va is not None:
next_va = callsite_va + 5
dest = (next_va + rel) & 0xFFFFFFFF
if dest == (target_va & 0xFFFFFFFF):
out.append(callsite_va)
i += 5
else:
i += 1
return out
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("exe", help="Path to game471.exe")
ap.add_argument("--string", default="data/stage/%s.dat", help="String to xref (ASCII).")
ap.add_argument("--before", type=int, default=0x80, help="Bytes before for disasm window.")
ap.add_argument("--after", type=int, default=0x180, help="Bytes after for disasm window.")
ap.add_argument(
"--imports",
action="store_true",
help="Also locate IAT callsites for CreateFileA/ReadFile/SetFilePointer/GetFileSize/CloseHandle.",
)
ap.add_argument(
"--call-xrefs",
default="",
help="Find rel32 E8 callsites to this VA (hex, e.g. 0x65bb00).",
)
ap.add_argument(
"--va-xrefs",
default="",
help="Find dword xrefs to this VA by scanning for a literal little-endian pointer (hex, e.g. 0x4ca320).",
)
args = ap.parse_args()
img = load_pe(args.exe)
print(f"exe: {img.exe if hasattr(img,'exe') else img.path}")
print(f"image_base={hex(img.image_base)} entry_va={hex(img.image_base + img.entry_rva)}")
print("sections:")
for s in img.sections:
print(f" {s.name:8s} rva={hex(s.rva)} raw={hex(s.raw)} size={hex(s.raw_size)}")
needle = args.string.encode("ascii")
s_va = find_string_va(img, needle)
if s_va is None:
print(f"string not found: {args.string!r}")
else:
print(f"\nstring {args.string!r} VA={hex(s_va)}")
xrefs = list(iter_xrefs_to_va(img, s_va))
print(f"xrefs: {len(xrefs)}")
for raw_off, xref_va in xrefs[:20]:
print(f"\n== xref @ {hex(xref_va)} (raw {hex(raw_off)}) ==")
for line in disasm_window(img, xref_va, args.before, args.after)[:80]:
print(line)
if args.imports:
imports = list_import_vas(args.exe)
want = [
"kernel32.dll!CreateFileA",
"kernel32.dll!ReadFile",
"kernel32.dll!SetFilePointer",
"kernel32.dll!GetFileSize",
"kernel32.dll!CloseHandle",
]
for k in want:
va = imports.get(k)
if va is None:
print(f"\nIAT: missing {k}")
continue
hits = find_iat_callsites(img, va)
print(f"\nIAT {k} slot={hex(va)} callsites={len(hits)}")
for h in hits[:10]:
print(f" callsite {hex(h)}")
if hits:
print(f"\n== {k} first callsite disasm ==")
for line in disasm_window(img, hits[0], args.before, args.after)[:80]:
print(line)
if args.call_xrefs:
target_va = int(args.call_xrefs, 16)
hits = find_rel32_callsites_to(img, target_va)
print(f"\nrel32 call xrefs to {hex(target_va)}: {len(hits)}")
for h in hits[:20]:
print(f" callsite {hex(h)}")
if hits:
print("\n== first callsite disasm ==")
for line in disasm_window(img, hits[0], args.before, args.after)[:80]:
print(line)
if args.va_xrefs:
target_va = int(args.va_xrefs, 16)
xrefs = list(iter_xrefs_to_va(img, target_va))
print(f"\ndword xrefs to {hex(target_va)}: {len(xrefs)}")
for raw_off, xref_va in xrefs[:20]:
print(f"\n== xref @ {hex(xref_va)} (raw {hex(raw_off)}) ==")
for line in disasm_window(img, xref_va, args.before, args.after)[:80]:
print(line)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
GC_DIR="$ROOT/GC"
PROTON_DIR="${PROTON_DIR:-}"
if [[ -n "$PROTON_DIR" ]]; then
WINE="${WINE:-$PROTON_DIR/files/bin/wine}"
WINESERVER="${WINESERVER:-$PROTON_DIR/files/bin/wineserver}"
else
WINE="${WINE:-$(command -v wine || true)}"
WINESERVER="${WINESERVER:-$(command -v wineserver || true)}"
fi
WINEPREFIX_DIR="$ROOT/.proton-gc/pfx"
LOCALE_ROOT="/tmp/gc-locale"
if [[ -z "$WINE" || ! -x "$WINE" ]]; then
echo "Wine was not found; set WINE or PROTON_DIR" >&2
exit 1
fi
if [[ ! -d "$LOCALE_ROOT/usr/lib/locale/ja_JP.utf8" ]]; then
mkdir -p "$LOCALE_ROOT/usr/lib/locale"
localedef --no-archive --prefix="$LOCALE_ROOT" -i ja_JP -f UTF-8 ja_JP.UTF-8
fi
export WINEPREFIX="$WINEPREFIX_DIR"
export LOCPATH="$LOCALE_ROOT/usr/lib/locale"
export LANG=ja_JP.UTF-8
export LC_ALL=ja_JP.UTF-8
export DXVK_LOG_LEVEL="${DXVK_LOG_LEVEL:-none}"
if [[ -n "$WINESERVER" && -x "$WINESERVER" ]]; then
"$WINESERVER" -k >/dev/null 2>&1 || true
fi
cd "$GC_DIR"
exec "$WINE" "$GC_DIR/game471.exe"
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
build_dir="$repo_root/build"
cmake -S "$repo_root" -B "$build_dir"
cmake --build "$build_dir" -j4 --target openroller-desktop
player="$build_dir/apps/desktop/OpenRoller"
if [[ $# -gt 0 ]]; then
stage_arg="$1"
if [[ ! -f "$stage_arg" ]]; then
printf 'stage file not found: %s\n' "$stage_arg" >&2
exit 2
fi
stage_path="$(realpath "$stage_arg")"
cd "$build_dir/apps/desktop"
exec "$player" "$stage_path"
fi
cd "$build_dir/apps/desktop"
exec "$player" --menu "$repo_root/GC"
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
printf 'Usage: %s <romfs-dir> <chart-id> [OpenRoller options...]\n' "$0" >&2
printf ' %s <romfs-dir> --list\n' "$0" >&2
printf 'Example: %s /tmp/waiwai/romfs sw_adr_hard_1\n' "$0" >&2
}
if [[ $# -lt 2 ]]; then
usage
exit 2
fi
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
romfs_dir="$(realpath "$1")"
chart_id="$2"
shift 2
if [[ ! -d "$romfs_dir/stage/data_gz" || ! -d "$romfs_dir/model" ]]; then
printf 'Not a decoded Wai Wai Party RomFS: %s\n' "$romfs_dir" >&2
exit 2
fi
if [[ ! "$chart_id" =~ ^[A-Za-z0-9_-]+$ ]]; then
printf 'Invalid chart id: %s\n' "$chart_id" >&2
exit 2
fi
source_dir="$romfs_dir/stage/data_gz"
if [[ "$chart_id" == "--list" ]]; then
find "$source_dir" -maxdepth 1 -type f -name '*.dat.gz' \
! -name '*_clip.dat.gz' ! -name '*_ext.dat.gz' -printf '%f\n' |
sed 's/\.dat\.gz$//' | sort
exit 0
fi
source_dat="$source_dir/$chart_id.dat.gz"
if [[ ! -f "$source_dat" ]]; then
printf 'Chart not found: %s\n' "$source_dat" >&2
printf 'Try one of:\n' >&2
find "$source_dir" -maxdepth 1 -type f -name '*.dat.gz' -printf ' %f\n' |
sed 's/\.dat\.gz$//' | head -20 >&2
exit 2
fi
cache_root="${OPENROLLER_NSW_CACHE:-$repo_root/build/nsw_runtime}"
chart_root="$cache_root/$chart_id"
stage_dir="$chart_root/stage"
sound_dir="$stage_dir/sound"
mkdir -p "$stage_dir" "$sound_dir"
stage_dat="$stage_dir/$chart_id.dat"
gzip -dc "$source_dat" > "$stage_dat"
for suffix in _ext _clip; do
source_file="$source_dir/$chart_id$suffix.dat.gz"
if [[ -f "$source_file" ]]; then
gzip -dc "$source_file" > "$stage_dir/$chart_id$suffix.dat"
fi
done
ln -sfn "$romfs_dir/model" "$chart_root/model"
if [[ -f "$romfs_dir/config/system.cfg" ]]; then
ln -sfn "$romfs_dir/config/system.cfg" "$chart_root/system.cfg"
fi
probe="$repo_root/build/openroller-stage-probe"
if [[ ! -x "$probe" ]]; then
cmake -S "$repo_root" -B "$repo_root/build"
cmake --build "$repo_root/build" -j4 --target openroller-stage-probe
fi
probe_output="$($probe "$stage_dat")"
bgm_name="$(printf '%s\n' "$probe_output" | sed -n 's/.* bgm=\([^ ]*\).*/\1/p' | head -1)"
opus_file=""
if [[ -n "$bgm_name" && "$bgm_name" =~ ^[A-Za-z0-9_-]+$ ]]; then
opus_file="$(find "$romfs_dir/stage/sound" -maxdepth 1 -type f \
-iname "*$bgm_name*.opus" -print -quit)"
fi
# A few shipped charts retain authoring-time BGM tokens such as
# `tayutau_2mix` or `shiva_2mix仮.mp3`. The release audio is consistently
# named from the song part of the Switch chart ID, so use it as the same
# fallback that the game's stage catalog effectively supplies.
if [[ -z "$opus_file" ]]; then
chart_stem="${chart_id#sw_}"
chart_stem="$(printf '%s\n' "$chart_stem" |
sed -E 's/_(easy|normal|hard|mas)_[12]$//')"
if [[ "$chart_id" == "sw_tutorial_1" ]]; then
chart_stem="tutorial_basic"
fi
opus_file="$(find "$romfs_dir/stage/sound" -maxdepth 1 -type f \
-iname "*_${chart_stem}_*.opus" -print -quit)"
fi
if [[ -n "$opus_file" ]]; then
wav_file="$sound_dir/$(basename "${opus_file%.opus}")"
if [[ ! -s "$wav_file" || "$opus_file" -nt "$wav_file" ]]; then
vgmstream_cli="${VGMSTREAM_CLI:-}"
if [[ -z "$vgmstream_cli" ]] && command -v vgmstream-cli >/dev/null 2>&1; then
vgmstream_cli="$(command -v vgmstream-cli)"
fi
if [[ -z "$vgmstream_cli" && -x "$repo_root/build/vgmstream-cli" ]]; then
vgmstream_cli="$repo_root/build/vgmstream-cli"
fi
if [[ -z "$vgmstream_cli" ]]; then
printf 'vgmstream-cli is required to decode Nintendo Switch OPUS audio.\n' >&2
printf 'Install it or set VGMSTREAM_CLI=/path/to/vgmstream-cli.\n' >&2
exit 1
fi
printf 'Decoding audio: %s\n' "$(basename "$opus_file")"
"$vgmstream_cli" -i -o "$wav_file" "$opus_file"
fi
else
printf 'No Opus BGM matched stage token %s; running without audio.\n' "$bgm_name" >&2
fi
player="$repo_root/build/apps/desktop/OpenRoller"
cmake -S "$repo_root" -B "$repo_root/build"
cmake --build "$repo_root/build" -j4 --target openroller-desktop
printf '%s\n' "$probe_output"
printf 'Prepared Switch chart: %s\n' "$stage_dat"
cd "$repo_root/build/apps/desktop"
exec "$player" "$stage_dat" "$@"
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Validate and summarize the exact game471 stage-note wire format."""
from __future__ import annotations
import argparse
import collections
import pathlib
import struct
NOTE = struct.Struct(">IBB9hB3fBB4f3IfI5fI")
TYPE_NAMES = (
"NONE", "NORMAL", "FLICK", "HOLD", "SCRATCH", "BEAT", "MERRY GO ROUND", "HIDDEN",
"HIDDEN2", "CRITICAL", "SLIDE HOLD", "SLIDE COUNTER", "TURN", "SPIN", "FINISH", "DUAL HOLD",
)
def u32be(blob: bytes, off: int) -> int:
return struct.unpack_from(">I", blob, off)[0]
def decode(path: pathlib.Path):
blob = path.read_bytes()
if len(blob) < 20:
raise ValueError("short header")
notes_off = u32be(blob, 12)
camera_off = u32be(blob, 16)
if not (0 <= notes_off < camera_off <= len(blob)):
raise ValueError("invalid note/camera offsets")
off = notes_off
name_count = u32be(blob, off)
off += 4
names = []
for _ in range(name_count):
if off >= camera_off:
raise ValueError("truncated note name")
size = blob[off]
off += 1
if off + size > camera_off:
raise ValueError("truncated note name bytes")
names.append(blob[off : off + size].rstrip(b"\0").decode("utf-8", "replace"))
off += size
if off + 4 > camera_off:
raise ValueError("missing note count")
count = u32be(blob, off)
off += 4
expected = off + count * NOTE.size
if expected != camera_off:
raise ValueError(f"size mismatch: count={count}, expected=0x{expected:x}, camera=0x{camera_off:x}")
return names, [NOTE.unpack_from(blob, off + i * NOTE.size) for i in range(count)]
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("path", type=pathlib.Path, help="stage .dat or directory")
parser.add_argument("--samples", action="store_true", help="show one decoded record per raw type")
args = parser.parse_args()
if args.path.is_dir():
paths = [
p for p in sorted(args.path.glob("*.dat"))
if not p.name.endswith(("_ext.dat", "_clip.dat"))
]
else:
paths = [args.path]
types = collections.Counter()
name_counts = collections.Counter()
override_count = 0
overrides = collections.Counter()
note_count = 0
samples = {}
failures = []
for path in paths:
try:
names, notes = decode(path)
except (OSError, ValueError, struct.error) as exc:
failures.append((path, str(exc)))
continue
name_counts[len(names)] += 1
note_count += len(notes)
for note in notes:
raw_type = note[1]
types[raw_type] += 1
override_count += note[2] != 0
if note[2] != 0:
overrides[(raw_type, note[2])] += 1
samples.setdefault(raw_type, (path, note))
print(f"record_size={NOTE.size}")
print(f"files={len(paths)} valid={len(paths) - len(failures)} invalid={len(failures)} notes={note_count}")
print("name_counts=" + " ".join(f"{key}:{value}" for key, value in sorted(name_counts.items())))
print("types=" + " ".join(
f"0x{key:02x}/{TYPE_NAMES[key] if key < len(TYPE_NAMES) else 'UNKNOWN'}:{value}"
for key, value in sorted(types.items())
))
print(f"type_override_nonzero={override_count}")
print("overrides=" + " ".join(
f"0x{raw:02x}/0x{override:02x}:{count}"
for (raw, override), count in sorted(overrides.items())
))
if args.samples:
for raw_type, (path, note) in sorted(samples.items()):
print(f"sample 0x{raw_type:02x} {path.name}: {note}")
for path, error in failures[:10]:
print(f"invalid {path.name}: {error}")
return 1 if failures else 0
if __name__ == "__main__":
raise SystemExit(main())
+316
View File
@@ -0,0 +1,316 @@
#!/usr/bin/env python3
"""Scan GC stage .dat containers and summarize likely 16-byte event streams.
This is a data-driven helper: it does not modify files.
Assumed event layout (big endian):
u16 id, f32 timestamp, u16 a, u16 type, f32 value, u16 b
Heuristics are intentionally conservative to reduce false positives.
"""
from __future__ import annotations
import argparse
import math
import os
import re
import struct
from dataclasses import dataclass
from typing import Dict, Iterable, List, Optional, Tuple
def u16be(b: bytes, off: int) -> int:
return (b[off] << 8) | b[off + 1]
def u32be(b: bytes, off: int) -> int:
return (b[off] << 24) | (b[off + 1] << 16) | (b[off + 2] << 8) | b[off + 3]
def f32be(b: bytes, off: int) -> float:
u = u32be(b, off)
return struct.unpack(">f", struct.pack(">I", u))[0]
def is_finite(x: float) -> bool:
return not (math.isnan(x) or math.isinf(x))
def plausible_ts(ts: float) -> bool:
return is_finite(ts) and -1.0 <= ts <= 1.0e6
def plausible_val(v: float) -> bool:
return is_finite(v) and abs(v) <= 1.0e7
@dataclass
class Section:
start: int
end: int
@dataclass
class BestStream:
section_index: int
start: int
end: int
alignment: int
score: int
event_count: int
pad_zero_ratio: float
def score_stream(b: bytes, start: int, end: int) -> Tuple[int, int, float]:
if end <= start:
return (0, 0, 0.0)
length = end - start
length -= length % 16
if length < 16 * 10:
return (0, 0, 0.0)
n = min(length // 16, 200)
score = 0
pad_fields = 0
pad_zeros = 0
plausible = 0
type_nonzero = 0
ts_nonzero = 0
ts_changes = 0
ts_backwards = 0
have_prev_ts = False
prev_ts = 0.0
for i in range(n):
off = start + i * 16
if off + 16 > len(b):
break
event_id = u16be(b, off + 0)
ts = f32be(b, off + 2)
a = u16be(b, off + 6)
typ = u16be(b, off + 8)
val = f32be(b, off + 10)
bb = u16be(b, off + 14)
pad_fields += 2
if a == 0:
pad_zeros += 1
if bb == 0:
pad_zeros += 1
if event_id == 0:
score += 1
if a == 0:
score += 2
if bb == 0:
score += 2
if typ not in (0x0000, 0xFFFF):
score += 1
type_nonzero += 1
if 0 < typ < 0x4000:
score += 1
if plausible_ts(ts):
score += 2
plausible += 1
if plausible_val(val):
score += 1
if ts != 0.0:
ts_nonzero += 1
if have_prev_ts:
if ts < prev_ts:
ts_backwards += 1
if abs(ts - prev_ts) > 1.0e-6:
ts_changes += 1
else:
have_prev_ts = True
prev_ts = ts
pad_zero_ratio = (pad_zeros / pad_fields) if pad_fields else 0.0
if plausible < (n // 4):
score //= 2
if type_nonzero < (n // 4):
score //= 2
if ts_nonzero < (n // 4):
score //= 2
if ts_changes < (n // 8):
score //= 2
if ts_backwards > (n // 20):
score //= 2
return (score, n, pad_zero_ratio)
def best_alignment(b: bytes, start: int, end: int) -> Tuple[int, int, int, float]:
best = (0, 0, 0, 0.0) # align, score, n, pad_ratio
for align in range(16):
s, n, pad = score_stream(b, start + align, end)
if s > best[1]:
best = (align, s, n, pad)
return best
def parse_stage_dat(path: str) -> Tuple[bytes, int, List[Section]]:
with open(path, "rb") as f:
blob = f.read()
if len(blob) < 4:
raise ValueError("file too small")
header_size = u32be(blob, 0)
if header_size < 4 or header_size > len(blob) or (header_size % 4) != 0:
raise ValueError(f"invalid header_size={header_size}")
words = [u32be(blob, off) for off in range(0, header_size, 4)]
offsets = []
for w in words:
if header_size <= w < len(blob):
offsets.append(w)
offsets.extend([header_size, len(blob)])
offsets = sorted(set(offsets))
sections: List[Section] = []
for i in range(len(offsets) - 1):
a = offsets[i]
c = offsets[i + 1]
if a < c:
sections.append(Section(a, c))
return blob, header_size, sections
@dataclass
class TypeStats:
count: int = 0
ts_min: float = float("inf")
ts_max: float = float("-inf")
val_min: float = float("inf")
val_max: float = float("-inf")
a_min: int = 0xFFFF
a_max: int = 0
b_min: int = 0xFFFF
b_max: int = 0
def add(self, ts: float, val: float, a: int, b: int) -> None:
self.count += 1
if is_finite(ts):
self.ts_min = min(self.ts_min, ts)
self.ts_max = max(self.ts_max, ts)
if is_finite(val):
self.val_min = min(self.val_min, val)
self.val_max = max(self.val_max, val)
self.a_min = min(self.a_min, a)
self.a_max = max(self.a_max, a)
self.b_min = min(self.b_min, b)
self.b_max = max(self.b_max, b)
def iter_events(blob: bytes, start: int, end: int) -> Iterable[Tuple[int, float, int, int, float, int]]:
length = end - start
length -= length % 16
for off in range(start, start + length, 16):
yield (
u16be(blob, off + 0),
f32be(blob, off + 2),
u16be(blob, off + 6),
u16be(blob, off + 8),
f32be(blob, off + 10),
u16be(blob, off + 14),
)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("path", help="Directory with stage .dat files, e.g. GC/data/stage")
ap.add_argument("--regex", default="", help="Only include files whose basename matches this regex (Python).")
ap.add_argument("--min-score", type=int, default=80, help="Reject candidates below this score")
ap.add_argument("--min-events", type=int, default=40, help="Reject candidates with fewer decoded events")
ap.add_argument("--min-pad-zero", type=float, default=0.70, help="Reject candidates with pad-zero ratio below this")
ap.add_argument("--max-files", type=int, default=0, help="Limit scan to N files (0 = no limit)")
args = ap.parse_args()
root = args.path
rx = re.compile(args.regex) if args.regex else None
files = [
os.path.join(root, fn)
for fn in sorted(os.listdir(root))
if fn.lower().endswith(".dat")
and os.path.isfile(os.path.join(root, fn))
and (rx is None or rx.search(fn) is not None)
]
if args.max_files and args.max_files > 0:
files = files[: args.max_files]
type_stats: Dict[int, TypeStats] = {}
total_files = 0
accepted_files = 0
rejected_files = 0
for p in files:
total_files += 1
try:
blob, header_size, sections = parse_stage_dat(p)
except Exception:
rejected_files += 1
continue
best: Optional[BestStream] = None
for i, sec in enumerate(sections):
align, score, n, pad = best_alignment(blob, sec.start, sec.end)
if best is None or score > best.score:
best = BestStream(
section_index=i,
start=sec.start + align,
end=sec.end,
alignment=align,
score=score,
event_count=n,
pad_zero_ratio=pad,
)
if not best:
rejected_files += 1
continue
if best.score < args.min_score or best.event_count < args.min_events or best.pad_zero_ratio < args.min_pad_zero:
rejected_files += 1
continue
accepted_files += 1
for _eid, ts, a, typ, val, bb in iter_events(blob, best.start, best.end):
st = type_stats.get(typ)
if st is None:
st = TypeStats()
type_stats[typ] = st
st.add(ts, val, a, bb)
items = sorted(type_stats.items(), key=lambda kv: kv[1].count, reverse=True)
print(f"files: total={total_files} accepted={accepted_files} rejected={rejected_files}")
print(f"unique event types (u16): {len(items)}")
print("")
print("Top event types by count:")
print(" type count ts[min..max] val[min..max] a[min..max] b[min..max]")
for typ, st in items[:30]:
ts_min = st.ts_min if st.ts_min != float("inf") else float("nan")
ts_max = st.ts_max if st.ts_max != float("-inf") else float("nan")
val_min = st.val_min if st.val_min != float("inf") else float("nan")
val_max = st.val_max if st.val_max != float("-inf") else float("nan")
print(
f" 0x{typ:04x} {st.count:7d} "
f"{ts_min:12.3f}..{ts_max:12.3f} "
f"{val_min:12.3f}..{val_max:12.3f} "
f"0x{st.a_min:04x}..0x{st.a_max:04x} 0x{st.b_min:04x}..0x{st.b_max:04x}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())