Initial public source release
Split reusable rendering and format support into vectorail-core and vectorail-gc.
This commit is contained in:
Executable
+12
@@ -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" "$@"
|
||||
@@ -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:]))
|
||||
@@ -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; }
|
||||
@@ -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
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
LIBRARY kernel32.dll
|
||||
EXPORTS
|
||||
CreateFileA
|
||||
SetFilePointer
|
||||
WriteFile
|
||||
CloseHandle
|
||||
Executable
+39
@@ -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())
|
||||
@@ -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()
|
||||
Executable
+36
@@ -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"
|
||||
Executable
+74
@@ -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"
|
||||
@@ -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())
|
||||
Executable
+37
@@ -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"
|
||||
Executable
+23
@@ -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"
|
||||
Executable
+124
@@ -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" "$@"
|
||||
Executable
+115
@@ -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())
|
||||
Executable
+316
@@ -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())
|
||||
Reference in New Issue
Block a user